@pushpalsdev/cli 1.0.18 → 1.0.20

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 (108) hide show
  1. package/dist/pushpals-cli.js +291 -44
  2. package/package.json +1 -1
  3. package/runtime/configs/backend.toml +1 -1
  4. package/runtime/configs/default.toml +1 -1
  5. package/runtime/sandbox/apps/workerpals/.python-version +1 -0
  6. package/runtime/sandbox/apps/workerpals/Dockerfile.sandbox +71 -0
  7. package/runtime/sandbox/apps/workerpals/package.json +25 -0
  8. package/runtime/sandbox/apps/workerpals/pyproject.toml +8 -0
  9. package/runtime/sandbox/apps/workerpals/src/backends/backend_config.ts +119 -0
  10. package/runtime/sandbox/apps/workerpals/src/backends/miniswe/miniswe_executor.py +2029 -0
  11. package/runtime/sandbox/apps/workerpals/src/backends/miniswe_backend.ts +48 -0
  12. package/runtime/sandbox/apps/workerpals/src/backends/openai_codex/openai_codex_executor.py +1259 -0
  13. package/runtime/sandbox/apps/workerpals/src/backends/openai_codex/test_openai_codex_runtime_config.py +110 -0
  14. package/runtime/sandbox/apps/workerpals/src/backends/openai_codex_backend.ts +67 -0
  15. package/runtime/sandbox/apps/workerpals/src/backends/openhands/openhands_executor.py +563 -0
  16. package/runtime/sandbox/apps/workerpals/src/backends/openhands_backend.ts +161 -0
  17. package/runtime/sandbox/apps/workerpals/src/backends/openhands_task_execute.ts +536 -0
  18. package/runtime/sandbox/apps/workerpals/src/backends/shared/executor_base.py +746 -0
  19. package/runtime/sandbox/apps/workerpals/src/backends/shared/test_settings_resolver.py +60 -0
  20. package/runtime/sandbox/apps/workerpals/src/backends/task_execute_registry.ts +21 -0
  21. package/runtime/sandbox/apps/workerpals/src/backends/types.ts +52 -0
  22. package/runtime/sandbox/apps/workerpals/src/common/execution_utils.ts +149 -0
  23. package/runtime/sandbox/apps/workerpals/src/common/executor_backend.ts +15 -0
  24. package/runtime/sandbox/apps/workerpals/src/common/generic_python_executor.ts +210 -0
  25. package/runtime/sandbox/apps/workerpals/src/common/logger.ts +65 -0
  26. package/runtime/sandbox/apps/workerpals/src/common/types.ts +9 -0
  27. package/runtime/sandbox/apps/workerpals/src/common/worktree_cleanup.ts +66 -0
  28. package/runtime/sandbox/apps/workerpals/src/context_manager.ts +45 -0
  29. package/runtime/sandbox/apps/workerpals/src/docker_executor.ts +1842 -0
  30. package/runtime/sandbox/apps/workerpals/src/execute_job.ts +3063 -0
  31. package/runtime/sandbox/apps/workerpals/src/job_runner.ts +194 -0
  32. package/runtime/sandbox/apps/workerpals/src/shell_manager.ts +210 -0
  33. package/runtime/sandbox/apps/workerpals/src/timeout_policy.ts +24 -0
  34. package/runtime/sandbox/apps/workerpals/src/workerpals_main.ts +1436 -0
  35. package/runtime/sandbox/apps/workerpals/tsconfig.json +15 -0
  36. package/runtime/sandbox/apps/workerpals/uv.lock +2014 -0
  37. package/runtime/sandbox/bun.lock +2591 -0
  38. package/runtime/sandbox/configs/backend.toml +79 -0
  39. package/runtime/sandbox/configs/default.toml +260 -0
  40. package/runtime/sandbox/configs/dev.toml +2 -0
  41. package/runtime/sandbox/configs/local.example.toml +129 -0
  42. package/runtime/sandbox/package.json +65 -0
  43. package/runtime/sandbox/packages/protocol/README.md +168 -0
  44. package/runtime/sandbox/packages/protocol/package.json +37 -0
  45. package/runtime/sandbox/packages/protocol/scripts/copy-schemas.js +17 -0
  46. package/runtime/sandbox/packages/protocol/src/a2a/README.md +52 -0
  47. package/runtime/sandbox/packages/protocol/src/a2a/mapping.ts +55 -0
  48. package/runtime/sandbox/packages/protocol/src/index.browser.ts +25 -0
  49. package/runtime/sandbox/packages/protocol/src/index.ts +25 -0
  50. package/runtime/sandbox/packages/protocol/src/schemas/approvals.schema.json +6 -0
  51. package/runtime/sandbox/packages/protocol/src/schemas/envelope.schema.json +96 -0
  52. package/runtime/sandbox/packages/protocol/src/schemas/events.schema.json +679 -0
  53. package/runtime/sandbox/packages/protocol/src/schemas/http.schema.json +50 -0
  54. package/runtime/sandbox/packages/protocol/src/types.ts +267 -0
  55. package/runtime/sandbox/packages/protocol/src/validate.browser.ts +154 -0
  56. package/runtime/sandbox/packages/protocol/src/validate.ts +233 -0
  57. package/runtime/sandbox/packages/protocol/src/version.ts +1 -0
  58. package/runtime/sandbox/packages/protocol/tsconfig.json +20 -0
  59. package/runtime/sandbox/packages/shared/package.json +19 -0
  60. package/runtime/sandbox/packages/shared/src/autonomy_policy.ts +400 -0
  61. package/runtime/sandbox/packages/shared/src/client_preflight.ts +286 -0
  62. package/runtime/sandbox/packages/shared/src/communication.ts +313 -0
  63. package/runtime/sandbox/packages/shared/src/config.ts +2180 -0
  64. package/runtime/sandbox/packages/shared/src/config_template_parity.ts +70 -0
  65. package/runtime/sandbox/packages/shared/src/git_backend.ts +205 -0
  66. package/runtime/sandbox/packages/shared/src/index.ts +101 -0
  67. package/runtime/sandbox/packages/shared/src/local_network.ts +101 -0
  68. package/runtime/sandbox/packages/shared/src/localbuddy_runtime.ts +314 -0
  69. package/runtime/sandbox/packages/shared/src/prompts.ts +64 -0
  70. package/runtime/sandbox/packages/shared/src/repo.ts +134 -0
  71. package/runtime/sandbox/packages/shared/src/session_event_visibility.ts +25 -0
  72. package/runtime/sandbox/packages/shared/src/vision.ts +247 -0
  73. package/runtime/sandbox/packages/shared/tsconfig.json +16 -0
  74. package/runtime/sandbox/prompts/workerpals/codex_quality_critic_instruction_prompt.md +14 -0
  75. package/runtime/sandbox/prompts/workerpals/commit_message_prompt.md +36 -0
  76. package/runtime/sandbox/prompts/workerpals/commit_message_user_prompt.md +7 -0
  77. package/runtime/sandbox/prompts/workerpals/miniswe_broker_system_prompt.md +33 -0
  78. package/runtime/sandbox/prompts/workerpals/miniswe_broker_task_prompt.md +5 -0
  79. package/runtime/sandbox/prompts/workerpals/miniswe_completion_requirement.md +1 -0
  80. package/runtime/sandbox/prompts/workerpals/miniswe_context_compaction_retry_prompt.md +1 -0
  81. package/runtime/sandbox/prompts/workerpals/miniswe_explicit_targets_block.md +2 -0
  82. package/runtime/sandbox/prompts/workerpals/miniswe_recovery_guidance_base.md +4 -0
  83. package/runtime/sandbox/prompts/workerpals/miniswe_recovery_guidance_blocker_line.md +1 -0
  84. package/runtime/sandbox/prompts/workerpals/miniswe_strict_tool_use_guidance.md +6 -0
  85. package/runtime/sandbox/prompts/workerpals/miniswe_supplemental_guidance_section.md +2 -0
  86. package/runtime/sandbox/prompts/workerpals/miniswe_timeout_note.md +1 -0
  87. package/runtime/sandbox/prompts/workerpals/miniswe_toolcall_retry_guidance.md +1 -0
  88. package/runtime/sandbox/prompts/workerpals/openai_codex_default_system_prompt.md +4 -0
  89. package/runtime/sandbox/prompts/workerpals/openai_codex_instruction_wrapper.md +5 -0
  90. package/runtime/sandbox/prompts/workerpals/openai_codex_runtime_policy_appendix.md +5 -0
  91. package/runtime/sandbox/prompts/workerpals/openai_codex_supplemental_guidance_section.md +2 -0
  92. package/runtime/sandbox/prompts/workerpals/openai_codex_task_execute_system_prompt.md +12 -0
  93. package/runtime/sandbox/prompts/workerpals/openhands_minimal_security_policy.j2 +8 -0
  94. package/runtime/sandbox/prompts/workerpals/openhands_minimal_system_prompt.j2 +20 -0
  95. package/runtime/sandbox/prompts/workerpals/openhands_strict_tool_use_message.md +1 -0
  96. package/runtime/sandbox/prompts/workerpals/openhands_supplemental_guidance_message.md +2 -0
  97. package/runtime/sandbox/prompts/workerpals/openhands_task_execute_fallback_system_prompt.md +1 -0
  98. package/runtime/sandbox/prompts/workerpals/openhands_task_execute_system_prompt.md +21 -0
  99. package/runtime/sandbox/prompts/workerpals/openhands_task_user_prompt.md +6 -0
  100. package/runtime/sandbox/prompts/workerpals/openhands_timeout_note.md +1 -0
  101. package/runtime/sandbox/prompts/workerpals/pr_description.md +42 -0
  102. package/runtime/sandbox/prompts/workerpals/task_quality_critic_system_prompt.md +9 -0
  103. package/runtime/sandbox/prompts/workerpals/task_quality_critic_user_prompt.md +17 -0
  104. package/runtime/sandbox/prompts/workerpals/workerpals_system_prompt.md +115 -0
  105. package/runtime/sandbox/protocol/schemas/approvals.schema.json +6 -0
  106. package/runtime/sandbox/protocol/schemas/envelope.schema.json +96 -0
  107. package/runtime/sandbox/protocol/schemas/events.schema.json +679 -0
  108. package/runtime/sandbox/protocol/schemas/http.schema.json +50 -0
@@ -0,0 +1,314 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { isAbsolute, join, resolve } from "path";
3
+
4
+ export type LocalBuddyRuntimeAction = "start" | "stop" | "noop";
5
+ export type LocalBuddyStartGateReason = "ready" | "backoff" | "retry_exhausted";
6
+
7
+ export type LocalBuddyRuntimeSnapshot = {
8
+ localbuddy: {
9
+ enabled: boolean;
10
+ port: number;
11
+ };
12
+ };
13
+
14
+ export const DEFAULT_LOCALBUDDY_PORT = 3003;
15
+
16
+ const DEFAULT_CONFIG_DIR = "configs";
17
+ const TRUTHY = new Set(["1", "true", "yes", "on"]);
18
+ const FALSY = new Set(["0", "false", "no", "off"]);
19
+
20
+ type LocalBuddyTomlSlice = {
21
+ profile?: string;
22
+ localbuddy?: {
23
+ enabled?: boolean;
24
+ port?: number;
25
+ };
26
+ };
27
+
28
+ export function parseLocalBuddyRuntimeSnapshot(raw: string): LocalBuddyRuntimeSnapshot {
29
+ let parsed: unknown;
30
+ try {
31
+ parsed = JSON.parse(raw);
32
+ } catch {
33
+ throw new Error("runtime config snapshot was not valid JSON");
34
+ }
35
+
36
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
37
+ throw new Error("runtime config snapshot must be a JSON object");
38
+ }
39
+
40
+ const record = parsed as Record<string, unknown>;
41
+ const localbuddyValue = record.localbuddy;
42
+ if (!localbuddyValue || typeof localbuddyValue !== "object" || Array.isArray(localbuddyValue)) {
43
+ throw new Error("runtime config snapshot must include localbuddy");
44
+ }
45
+
46
+ const localbuddy = localbuddyValue as Record<string, unknown>;
47
+ const enabled = Boolean(localbuddy.enabled);
48
+ const port = Number.parseInt(String(localbuddy.port ?? DEFAULT_LOCALBUDDY_PORT), 10);
49
+
50
+ return {
51
+ localbuddy: {
52
+ enabled,
53
+ port:
54
+ Number.isFinite(port) && port >= 1 && port <= 65_535
55
+ ? port
56
+ : DEFAULT_LOCALBUDDY_PORT,
57
+ },
58
+ };
59
+ }
60
+
61
+ export function loadLocalBuddyRuntimeSnapshotFromFiles(
62
+ workspaceRoot: string,
63
+ env: Record<string, string | undefined> = process.env,
64
+ ): LocalBuddyRuntimeSnapshot {
65
+ const envFileValues = readEnvFile(resolve(workspaceRoot, ".env"));
66
+ const mergedEnv: Record<string, string | undefined> = {
67
+ ...env,
68
+ ...envFileValues,
69
+ };
70
+
71
+ const configDirOverride = firstNonEmpty(mergedEnv.PUSHPALS_CONFIG_DIR_OVERRIDE);
72
+ const configDir = resolveRuntimeConfigDir(workspaceRoot, configDirOverride);
73
+ const defaultToml = readRequiredTomlSlice(join(configDir, "default.toml"));
74
+ const preferredProfile = firstNonEmpty(mergedEnv.PUSHPALS_PROFILE, defaultToml.profile, "dev");
75
+ const profileToml = readTomlSlice(join(configDir, `${preferredProfile}.toml`));
76
+ const localExampleToml = readTomlSlice(join(configDir, "local.example.toml"));
77
+ const localToml = readTomlSlice(join(configDir, "local.toml"));
78
+
79
+ const mergedLocalbuddy = {
80
+ ...defaultToml.localbuddy,
81
+ ...profileToml.localbuddy,
82
+ ...localExampleToml.localbuddy,
83
+ ...localToml.localbuddy,
84
+ };
85
+
86
+ const enabled = parseBoolEnv(mergedEnv.LOCALBUDDY_ENABLED) ?? mergedLocalbuddy.enabled ?? false;
87
+ const port =
88
+ parseIntEnv(mergedEnv.LOCAL_AGENT_PORT) ?? mergedLocalbuddy.port ?? DEFAULT_LOCALBUDDY_PORT;
89
+
90
+ return {
91
+ localbuddy: {
92
+ enabled,
93
+ port:
94
+ Number.isFinite(port) && port >= 1 && port <= 65_535
95
+ ? Math.floor(port)
96
+ : DEFAULT_LOCALBUDDY_PORT,
97
+ },
98
+ };
99
+ }
100
+
101
+ export function resolveLocalBuddyRuntimeAction(
102
+ running: boolean,
103
+ enabled: boolean,
104
+ ): LocalBuddyRuntimeAction {
105
+ if (enabled && !running) return "start";
106
+ if (!enabled && running) return "stop";
107
+ return "noop";
108
+ }
109
+
110
+ export function computeLocalBuddyRestartBackoffMs(
111
+ consecutiveFailures: number,
112
+ baseMs = 5_000,
113
+ maxMs = 60_000,
114
+ ): number {
115
+ const safeFailures = Math.max(1, Math.floor(consecutiveFailures));
116
+ const multiplier = 2 ** Math.max(0, safeFailures - 1);
117
+ return Math.min(maxMs, baseMs * multiplier);
118
+ }
119
+
120
+ export function resolveLocalBuddyStartGate(args: {
121
+ nowMs: number;
122
+ retryAfterMs: number;
123
+ consecutiveFailures: number;
124
+ maxConsecutiveFailures: number;
125
+ }): LocalBuddyStartGateReason {
126
+ if (args.consecutiveFailures >= args.maxConsecutiveFailures) {
127
+ return "retry_exhausted";
128
+ }
129
+ if (args.retryAfterMs > args.nowMs) {
130
+ return "backoff";
131
+ }
132
+ return "ready";
133
+ }
134
+
135
+ function firstNonEmpty(...values: Array<string | undefined>): string {
136
+ for (const value of values) {
137
+ const trimmed = String(value ?? "").trim();
138
+ if (trimmed) return trimmed;
139
+ }
140
+ return "";
141
+ }
142
+
143
+ function resolvePathFromRoot(workspaceRoot: string, value: string): string {
144
+ if (!value) return workspaceRoot;
145
+ if (isAbsolute(value)) return resolve(value);
146
+ return resolve(workspaceRoot, value);
147
+ }
148
+
149
+ function resolveRuntimeConfigDir(workspaceRoot: string, configuredDir?: string): string {
150
+ if (configuredDir && configuredDir.trim()) {
151
+ return resolvePathFromRoot(workspaceRoot, configuredDir);
152
+ }
153
+
154
+ return resolvePathFromRoot(workspaceRoot, DEFAULT_CONFIG_DIR);
155
+ }
156
+
157
+ function parseBoolEnv(value: string | undefined): boolean | undefined {
158
+ const text = String(value ?? "").trim().toLowerCase();
159
+ if (!text) return undefined;
160
+ if (TRUTHY.has(text)) return true;
161
+ if (FALSY.has(text)) return false;
162
+ return undefined;
163
+ }
164
+
165
+ function parseIntEnv(value: string | undefined): number | undefined {
166
+ const text = String(value ?? "").trim();
167
+ if (!text) return undefined;
168
+ const parsed = Number.parseInt(text, 10);
169
+ return Number.isFinite(parsed) ? parsed : undefined;
170
+ }
171
+
172
+ function readTomlSlice(path: string): LocalBuddyTomlSlice {
173
+ if (existsSync(path)) return parseTomlSlice(path);
174
+ return {};
175
+ }
176
+
177
+ function readRequiredTomlSlice(path: string): LocalBuddyTomlSlice {
178
+ if (!existsSync(path)) {
179
+ throw new Error(`Missing required runtime config file: ${path}`);
180
+ }
181
+ return parseTomlSlice(path);
182
+ }
183
+
184
+ function parseTomlSlice(path: string): LocalBuddyTomlSlice {
185
+ const text = readFileSync(path, "utf8");
186
+ const result: LocalBuddyTomlSlice = {};
187
+ let currentSection = "";
188
+
189
+ for (const rawLine of text.split(/\r?\n/)) {
190
+ const line = stripTomlComment(rawLine).trim();
191
+ if (!line) continue;
192
+
193
+ const sectionMatch = line.match(/^\[([^\]]+)\]$/);
194
+ if (sectionMatch) {
195
+ currentSection = String(sectionMatch[1] ?? "").trim();
196
+ continue;
197
+ }
198
+
199
+ const entryMatch = line.match(/^([A-Za-z0-9_.-]+)\s*=\s*(.+)$/);
200
+ if (!entryMatch) continue;
201
+
202
+ const key = String(entryMatch[1] ?? "").trim();
203
+ const value = parseTomlScalar(String(entryMatch[2] ?? "").trim());
204
+
205
+ if (!currentSection && key === "profile" && typeof value === "string") {
206
+ result.profile = value;
207
+ continue;
208
+ }
209
+
210
+ if (currentSection !== "localbuddy") continue;
211
+ if (key === "enabled" && typeof value === "boolean") {
212
+ result.localbuddy = { ...(result.localbuddy ?? {}), enabled: value };
213
+ } else if (key === "port" && typeof value === "number") {
214
+ result.localbuddy = { ...(result.localbuddy ?? {}), port: value };
215
+ }
216
+ }
217
+
218
+ return result;
219
+ }
220
+
221
+ function stripTomlComment(line: string): string {
222
+ let inQuote = false;
223
+ let quoteChar = "";
224
+ for (let i = 0; i < line.length; i += 1) {
225
+ const char = line[i];
226
+ if ((char === '"' || char === "'") && (i === 0 || line[i - 1] !== "\\")) {
227
+ if (!inQuote) {
228
+ inQuote = true;
229
+ quoteChar = char;
230
+ } else if (quoteChar === char) {
231
+ inQuote = false;
232
+ quoteChar = "";
233
+ }
234
+ continue;
235
+ }
236
+ if (char === "#" && !inQuote) {
237
+ return line.slice(0, i);
238
+ }
239
+ }
240
+ return line;
241
+ }
242
+
243
+ function parseTomlScalar(value: string): string | number | boolean | null {
244
+ const trimmed = value.trim();
245
+ if (!trimmed) return null;
246
+ if (trimmed === "true") return true;
247
+ if (trimmed === "false") return false;
248
+ if (/^-?\d+$/.test(trimmed)) {
249
+ const parsed = Number.parseInt(trimmed, 10);
250
+ if (Number.isFinite(parsed)) return parsed;
251
+ }
252
+ const quoted = trimmed.match(/^"(.*)"$/) ?? trimmed.match(/^'(.*)'$/);
253
+ if (quoted) {
254
+ return quoted[1] ?? "";
255
+ }
256
+ return trimmed;
257
+ }
258
+
259
+ function readEnvFile(path: string): Record<string, string> {
260
+ if (!existsSync(path)) return {};
261
+ const text = readFileSync(path, "utf8");
262
+ const values: Record<string, string> = {};
263
+ for (const rawLine of text.split(/\r?\n/)) {
264
+ const parsed = parseEnvAssignment(rawLine);
265
+ if (!parsed) continue;
266
+ values[parsed.key] = parsed.value;
267
+ }
268
+ return values;
269
+ }
270
+
271
+ function parseEnvAssignment(line: string): { key: string; value: string } | null {
272
+ const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
273
+ if (!match) return null;
274
+ return {
275
+ key: match[1],
276
+ value: parseEnvValue(match[2] ?? ""),
277
+ };
278
+ }
279
+
280
+ function parseEnvValue(raw: string): string {
281
+ const trimmed = raw.trim();
282
+ if (!trimmed) return "";
283
+ if (trimmed.startsWith("#")) return "";
284
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
285
+ try {
286
+ return JSON.parse(trimmed) as string;
287
+ } catch {
288
+ return trimmed.slice(1, -1);
289
+ }
290
+ }
291
+ if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
292
+ return trimmed.slice(1, -1);
293
+ }
294
+
295
+ let inQuote = false;
296
+ let quoteChar = "";
297
+ for (let i = 0; i < trimmed.length; i += 1) {
298
+ const char = trimmed[i];
299
+ if ((char === '"' || char === "'") && (i === 0 || trimmed[i - 1] !== "\\")) {
300
+ if (!inQuote) {
301
+ inQuote = true;
302
+ quoteChar = char;
303
+ } else if (quoteChar === char) {
304
+ inQuote = false;
305
+ quoteChar = "";
306
+ }
307
+ continue;
308
+ }
309
+ if (char === "#" && !inQuote) {
310
+ return trimmed.slice(0, i).trimEnd();
311
+ }
312
+ }
313
+ return trimmed;
314
+ }
@@ -0,0 +1,64 @@
1
+ import { readFileSync } from "fs";
2
+ import { join, resolve } from "path";
3
+ import { detectRepoRoot } from "./repo.js";
4
+
5
+ const TEMPLATE_TOKEN = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
6
+ const promptTemplateCache = new Map<string, string>();
7
+ const repoDocCache = new Map<string, string>();
8
+
9
+ function resolvePromptPath(relativePath: string): string {
10
+ const promptRootOverride = String(process.env.PUSHPALS_PROMPTS_ROOT_OVERRIDE ?? "").trim();
11
+ const repoRoot = promptRootOverride ? resolve(promptRootOverride) : detectRepoRoot(process.cwd());
12
+ return join(repoRoot, "prompts", relativePath);
13
+ }
14
+
15
+ function resolveRepoDocPath(relativePath: string): string {
16
+ const repoRoot = detectRepoRoot(process.cwd());
17
+ return join(repoRoot, relativePath);
18
+ }
19
+
20
+ export function loadPromptTemplate(
21
+ relativePath: string,
22
+ replacements?: Record<string, string>,
23
+ ): string {
24
+ const promptPath = resolvePromptPath(relativePath);
25
+ let template = promptTemplateCache.get(promptPath);
26
+
27
+ if (template === undefined) {
28
+ template = readFileSync(promptPath, "utf8");
29
+ promptTemplateCache.set(promptPath, template);
30
+ }
31
+
32
+ if (!replacements || Object.keys(replacements).length === 0) {
33
+ return template;
34
+ }
35
+
36
+ return template.replace(TEMPLATE_TOKEN, (_match: string, token: string) => {
37
+ const value = replacements[token];
38
+ if (value === undefined) {
39
+ throw new Error(`[prompts] Missing replacement for "{{${token}}}" in ${promptPath}`);
40
+ }
41
+ return value;
42
+ });
43
+ }
44
+
45
+ export function loadRepoDocText(relativePath: string, opts?: { cache?: boolean }): string {
46
+ const pathValue = String(relativePath ?? "").trim();
47
+ if (!pathValue) {
48
+ throw new Error("[docs] relativePath is required");
49
+ }
50
+
51
+ const docPath = resolveRepoDocPath(pathValue);
52
+ const shouldCache = opts?.cache !== false;
53
+
54
+ if (shouldCache) {
55
+ const cached = repoDocCache.get(docPath);
56
+ if (cached !== undefined) return cached;
57
+ }
58
+
59
+ const text = readFileSync(docPath, "utf8");
60
+ if (shouldCache) {
61
+ repoDocCache.set(docPath, text);
62
+ }
63
+ return text;
64
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Repository utilities for detecting git root and reading context
3
+ */
4
+
5
+ import { existsSync, readFileSync, statSync } from "fs";
6
+ import { resolve } from "path";
7
+
8
+ function resolveDotGitEntry(repoRoot: string): string {
9
+ return resolve(repoRoot, ".git");
10
+ }
11
+
12
+ export function findGitRepoRoot(startDir: string): string | null {
13
+ const override = String(process.env.PUSHPALS_REPO_ROOT_OVERRIDE ?? "").trim();
14
+ if (override) {
15
+ const resolvedOverride = resolve(override);
16
+ if (resolveGitMetadataDir(resolvedOverride)) {
17
+ return resolvedOverride;
18
+ }
19
+ console.warn(
20
+ `[repo] PUSHPALS_REPO_ROOT_OVERRIDE does not point to a git repository: ${resolvedOverride}`,
21
+ );
22
+ }
23
+
24
+ let current = resolve(startDir);
25
+ const root = resolve(current, "/");
26
+
27
+ while (current !== root) {
28
+ if (resolveGitMetadataDir(current)) {
29
+ return current;
30
+ }
31
+ current = resolve(current, "..");
32
+ }
33
+
34
+ return resolveGitMetadataDir(root) ? root : null;
35
+ }
36
+
37
+ export function resolveGitMetadataDir(repoRoot: string): string | null {
38
+ const dotGitPath = resolveDotGitEntry(repoRoot);
39
+ if (!existsSync(dotGitPath)) return null;
40
+
41
+ try {
42
+ const stat = statSync(dotGitPath);
43
+ if (stat.isDirectory()) {
44
+ return dotGitPath;
45
+ }
46
+ if (!stat.isFile()) {
47
+ return null;
48
+ }
49
+ } catch {
50
+ return null;
51
+ }
52
+
53
+ try {
54
+ const firstLine = readFileSync(dotGitPath, "utf8").split(/\r?\n/, 1)[0] ?? "";
55
+ const match = firstLine.match(/^gitdir:\s*(.+)\s*$/i);
56
+ if (!match) return null;
57
+ const gitDir = resolve(repoRoot, match[1].trim());
58
+ return existsSync(gitDir) ? gitDir : null;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ export function resolveGitStateFilePath(repoRoot: string, fileName: string): string | null {
65
+ const gitMetadataDir = resolveGitMetadataDir(repoRoot);
66
+ const normalizedFileName = String(fileName ?? "").trim();
67
+ if (!gitMetadataDir || !normalizedFileName) return null;
68
+ return resolve(gitMetadataDir, normalizedFileName);
69
+ }
70
+
71
+ /**
72
+ * Detect git repository root by walking up from start directory.
73
+ * Returns the directory containing git metadata, or start directory if not found.
74
+ *
75
+ * @param startDir - Directory to start searching from (typically process.cwd())
76
+ * @returns Absolute path to repository root
77
+ */
78
+ export function detectRepoRoot(startDir: string): string {
79
+ const repoRoot = findGitRepoRoot(startDir);
80
+ if (repoRoot) {
81
+ return repoRoot;
82
+ }
83
+
84
+ // Fallback to start directory if no .git found
85
+ console.warn(`[repo] No .git directory found, using: ${startDir}`);
86
+ return startDir;
87
+ }
88
+
89
+ /**
90
+ * Read basic repository context for LLM enhancement.
91
+ * Executes git commands to gather current branch, status, and recent commits.
92
+ *
93
+ * @param repoRoot - Absolute path to repository root
94
+ * @returns Repository context object
95
+ */
96
+ export async function getRepoContext(repoRoot: string): Promise<{
97
+ branch: string;
98
+ status: string;
99
+ recentCommits: string;
100
+ }> {
101
+ const git = async (args: string[]): Promise<string> => {
102
+ const proc = Bun.spawn(["git", ...args], {
103
+ cwd: repoRoot,
104
+ stdout: "pipe",
105
+ stderr: "pipe",
106
+ });
107
+ const stdout = await new Response(proc.stdout).text();
108
+ const exitCode = await proc.exited;
109
+
110
+ if (exitCode !== 0) {
111
+ const stderr = await new Response(proc.stderr).text();
112
+ throw new Error(`git ${args[0]} failed (exit ${exitCode}): ${stderr}`);
113
+ }
114
+
115
+ return stdout.trim();
116
+ };
117
+
118
+ try {
119
+ const [branch, status, recentCommits] = await Promise.all([
120
+ git(["rev-parse", "--abbrev-ref", "HEAD"]).catch(() => "unknown"),
121
+ git(["status", "--porcelain"]).catch(() => "unknown"),
122
+ git(["log", "--oneline", "-n", "5"]).catch(() => "unknown"),
123
+ ]);
124
+
125
+ return { branch, status, recentCommits };
126
+ } catch (err) {
127
+ console.error("[repo] Failed to get repo context:", err);
128
+ return {
129
+ branch: "unknown",
130
+ status: "unknown",
131
+ recentCommits: "unknown",
132
+ };
133
+ }
134
+ }
@@ -0,0 +1,25 @@
1
+ type SessionEventLike = {
2
+ type?: string | null;
3
+ payload?: Record<string, unknown> | null;
4
+ };
5
+
6
+ const HEARTBEAT_STATUS_RE = /\bheartbeat\b/i;
7
+
8
+ export function isHeartbeatStatusSessionEvent(event: SessionEventLike | null | undefined): boolean {
9
+ const type = String(event?.type ?? "")
10
+ .trim()
11
+ .toLowerCase();
12
+ if (type !== "status") return false;
13
+
14
+ const payload = event?.payload ?? {};
15
+ const detail = typeof payload.detail === "string" ? payload.detail.trim() : "";
16
+ const message = typeof payload.message === "string" ? payload.message.trim() : "";
17
+
18
+ return HEARTBEAT_STATUS_RE.test(detail) || HEARTBEAT_STATUS_RE.test(message);
19
+ }
20
+
21
+ export function shouldDisplayInteractiveSessionEvent(
22
+ event: SessionEventLike | null | undefined,
23
+ ): boolean {
24
+ return !isHeartbeatStatusSessionEvent(event);
25
+ }