Skip to content

stack0:feat: add shared contracts and core utility baseline#159

Open
VX1D wants to merge 6 commits intomichaelshimeles:mainfrom
VX1D:stack/pr0-foundation-contracts
Open

stack0:feat: add shared contracts and core utility baseline#159
VX1D wants to merge 6 commits intomichaelshimeles:mainfrom
VX1D:stack/pr0-foundation-contracts

Conversation

@VX1D
Copy link
Copy Markdown
Contributor

@VX1D VX1D commented Mar 3, 2026

PR0: foundation contracts

Summary

This is the stack foundation. It fixes baseline type/contract drift and introduces shared utility primitives used by the rest of the chain.

Why this PR exists

  • main had contract mismatches (especially config/telemetry surfaces).
  • Later PRs depend on common helpers (errors, cleanup, validation, indexing).
  • Putting this in PR0 avoids copy-pasting the same support fixes into every PR.

What it adds

  • Runtime/config contract updates in cli/src/config/types.ts.
  • Telemetry payload type contract updates in cli/src/telemetry/types.ts.
  • Logger state access cleanup in cli/src/ui/logger.ts (live verbose state behavior).
  • New shared utility layer in cli/src/utils/:
    • errors.ts (standardized error normalization)
    • cleanup.ts (shared cleanup routines)
    • json-validation.ts (safe parsing/validation helpers)
    • file-indexer.ts (task/file relevance indexing support)
    • sanitization.ts (path/text sanitization helpers)
    • ai-output-parser.ts (AI output parsing utility)
  • Core constants in cli/src/config/constants.ts.

Impact on later PRs

  • Unblocks execution/planning/telemetry/task PRs without re-adding baseline helpers.
  • Keeps ownership clean: shared contract + shared utilities are centralized here.

Validation

  • Included in cumulative stack verification.
  • Stack passes bun run check, bun tsc --noEmit, and bun test when merged in order.

Note

Add shared contracts and core utility baseline by introducing centralized CLI constants, structured logging sinks, and a file indexing system with JSON cache under .ralphy

Add a constants module for runtime limits and paths, implement structured logging with pluggable sinks and JSON-lines persistence, and add a workspace file indexer with hashing and cached queries; extend config and types for telemetry webhook and RuntimeOptions.debugOpenCode; note that parseJsonLine in cli/src/utils/json-validation.ts contains syntax errors that break the build.

📍Where to Start

Start with structured logging initialization in initializeStructuredLogging in logger.ts, then review the constants in constants.ts and the file indexer flow in file-indexer.ts; address the compile error in parseJsonLine in json-validation.ts.

Macroscope summarized 2dc0883.

@vercel
Copy link
Copy Markdown

vercel bot commented Mar 3, 2026

@VX1D is attempting to deploy a commit to the Goshen Labs Team on Vercel.

A member of the Team first needs to authorize it.

@dosubot
Copy link
Copy Markdown

dosubot bot commented Mar 3, 2026

Related Documentation

2 document(s) may need updating based on files changed in this PR:

Goshen Labs's Space

Copilot AI Engine Integration
View Suggested Changes
@@ -92,9 +92,10 @@
   discord_webhook: "https://discord.com/api/webhooks/..."
   slack_webhook: "https://hooks.slack.com/services/..."
   custom_webhook: "https://your-api.com/webhook"
+  telemetry_webhook: "https://your-api.com/telemetry"  # optional telemetry export hook
 ```
 
-Notifications include task completion counts and status (completed/failed).
+Notifications include task completion counts and status (completed/failed). The `telemetry_webhook` is an optional field for exporting telemetry data to a custom endpoint, similar to how the custom webhook works but specifically for telemetry events.
 
 ---
 

[Accept] [Decline]

Error Handling and Model Override for OpenCode Engine
View Suggested Changes
@@ -69,8 +69,26 @@
 #### Rationale
 Model override allows you to use more capable models for planning and faster or cheaper models for execution, enabling flexible and cost-effective workflows["Rationale"](https://github.com/michaelshimeles/ralphy/pull/32).
 
-### Rationale
-You can now pass arbitrary engine-specific arguments to OpenCode (and all supported engines) using the standard CLI `--` separator. This allows you to leverage advanced features or experimental flags provided by the engine, even if they are not explicitly supported by ralphy's own CLI options.
+### OpenCode Debugging Configuration
+Ralphy provides a `debugOpenCode` runtime option to enable comprehensive debugging output for the OpenCode engine. This option is disabled by default and can be enabled when troubleshooting OpenCode-specific issues.
+
+#### Configuration Methods
+**Via configuration file:**
+
+```yaml
+debugOpenCode: true  # Enable comprehensive OpenCode debugging
+```
+
+**Via CLI flag:**
+
+```bash
+ralphy --debug-opencode --opencode "Implement OAuth2 login"
+```
+
+When enabled, this option provides detailed debugging information about OpenCode engine execution, including command invocation, output streaming, and error handling. This is particularly useful for diagnosing issues with OpenCode integration or investigating unexpected behavior.
+
+### Engine-Specific Arguments
+You can pass arbitrary engine-specific arguments to OpenCode (and all supported engines) using the standard CLI `--` separator. This allows you to leverage advanced features or experimental flags provided by the engine, even if they are not explicitly supported by ralphy's own CLI options.
 
 #### Example: Passing Engine-Specific Arguments
 

[Accept] [Decline]

Note: You must be authenticated to accept/decline updates.

How did I do? Any feedback?  Join Discord

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 3, 2026

Greptile Summary

This PR introduces the shared utility and contract foundation for the Ralphy CLI stack: a structured logging layer (logger.ts), error taxonomy (errors.ts), workspace file indexer (file-indexer.ts), cleanup registry (cleanup.ts), JSON stream event parsing (json-validation.ts), AI output parsing (ai-output-parser.ts), secret sanitization (sanitization.ts), and new constants/type contracts (constants.ts, config/types.ts, telemetry/types.ts).

The foundational intent is sound and the architecture is well-organized, but six concrete issues require attention:

  • createErrorWithContext downcasts to base class (errors.ts): loses error subclass, breaking instanceof checks for TimeoutError, ProcessError, etc.
  • formatParsedStep truncates inconsistently (ai-output-parser.ts): step.raw/step.executed are capped at 100 chars, but step.reading/step.writing/step.running/step.thought are unbounded.
  • Dead !isEscaped guard (json-validation.ts line 132): condition is always true and can be removed.
  • DEFAULT_IGNORE_PATTERNS missing build-artifact dirs (constants.ts): common directories like dist, build, target, __pycache__, coverage are absent; for large monorepos these can add hundreds of thousands of files to the index.
  • WebhookSessionData / WebhookSessionDetails not exported (telemetry/types.ts): these form the public contract of TelemetryWebhookPayload but are private.
  • Duplicate entries in isCommonWord (file-indexer.ts): "try" and "new" each appear twice.

Confidence Score: 3/5

  • Reasonable to merge with fixes for the six flagged issues: error subclass preservation, consistent field truncation, dead code removal, expanded ignore patterns, type exports, and duplicate cleanup.
  • All six comments are concrete, verified findings across critical files (error handling, AI output formatting, JSON parsing, configuration, telemetry contracts, file indexing). Four are logic/correctness issues (subclass loss, inconsistent truncation, dead guard, missing ignore patterns), two are API/style issues (unexported types, duplicate Set entries). None are speculative or deferred. While individually addressable, the breadth of issues across the utility baseline—which all later stack PRs will depend on—warrants careful remediation before merge.
  • cli/src/utils/errors.ts, cli/src/utils/ai-output-parser.ts, cli/src/config/constants.ts, cli/src/utils/file-indexer.ts require fixes for correctness; cli/src/telemetry/types.ts and cli/src/utils/json-validation.ts are minor cleanups.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[CLI / Runtime] -->|"setDebug / setVerbose"| B[logger.ts\nloggerState]
    A -->|"initializeStructuredLogging"| C[MultiLogSink]
    C --> D[ConsoleLogSink]
    C --> E[FilteredLogSink]
    E --> F[JsonFileLogSink\nsetInterval flush timer]

    A -->|"indexWorkspace"| G[file-indexer.ts\nindexCache TTL check]
    G -->|cache miss| H[performIndexing]
    H -->|disk cache hit| I[incrementalUpdateIndex\nasync I/O ✓]
    H -->|no disk cache| J[collectFiles\nreaddirAsync ✓]
    J --> K[createFileIndexEntry\nstatAsync + readFileAsync]
    K --> L[saveIndexToDisk]
    G -->|cache hit & fresh| M[cloneFileIndex → caller]

    A -->|"log(level, ...)"| N{verboseMode?}
    N -->|debug + not verbose| O[drop message]
    N -->|else| P[createLogEntry\nsanitizeSecrets]
    P --> Q[logSink.write]

    A -->|"createErrorWithContext"| R[standardizeError]
    R --> S{instanceof RalphyError?}
    S -->|yes| T[return error as-is]
    S -->|no| U[wrap in RalphyError]
    T --> V[createErrorWithContext wraps in RalphyError ⚠️]
    U --> V

    A -->|"parseJsonLine"| W[boundary scan\nbrace depth counter]
    W --> X[StreamJsonEventSchema.parse]
    X --> Y[StreamJsonEvent]

    A -->|"formatParsedStep"| Z{step.field?}
    Z -->|writing/reading/running| AA[truncated?]
    Z -->|thought/executed/raw| AB[some truncated ✓]
    AA --> AC{inconsistent ⚠️}
    AB --> AC
Loading

Last reviewed commit: 2dc0883

Comment on lines +68 to +74
if (proc.connected || proc.pid) {
proc.kill("SIGKILL");

// Final verification
await new Promise((resolve) => setTimeout(resolve, 500));
if (proc.connected || proc.pid) {
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Process liveness check always evaluates to true — SIGTERM grace period is bypassed

proc.pid is assigned at spawn time and is never cleared when a child process exits. This means if (proc.connected || proc.pid) always evaluates to true for any process that was successfully spawned, regardless of whether it has exited. As a result, SIGKILL is unconditionally sent after every SIGTERM — the 1-second graceful-shutdown window is never actually honored.

The correct check for process liveness is proc.exitCode === null && proc.signalCode === null:

Suggested change
if (proc.connected || proc.pid) {
proc.kill("SIGKILL");
// Final verification
await new Promise((resolve) => setTimeout(resolve, 500));
if (proc.connected || proc.pid) {
}
// Wait a bit and verify it's dead
await new Promise((resolve) => setTimeout(resolve, 1000));
// Check if process is still running
if (proc.exitCode === null && proc.signalCode === null) {
proc.kill("SIGKILL");
// Final verification
await new Promise((resolve) => setTimeout(resolve, 500));
if (proc.exitCode === null && proc.signalCode === null) {
logWarn(`Process ${proc.pid} may still be running after SIGKILL`);
}
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/src/utils/cleanup.ts
Line: 68-74

Comment:
**Process liveness check always evaluates to true — SIGTERM grace period is bypassed**

`proc.pid` is assigned at spawn time and is **never cleared** when a child process exits. This means `if (proc.connected || proc.pid)` always evaluates to `true` for any process that was successfully spawned, regardless of whether it has exited. As a result, SIGKILL is unconditionally sent after every SIGTERM — the 1-second graceful-shutdown window is never actually honored.

The correct check for process liveness is `proc.exitCode === null && proc.signalCode === null`:

```suggestion
// Wait a bit and verify it's dead
await new Promise((resolve) => setTimeout(resolve, 1000));

// Check if process is still running
if (proc.exitCode === null && proc.signalCode === null) {
  proc.kill("SIGKILL");

  // Final verification
  await new Promise((resolve) => setTimeout(resolve, 500));
  if (proc.exitCode === null && proc.signalCode === null) {
    logWarn(`Process ${proc.pid} may still be running after SIGKILL`);
  }
}
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +31 to +32
if (TextSchema.safeParse(event).success) {
const textEvent = TextSchema.parse(event);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Double-parse with safeParse + parse — redundant work

The code validates with safeParse, discards the result, then parses again with parse. This is inefficient — use the .data property from safeParse directly:

Suggested change
if (TextSchema.safeParse(event).success) {
const textEvent = TextSchema.parse(event);
const textResult = TextSchema.safeParse(event);
if (textResult.success) {
const textEvent = textResult.data;
Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/src/utils/ai-output-parser.ts
Line: 31-32

Comment:
**Double-parse with `safeParse` + `parse` — redundant work**

The code validates with `safeParse`, discards the result, then parses again with `parse`. This is inefficient — use the `.data` property from `safeParse` directly:

```suggestion
const textResult = TextSchema.safeParse(event);
if (textResult.success) {
  const textEvent = textResult.data;
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +140 to +142
export function setLogSink(sink: LogSink): void {
logSink = sink;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low ui/logger.ts:140

JsonFileLogSink has a live interval but no lifecycle management: replacing sinks via setLogSink doesn’t dispose the old one, initializeStructuredLogging returns nothing to dispose, and the interval can keep the process alive. Suggest adding a dispose contract to LogSink, calling it in setLogSink, returning a disposable from initializeStructuredLogging (or exposing a shutdown), and unref() the interval so CLIs can exit cleanly.

Suggested change
export function setLogSink(sink: LogSink): void {
logSink = sink;
}
export function setLogSink(sink: LogSink): void {
if ((logSink as { dispose?: () => void }).dispose) {
(logSink as { dispose: () => void }).dispose();
}
logSink = sink;
}
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file cli/src/ui/logger.ts around lines 140-142:

`JsonFileLogSink` has a live interval but no lifecycle management: replacing sinks via `setLogSink` doesn’t dispose the old one, `initializeStructuredLogging` returns nothing to dispose, and the interval can keep the process alive. Suggest adding a `dispose` contract to `LogSink`, calling it in `setLogSink`, returning a disposable from `initializeStructuredLogging` (or exposing a shutdown), and `unref()` the interval so CLIs can exit cleanly.

Evidence trail:
cli/src/ui/logger.ts lines 82-84 (LogSink interface only has write), lines 123-125 (setLogSink just reassigns without disposing old sink), lines 248-254 (setInterval created without .unref()), lines 291-299 (dispose method exists but not called by system), lines 329-346 (initializeStructuredLogging returns void)

Comment on lines +269 to +270
// Validate path to prevent path traversal attacks
this.filePath = validateLogPath(filePath);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium ui/logger.ts:269

appendFileSync throws ENOENT when the logs directory doesn't exist. Since validateLogPath enforces paths within logs but never creates it, a fresh execution fails silently with buffered logs and console errors. Consider adding fs.mkdirSync(this.allowedDir, { recursive: true }) in the constructor before any file operations.

+		import { mkdirSync } from "node:fs";
+		mkdirSync(allowedDir, { recursive: true });
 		this.filePath = validateLogPath(filePath);
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file cli/src/ui/logger.ts around lines 269-270:

`appendFileSync` throws `ENOENT` when the `logs` directory doesn't exist. Since `validateLogPath` enforces paths within `logs` but never creates it, a fresh execution fails silently with buffered logs and console errors. Consider adding `fs.mkdirSync(this.allowedDir, { recursive: true })` in the constructor before any file operations.

Evidence trail:
cli/src/ui/logger.ts:14 - `ALLOWED_LOG_DIR = "logs"`
cli/src/ui/logger.ts:17-27 - `validateLogPath` validates path is within `logs` but does not create directory
cli/src/ui/logger.ts:229-246 - `JsonFileLogSink` constructor calls `validateLogPath(filePath)` but no `mkdirSync` call
cli/src/ui/logger.ts:269 - `appendFileSync(this.filePath, lines, "utf-8")` throws ENOENT if directory doesn't exist
cli/src/ui/logger.ts:271-280 - catch block logs error to console and keeps logs buffered

await new Promise((resolve) => setTimeout(resolve, 1000));

// Check if process is still running
if (proc.connected || proc.pid) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low utils/cleanup.ts:68

The SIGKILL fallback (lines 68-75) always executes because proc.pid is defined even after the process exits. This causes unnecessary delays and redundant kill signals. Replace the condition with proc.exitCode === null to check if the process is actually still running.

-          if (proc.connected || proc.pid) {
+          if (proc.exitCode === null) {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file cli/src/utils/cleanup.ts around line 68:

The `SIGKILL` fallback (lines 68-75) always executes because `proc.pid` is defined even after the process exits. This causes unnecessary delays and redundant kill signals. Replace the condition with `proc.exitCode === null` to check if the process is actually still running.

Evidence trail:
cli/src/utils/cleanup.ts lines 68-75 showing condition `if (proc.connected || proc.pid) { proc.kill("SIGKILL"); ... }`. Node.js documentation at https://nodejs.org/api/child_process.html confirms subprocess.pid is set at spawn and only undefined if spawning fails - it doesn't become undefined when process exits. Documentation also confirms subprocess.exitCode is null while process is running.

Comment on lines +60 to +76
export function standardizeError(error: unknown): RalphyError {
if (error instanceof RalphyError) {
return error;
}

if (error instanceof Error) {
return new RalphyError(error.message, "UNKNOWN_ERROR", {
originalName: error.name,
originalStack: error.stack,
});
}

if (typeof error === "string") {
return new RalphyError(error, "STRING_ERROR");
}

return new RalphyError(String(error), "UNKNOWN_ERROR", { originalType: typeof error });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium utils/errors.ts:60

standardizeError drops important metadata for non-RalphyError inputs (plain objects stringify to "[object Object]" and Error.code is ignored), which breaks retry decisions. Suggest preserving message/code when given objects, keeping Error.code (and merging other fields into context) so isRetryableError can reliably detect system error codes.

-	if (typeof error === "string") {
-		return new RalphyError(error, "STRING_ERROR");
-	}
-
-	return new RalphyError(String(error), "UNKNOWN_ERROR", { originalType: typeof error });
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file cli/src/utils/errors.ts around lines 60-76:

`standardizeError` drops important metadata for non-`RalphyError` inputs (plain objects stringify to "[object Object]" and `Error.code` is ignored), which breaks retry decisions. Suggest preserving `message`/`code` when given objects, keeping `Error.code` (and merging other fields into `context`) so `isRetryableError` can reliably detect system error codes.

Evidence trail:
cli/src/utils/errors.ts lines 60-76 (REVIEWED_COMMIT): `standardizeError` function shows:
- Line 66-70: Error handling preserves `originalName` and `originalStack` but not `error.code`
- Line 75: Plain objects become `String(error)` which is `"[object Object]"`
- Lines 82-109: `isRetryableError` checks `standardized.code` against retryable codes, meaning lost `Error.code` values won't be detected by code-based checks

Comment on lines +92 to +101
function cloneFileIndex(index: FileIndex): FileIndex {
return {
version: index.version,
timestamp: index.timestamp,
workDir: index.workDir,
files: new Map(index.files),
totalFiles: index.totalFiles,
totalSize: index.totalSize,
};
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low utils/file-indexer.ts:92

The cloneFileIndex function returns a shallow clone of the files Map, so the keywords arrays in FileIndexEntry objects are shared between the returned index and the internal indexCache. Modifying entry.keywords after calling indexWorkspace corrupts the cached entry for that file, affecting all future callers.

-function cloneFileIndex(index: FileIndex): FileIndex {
-	return {
-		version: index.version,
-		timestamp: index.timestamp,
-		workDir: index.workDir,
-		files: new Map(index.files),
-		totalFiles: index.totalFiles,
-		totalSize: index.totalSize,
-	};
-}
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file cli/src/utils/file-indexer.ts around lines 92-101:

The `cloneFileIndex` function returns a shallow clone of the `files` Map, so the `keywords` arrays in `FileIndexEntry` objects are shared between the returned index and the internal `indexCache`. Modifying `entry.keywords` after calling `indexWorkspace` corrupts the cached entry for that file, affecting all future callers.

Evidence trail:
cli/src/utils/file-indexer.ts lines 87-99 (cloneFileIndex function with JSDoc claiming 'Deep clone' but using `new Map(index.files)` which is shallow), lines 78-80 (indexCache definition as module-level Map), lines 458-461 (indexWorkspace returning `cloneFileIndex(cached)`), lines 27-43 (FileIndexEntry interface showing `keywords: string[]` which would be shared by reference)

Comment on lines +113 to +122
function shouldIgnoreFile(filePath: string, ignorePatterns: string[]): boolean {
const normalizedPath = filePath.replace(/\\/g, "/");

for (const pattern of ignorePatterns) {
if (matchesGlob(normalizedPath, pattern)) {
return true;
}
}

return false;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low utils/file-indexer.ts:113

shouldIgnoreFile normalizes filePath to forward slashes, but passes ignorePatterns unchanged to matchesGlob. On Windows, a pattern like temp\** becomes a regex matching literal backslashes, while the path contains forward slashes — the pattern never matches and files are incorrectly indexed.

-function shouldIgnoreFile(filePath: string, ignorePatterns: string[]): boolean {
-	const normalizedPath = filePath.replace(/\\/g, "/");
+	const normalizedPath = filePath.replace(/\\/g, "/");
+	const normalizedPatterns = ignorePatterns.map((p) => p.replace(/\\/g, "/"));
 
-	for (const pattern of ignorePatterns) {
+	for (const pattern of normalizedPatterns) {
 		if (matchesGlob(normalizedPath, pattern)) {
 			return true;
 		}
 	}
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file cli/src/utils/file-indexer.ts around lines 113-122:

`shouldIgnoreFile` normalizes `filePath` to forward slashes, but passes `ignorePatterns` unchanged to `matchesGlob`. On Windows, a pattern like `temp\**` becomes a regex matching literal backslashes, while the path contains forward slashes — the pattern never matches and files are incorrectly indexed.

Evidence trail:
cli/src/utils/file-indexer.ts lines 113-121 (shouldIgnoreFile normalizes filePath but not patterns), cli/src/utils/file-indexer.ts lines 141-177 (globToRegex function - line 154 escapes backslashes with `\$&`), cli/src/config/constants.ts lines 50-58 (DEFAULT_IGNORE_PATTERNS - no backslashes, but user patterns can be passed via options.ignorePatterns)

Comment on lines +607 to +614
// Check if another operation is already indexing this workspace
const existingPromise = indexingPromises.get(workDir);
if (existingPromise) {
logDebug(`Waiting for concurrent indexing of ${workDir}...`);
const result = await existingPromise;
// Return a clone even from the concurrent operation's result
return cloneFileIndex(result);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium utils/file-indexer.ts:607

forceRebuild can return stale results: indexWorkspace joins an in‑flight promise even when forceRebuild is true (and rebuildFileIndex inherits this). Suggest ignoring existing promises when forceRebuild is true and always starting a fresh indexing.

-	// Check if another operation is already indexing this workspace
-	const existingPromise = indexingPromises.get(workDir);
-	if (existingPromise) {
-		logDebug(`Waiting for concurrent indexing of ${workDir}...`);
-		const result = await existingPromise;
-		// Return a clone even from the concurrent operation's result
-		return cloneFileIndex(result);
-	}
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file cli/src/utils/file-indexer.ts around lines 607-614:

`forceRebuild` can return stale results: `indexWorkspace` joins an in‑flight promise even when `forceRebuild` is `true` (and `rebuildFileIndex` inherits this). Suggest ignoring existing promises when `forceRebuild` is `true` and always starting a fresh indexing.

Evidence trail:
cli/src/utils/file-indexer.ts lines 608-614 (REVIEWED_COMMIT) - shows the existing promise check without forceRebuild consideration; lines 603-606 show memory cache correctly checks forceRebuild; line 990-993 shows rebuildFileIndex calls indexWorkspace with forceRebuild: true

Comment on lines +156 to +158
const rawMessage = args
.map((arg) => (typeof arg === "string" ? arg : JSON.stringify(arg)))
.join(" ");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low ui/logger.ts:156

createLogEntry uses JSON.stringify, which throws on circular refs/BigInt and drops details for Error/Map/Set. Suggest a safe serializer (e.g., util.inspect) or a try/catch with a fallback to avoid crashes while preserving useful info.

+function createLogEntry(level: LogLevel, component: string | undefined, args: unknown[]): LogEntry {
+	const rawMessage = args
+		.map((arg) => {
+			if (typeof arg === "string") return arg;
+			try {
+				return JSON.stringify(arg);
+			} catch {
+				return "[unserializable]";
+			}
+		})
+		.join(" ");
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file cli/src/ui/logger.ts around lines 156-158:

`createLogEntry` uses `JSON.stringify`, which throws on circular refs/`BigInt` and drops details for `Error`/`Map`/`Set`. Suggest a safe serializer (e.g., `util.inspect`) or a try/catch with a fallback to avoid crashes while preserving useful info.

Evidence trail:
cli/src/ui/logger.ts lines 155-167 at REVIEWED_COMMIT - `createLogEntry` function uses `JSON.stringify(arg)` on line 158 without try/catch. Standard JavaScript behavior: `JSON.stringify` throws on circular refs and BigInt, returns `{}` for Error/Map/Set.

Comment on lines +85 to +100
// 2. Run registered cleanup functions
const promises: Promise<void>[] = [];
for (const fn of cleanupRegistry) {
try {
const result = fn();
if (result instanceof Promise) {
promises.push(result);
}
} catch (err) {
// Log sync errors but continue with other cleanup functions
promises.push(Promise.reject(err));
}
}

await Promise.allSettled(promises);
cleanupRegistry.clear();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low utils/cleanup.ts:85

The catch block on line 94 pushes rejected Promises into the promises array, but Promise.allSettled results are never inspected — errors from cleanup functions are silently swallowed. This contradicts the comment // Log sync errors and hides failures from the user. Consider logging errors with logWarn before converting them to resolved/rejected promises, or iterate through Promise.allSettled results to log any rejected promises.

 		try {
 			const result = fn();
 			if (result instanceof Promise) {
 				promises.push(result);
 			}
 		} catch (err) {
-			// Log sync errors but continue with other cleanup functions
-			promises.push(Promise.reject(err));
+			// Log sync errors but continue with other cleanup functions
+			logWarn(`Cleanup function failed: ${err}`);
 		}
 	}
 
-	await Promise.allSettled(promises);
+	const results = await Promise.allSettled(promises);
+	for (const result of results) {
+		if (result.status === 'rejected') {
+			logWarn(`Async cleanup function failed: ${result.reason}`);
+		}
+	}
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file cli/src/utils/cleanup.ts around lines 85-100:

The `catch` block on line 94 pushes rejected Promises into the `promises` array, but `Promise.allSettled` results are never inspected — errors from cleanup functions are silently swallowed. This contradicts the comment `// Log sync errors` and hides failures from the user. Consider logging errors with `logWarn` before converting them to resolved/rejected promises, or iterate through `Promise.allSettled` results to log any rejected promises.

Evidence trail:
cli/src/utils/cleanup.ts (REVIEWED_COMMIT): Line 3 imports `logWarn` from logger. Line 86 comment: `// Log sync errors but continue with other cleanup functions`. Line 88: `promises.push(Promise.reject(err))` in catch block - no logging. Line 91: `await Promise.allSettled(promises)` - results discarded without inspection. Errors are silently swallowed despite comment claiming they are logged.

Comment on lines +125 to +176
/**
* Convert glob pattern to regex
*/
function matchesGlob(filePath: string, pattern: string): boolean {
// Handle ** patterns properly
const regexPattern = globToRegex(pattern);
return regexPattern.test(filePath);
}

/**
* Convert glob pattern to regex
*
* SECURITY NOTE: This function includes protections against ReDoS attacks:
* - Input length is limited to MAX_GLOB_PATTERN_LENGTH
* - Uses non-backtracking patterns where possible
*/
function globToRegex(pattern: string): RegExp {
const safePattern =
pattern.length > MAX_GLOB_PATTERN_LENGTH
? pattern.slice(0, MAX_GLOB_PATTERN_LENGTH)
: pattern;

// Limit pattern length to prevent ReDoS attacks
if (safePattern.length < pattern.length) {
logDebug(`Glob pattern too long (${pattern.length} > ${MAX_GLOB_PATTERN_LENGTH}), truncating`);
}

// Escape special regex characters except * and ?
// Use a bounded approach to prevent catastrophic backtracking
let regex = safePattern
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*\*/g, "\0DOUBLESTAR\0") // Temporarily mark **
.replace(/\*/g, "[^/]*") // Single * matches anything except /
.replace(/\?/g, "[^/]"); // ? matches single char except /

// Handle ** (match any number of directories) using non-capturing group
// The (?:.*/)? pattern is bounded - it won't cause catastrophic backtracking
regex = regex.replace(/\0DOUBLESTAR\0/g, "(?:.*/)?");

// Handle directory separators
regex = regex.replace(/\//g, "[/\\\\]");

// Anchor to start
regex = `^${regex}`;

// Match at end if pattern doesn't end with /**
if (!safePattern.endsWith("/**")) {
regex += "$";
}

return new RegExp(regex, "i");
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium utils/file-indexer.ts:125

Ignore matching is broken: globToRegex anchors patterns and mishandles **, so patterns like node_modules or src/**/test.ts don’t match nested paths. Suggest using a vetted glob matcher (e.g., picomatch) on POSIX‑normalized paths, or update globToRegex to drop unconditional anchors and have ** consume zero‑or‑more path segments.

-	regex = `^${regex}`;
-
-	// Match at end if pattern doesn't end with /**
-	if (!safePattern.endsWith("/**")) {
-		regex += "$";
-	}
-
-	return new RegExp(regex, "i");
+	// Match anywhere in the path unless pattern starts with /
+	const anchored = safePattern.startsWith("/");
+	regex = anchored ? `^${regex}$` : `(?:^|/)${regex}(?:$|/)`;
+
+	return new RegExp(regex, "i");
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file cli/src/utils/file-indexer.ts around lines 125-176:

Ignore matching is broken: `globToRegex` anchors patterns and mishandles `**`, so patterns like `node_modules` or `src/**/test.ts` don’t match nested paths. Suggest using a vetted glob matcher (e.g., `picomatch`) on POSIX‑normalized paths, or update `globToRegex` to drop unconditional anchors and have `**` consume zero‑or‑more path segments.

Evidence trail:
cli/src/utils/file-indexer.ts lines 140-175 (globToRegex function), line 162 (`**` replaced with `(?:.*/)?`), line 168 (start anchor `^`), lines 170-172 (end anchor `$`). cli/src/config/constants.ts lines 50-55 (DEFAULT_IGNORE_PATTERNS includes `node_modules`, `.git` etc. without `**/` prefix).

Comment on lines +392 to 405
export function initializeStructuredLogging(
logFilePath?: string,
minLevel: LogLevel = "info",
): void {
const sinks: LogSink[] = [new ConsoleLogSink()];

if (logFilePath) {
const fileSink = new JsonFileLogSink(logFilePath);
const filteredFileSink = new FilteredLogSink(fileSink, minLevel);
sinks.push(filteredFileSink);
}

setLogSink(new MultiLogSink(sinks));
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JsonFileLogSink flush timer is leaked with no dispose path

initializeStructuredLogging constructs a JsonFileLogSink, which immediately starts a setInterval timer in its constructor. The sink reference is buried inside a FilteredLogSink inside the MultiLogSink stored in the module-level logSink variable. There is no way for a caller to retrieve and call dispose() on this sink — the interval will run for the lifetime of the process, preventing the event loop from draining cleanly on exit.

The function should return a dispose callback (or the JsonFileLogSink instance directly) so callers can stop the timer:

export function initializeStructuredLogging(
    logFilePath?: string,
    minLevel: LogLevel = "info",
): (() => void) | undefined {
    const sinks: LogSink[] = [new ConsoleLogSink()];
    let fileSink: JsonFileLogSink | undefined;

    if (logFilePath) {
        fileSink = new JsonFileLogSink(logFilePath);
        const filteredFileSink = new FilteredLogSink(fileSink, minLevel);
        sinks.push(filteredFileSink);
    }

    setLogSink(new MultiLogSink(sinks));
    return fileSink ? () => fileSink!.dispose() : undefined;
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/src/ui/logger.ts
Line: 392-405

Comment:
**`JsonFileLogSink` flush timer is leaked with no dispose path**

`initializeStructuredLogging` constructs a `JsonFileLogSink`, which immediately starts a `setInterval` timer in its constructor. The sink reference is buried inside a `FilteredLogSink` inside the `MultiLogSink` stored in the module-level `logSink` variable. There is no way for a caller to retrieve and call `dispose()` on this sink — the interval will run for the lifetime of the process, preventing the event loop from draining cleanly on exit.

The function should return a dispose callback (or the `JsonFileLogSink` instance directly) so callers can stop the timer:

```typescript
export function initializeStructuredLogging(
    logFilePath?: string,
    minLevel: LogLevel = "info",
): (() => void) | undefined {
    const sinks: LogSink[] = [new ConsoleLogSink()];
    let fileSink: JsonFileLogSink | undefined;

    if (logFilePath) {
        fileSink = new JsonFileLogSink(logFilePath);
        const filteredFileSink = new FilteredLogSink(fileSink, minLevel);
        sinks.push(filteredFileSink);
    }

    setLogSink(new MultiLogSink(sinks));
    return fileSink ? () => fileSink!.dispose() : undefined;
}
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +55 to +61
export function setDebug(debug: boolean): void {
loggerState.debugMode = debug;
if (debug) {
loggerState.verboseMode = true;
}
verboseMode = loggerState.verboseMode;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium ui/logger.ts:55

setDebug(false) disables debugMode but leaves verboseMode as true, so debug logs continue to be printed even though isDebug() returns false. Since log() gates debug messages solely on verboseMode, calling setDebug(false) fails to actually disable debug output. Consider also setting verboseMode to false when debug is false.

 export function setDebug(debug: boolean): void {
 	loggerState.debugMode = debug;
-	if (debug) {
-		loggerState.verboseMode = true;
+	if (debug) {
+		loggerState.verboseMode = true;
+	} else {
+		loggerState.verboseMode = false;
 	}
 	verboseMode = loggerState.verboseMode;
 }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file cli/src/ui/logger.ts around lines 55-61:

`setDebug(false)` disables `debugMode` but leaves `verboseMode` as `true`, so debug logs continue to be printed even though `isDebug()` returns `false`. Since `log()` gates debug messages solely on `verboseMode`, calling `setDebug(false)` fails to actually disable debug output. Consider also setting `verboseMode` to `false` when `debug` is `false`.

Evidence trail:
cli/src/ui/logger.ts lines 54-60 (setDebug function), lines 157-165 (log function checking verboseMode for debug gating), lines 39-41 (isDebug returns debugMode). The setDebug function only sets verboseMode=true when debug=true, with no code to set verboseMode=false when debug=false.

}

if (step.thought) {
// Clean up common "Thinking:" prefixes since we wrap in {} later
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium utils/ai-output-parser.ts:131

The regex /^(Thinking|Analyzing|Considering|Warning|Waiting)[:\s]*/i corrupts grammatical sentences by matching and stripping the first word when the AI outputs something like "Analyzing the file structure...". The cleaned thought becomes "the file structure...", which is nonsensical. Consider adding a word boundary or requiring a colon after the keyword, e.g., /(Thinking|Analyzing|Considering|Warning|Waiting):\s*/i, so only actual prefixes like "Analyzing: " are stripped.

-		const cleanedThought = step.thought.replace(/^(Thinking|Analyzing|Considering|Warning|Waiting)[:\s]*/i, "").trim();
+		const cleanedThought = step.thought.replace(/^(Thinking|Analyzing|Considering|Warning|Waiting):\s*/i, "").trim();
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file cli/src/utils/ai-output-parser.ts around line 131:

The regex `/^(Thinking|Analyzing|Considering|Warning|Waiting)[:\s]*/i` corrupts grammatical sentences by matching and stripping the first word when the AI outputs something like "Analyzing the file structure...". The cleaned thought becomes "the file structure...", which is nonsensical. Consider adding a word boundary or requiring a colon after the keyword, e.g., `/(Thinking|Analyzing|Considering|Warning|Waiting):\s*/i`, so only actual prefixes like "Analyzing: " are stripped.

Evidence trail:
cli/src/utils/ai-output-parser.ts line 131 (REVIEWED_COMMIT): `const cleanedThought = step.thought.replace(/^(Thinking|Analyzing|Considering|Warning|Waiting)[:\s]*/i, "").trim();` - The regex `[:\s]*` matches zero or more colons OR whitespace, so for input 'Analyzing the file structure...', it matches 'Analyzing ' and strips it, leaving 'the file structure...'

Comment on lines +15 to +28
/**
* Validate log file path to prevent path traversal attacks
*/
function validateLogPath(filePath: string): string {
const resolved = path.resolve(filePath);
const allowedDir = path.resolve(process.cwd(), ALLOWED_LOG_DIR);
const relative = path.relative(allowedDir, resolved);

if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error(`Invalid log file path: ${filePath} must be within ${ALLOWED_LOG_DIR}`);
}

return resolved;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium ui/logger.ts:15

validateLogPath returns a path inside the logs directory, but the code never ensures this directory exists. Since appendFileSync in JsonFileLogSink does not create parent directories, logging will throw ENOENT in fresh environments where the logs folder doesn't exist. Consider adding mkdirSync with recursive: true to create the directory before it is used.

function validateLogPath(filePath: string): string {
	const resolved = path.resolve(filePath);
	const allowedDir = path.resolve(process.cwd(), ALLOWED_LOG_DIR);
	const relative = path.relative(allowedDir, resolved);

	if (relative.startsWith("..") || path.isAbsolute(relative)) {
		throw new Error(`Invalid log file path: ${filePath} must be within ${ALLOWED_LOG_DIR}`);
	}

+	// Ensure the logs directory exists before returning the path
+	import { mkdirSync } from "node:fs";
+	mkdirSync(allowedDir, { recursive: true });

	return resolved;
}
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file cli/src/ui/logger.ts around lines 15-28:

`validateLogPath` returns a path inside the `logs` directory, but the code never ensures this directory exists. Since `appendFileSync` in `JsonFileLogSink` does not create parent directories, logging will throw `ENOENT` in fresh environments where the `logs` folder doesn't exist. Consider adding `mkdirSync` with `recursive: true` to create the directory before it is used.

Evidence trail:
cli/src/ui/logger.ts lines 15 (ALLOWED_LOG_DIR), 18-27 (validateLogPath), 228-241 (JsonFileLogSink constructor), 276 (appendFileSync call). git_grep for `mkdirSync.*logs` returned no results. git_grep for all `mkdirSync` calls shows no logs directory creation anywhere in the repo.

Comment on lines +173 to 181
function log(level: LogLevel, component: string | undefined, ...args: unknown[]): void {
// Debug messages only show in verbose or debug mode
if (level === "debug" && !loggerState.verboseMode) {
return;
}

const entry = createLogEntry(level, component, args);
logSink.write(entry);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low ui/logger.ts:173

The log function drops all 'debug' level messages unless verboseMode is enabled, even when a file sink is explicitly configured to capture debug logs via minLevel: 'debug'. This prevents debug data from reaching the file while keeping console output quiet, contradicting the sink's configuration. Consider removing the global verbose check from log and letting each sink handle its own filtering.

 function log(level: LogLevel, component: string | undefined, ...args: unknown[]): void {
 	// Debug messages only show in verbose or debug mode
-	if (level === "debug" && !loggerState.verboseMode) {
-		return;
-	}
-
 	const entry = createLogEntry(level, component, args);
 	logSink.write(entry);
 }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file cli/src/ui/logger.ts around lines 173-181:

The `log` function drops all 'debug' level messages unless `verboseMode` is enabled, even when a file sink is explicitly configured to capture debug logs via `minLevel: 'debug'`. This prevents debug data from reaching the file while keeping console output quiet, contradicting the sink's configuration. Consider removing the global verbose check from `log` and letting each sink handle its own filtering.

Evidence trail:
cli/src/ui/logger.ts lines 173-180 (log function with verboseMode check before logSink.write), lines 368-387 (FilteredLogSink class with minLevel), lines 396-408 (initializeStructuredLogging with minLevel parameter passed to FilteredLogSink)

Comment on lines +84 to +86
interface WebhookSessionData {
sessionId: string;
engine: string;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium telemetry/types.ts:84

WebhookSessionData is missing the timestamp field from the Session interface, so the original session start time is dropped when constructing the webhook payload. The top-level timestamp in TelemetryWebhookPayload records the transmission time, not the session start time. Add timestamp: number to WebhookSessionData to preserve the session's original start time.

interface WebhookSessionData {
	sessionId: string;
+	timestamp: number;
	engine: string;
Also found in 1 other location(s)

cli/src/utils/json-validation.ts:31

The StepStartSchema does not define session ID fields (sessionID, sessionId, session_id) or enable passthrough. Zod z.object strips unknown keys by default. Consequently, if the upstream step_start event includes a session ID, it is stripped during parsing, causing the extractSessionId helper to return null and the session context to be lost.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file cli/src/telemetry/types.ts around lines 84-86:

`WebhookSessionData` is missing the `timestamp` field from the `Session` interface, so the original session start time is dropped when constructing the webhook payload. The top-level `timestamp` in `TelemetryWebhookPayload` records the transmission time, not the session start time. Add `timestamp: number` to `WebhookSessionData` to preserve the session's original start time.

Evidence trail:
- cli/src/telemetry/types.ts lines 17-18: `Session` interface defines `timestamp: number; // Unix ms`
- cli/src/telemetry/types.ts lines 84-100: `WebhookSessionData` interface - no `timestamp` field present
- cli/src/telemetry/webhook.ts line 28: `timestamp: new Date().toISOString()` sets transmission time
- cli/src/telemetry/webhook.ts lines 29-42: `session` object construction copies fields from Session but omits `session.timestamp`

Also found in 1 other location(s):
- cli/src/utils/json-validation.ts:31 -- The `StepStartSchema` does not define session ID fields (`sessionID`, `sessionId`, `session_id`) or enable passthrough. Zod `z.object` strips unknown keys by default. Consequently, if the upstream `step_start` event includes a session ID, it is stripped during parsing, causing the `extractSessionId` helper to return `null` and the session context to be lost.


const cleanupRegistry: Set<CleanupFn> = new Set();
const trackedProcesses: Set<ChildProcess> = new Set();
let isCleaningUp = false;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium utils/cleanup.ts:9

When runCleanup is called while already running, the boolean isCleaningUp flag causes the second call to return immediately while cleanup continues in the background. The signal handler at line 125 then calls process.exit(0) before cleanup actually completes, terminating the process prematurely and potentially leaving orphaned child processes. Consider replacing the boolean flag with a shared Promise that all callers await.

-let isCleaningUp = false;
+let cleanupPromise: Promise<void> | null = null;
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file cli/src/utils/cleanup.ts around line 9:

When `runCleanup` is called while already running, the boolean `isCleaningUp` flag causes the second call to return immediately while cleanup continues in the background. The signal handler at line 125 then calls `process.exit(0)` before cleanup actually completes, terminating the process prematurely and potentially leaving orphaned child processes. Consider replacing the boolean flag with a shared Promise that all callers await.

Evidence trail:
cli/src/utils/cleanup.ts lines 9, 37-38 (isCleaningUp flag and early return), lines 101-124 (signal handler with separate isShuttingDown flag that awaits runCleanup() then calls process.exit(0)). The defect occurs when runCleanup() is called outside a signal handler, then a signal arrives - the signal handler's runCleanup() returns immediately due to isCleaningUp=true, then process.exit(0) is called before the original cleanup completes.

Comment on lines +146 to +147
const trimmed = step.raw.trim();
if (trimmed.startsWith("{") || trimmed.startsWith('"')) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium utils/ai-output-parser.ts:146

The raw output fallback returns null for any message starting with ", which drops valid quoted text strings like "Hello world" when the AI produces plain quoted content. Consider checking for actual JSON structure (e.g., startsWith('{') only, or validating with JSON.parse) instead of treating all double-quoted strings as unparsed JSON.

Suggested change
const trimmed = step.raw.trim();
if (trimmed.startsWith("{") || trimmed.startsWith('"')) {
if (trimmed.startsWith("{")) {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file cli/src/utils/ai-output-parser.ts around lines 146-147:

The raw output fallback returns `null` for any message starting with `"`, which drops valid quoted text strings like `"Hello world"` when the AI produces plain quoted content. Consider checking for actual JSON structure (e.g., `startsWith('{')` only, or validating with `JSON.parse`) instead of treating all double-quoted strings as unparsed JSON.

Evidence trail:
cli/src/utils/ai-output-parser.ts lines 144-150 (REVIEWED_COMMIT) - shows the check `trimmed.startsWith("{") || trimmed.startsWith('"')` returning null
cli/src/utils/json-validation.ts lines 94-145 (REVIEWED_COMMIT) - shows parseJsonLine only accepts objects matching StreamJsonEventSchema with specific type values, meaning plain JSON strings fail validation
cli/src/utils/ai-output-parser.ts lines 16-18 (REVIEWED_COMMIT) - shows parseAIStep sets `raw: step` when parseJsonLine returns null

Comment on lines +85 to +86
/**
* Deep clone a FileIndex to return an immutable copy
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium utils/file-indexer.ts:85

Concurrency lock ignores options. indexWorkspace reuses indexingPromises keyed only by workDir, so calls with different ignorePatterns/maxDepth or forceRebuild can return the wrong index. Suggest keying by workDir + options (or skipping reuse when options differ) and always bypassing reuse when forceRebuild is set.

-// Track promises for workspaces being indexed to allow waiting
-const indexingPromises = new Map<string, Promise<FileIndex>>();
+// Track promises for workspaces being indexed to allow waiting
+interface IndexingKey {
+	workDir: string;
+	ignorePatterns: string[];
+	maxDepth: number;
+	forceRebuild: boolean;
+}
+
+function makeIndexingKey(workDir: string, ignorePatterns: string[], maxDepth: number, forceRebuild: boolean): string {
+	return JSON.stringify({ workDir, ignorePatterns, maxDepth, forceRebuild });
+}
+
+const indexingPromises = new Map<string, Promise<FileIndex>>();
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file cli/src/utils/file-indexer.ts around lines 85-86:

Concurrency lock ignores options. `indexWorkspace` reuses `indexingPromises` keyed only by `workDir`, so calls with different `ignorePatterns`/`maxDepth` or `forceRebuild` can return the wrong index. Suggest keying by `workDir + options` (or skipping reuse when options differ) and always bypassing reuse when `forceRebuild` is set.

Evidence trail:
cli/src/utils/file-indexer.ts lines 87 (indexingPromises typed as Map<string, Promise<FileIndex>>), lines 615-621 (reuse check using only workDir key), line 624 (set only by workDir), lines 604-608 (function signature with options). The code confirms the promise map is keyed solely by workDir without considering options, and forceRebuild is bypassed when an existing promise exists.

Comment on lines +24 to +29
// Limit input length to prevent ReDoS attacks
if (input.length > MAX_SANITIZE_INPUT_LENGTH) {
// For very large inputs, truncate and add warning
const truncated = input.slice(0, MAX_SANITIZE_INPUT_LENGTH);
return `${truncated}\n\n[WARNING: Content truncated due to size limits during secret sanitization]`;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium utils/sanitization.ts:24

When input.length > MAX_SANITIZE_INPUT_LENGTH, the code returns the truncated string immediately without passing it through the sanitization patterns, so secrets in the first 1MB are logged in cleartext. Pass the truncated content through the sanitization loop before returning.

-const truncated = input.slice(0, MAX_SANITIZE_INPUT_LENGTH);
-		return `${truncated}\n\n[WARNING: Content truncated due to size limits during secret sanitization]`;
+		input = input.slice(0, MAX_SANITIZE_INPUT_LENGTH);
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file cli/src/utils/sanitization.ts around lines 24-29:

When `input.length > MAX_SANITIZE_INPUT_LENGTH`, the code returns the truncated string immediately without passing it through the sanitization patterns, so secrets in the first 1MB are logged in cleartext. Pass the truncated content through the sanitization loop before returning.

Evidence trail:
cli/src/utils/sanitization.ts lines 24-28 (REVIEWED_COMMIT): Early return when input.length > MAX_SANITIZE_INPUT_LENGTH returns truncated string directly without sanitization.
cli/src/utils/sanitization.ts lines 30-42 (REVIEWED_COMMIT): Sanitization patterns are defined and applied only AFTER the length check, so they are never reached for oversized inputs.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium ui/logger.ts:396

initializeStructuredLogging creates a JsonFileLogSink that starts a persistent setInterval timer, but the function returns nothing and provides no way to access the sink instance. The timer prevents the Node.js process from exiting naturally, causing the CLI to hang after completing its work. Consider returning the MultiLogSink so callers can dispose the file sink when done.

export function initializeStructuredLogging(
	logFilePath?: string,
	minLevel: LogLevel = "info",
-): void {
+): MultiLogSink {
	const sinks: LogSink[] = [new ConsoleLogSink()];

	if (logFilePath) {
		const fileSink = new JsonFileLogSink(logFilePath);
		const filteredFileSink = new FilteredLogSink(fileSink, minLevel);
		sinks.push(filteredFileSink);
	}

- 	setLogSink(new MultiLogSink(sinks));
+ 	const sink = new MultiLogSink(sinks);
+ 	setLogSink(sink);
+ 	return sink;
 }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file cli/src/ui/logger.ts around lines 396-409:

`initializeStructuredLogging` creates a `JsonFileLogSink` that starts a persistent `setInterval` timer, but the function returns nothing and provides no way to access the sink instance. The timer prevents the Node.js process from exiting naturally, causing the CLI to hang after completing its work. Consider returning the `MultiLogSink` so callers can dispose the file sink when done.

Evidence trail:
cli/src/ui/logger.ts lines 396-409 (`initializeStructuredLogging` function returning void), lines 260-282 (`JsonFileLogSink` constructor with `setInterval`), lines 328-336 (`dispose()` method), lines 343-365 (`MultiLogSink` class without dispose method), lines 92-94 (`LogSink` interface with only `write()` method), lines 140-142 and 147-149 (`setLogSink` and `getLogSink` functions)

Comment on lines +52 to +60
export const DEFAULT_IGNORE_PATTERNS = [
".git",
"node_modules",
".ralphy-sandboxes",
".ralphy-worktrees",
".ralphy",
"agent-*",
"sandbox-*",
];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DEFAULT_IGNORE_PATTERNS only matches root-level directories

The glob patterns here (e.g. node_modules, .git) compile to anchored regexes like ^node_modules$ and ^\.git$ (see globToRegex in file-indexer.ts lines 161–180). This means a top-level node_modules is correctly ignored. However, in monorepos with nested directories — e.g. packages/app/node_modules or packages/web/node_modules — the relative paths like packages/app/node_modules will not match ^node_modules$, causing the entire nested dependency tree to be indexed, resulting in severe performance degradation and bloated cache.

The patterns need a **/ prefix to match at any depth:

Suggested change
export const DEFAULT_IGNORE_PATTERNS = [
".git",
"node_modules",
".ralphy-sandboxes",
".ralphy-worktrees",
".ralphy",
"agent-*",
"sandbox-*",
];
export const DEFAULT_IGNORE_PATTERNS = [
"**/.git",
"**/node_modules",
"**/.ralphy-sandboxes",
"**/.ralphy-worktrees",
"**/.ralphy",
"agent-*",
"sandbox-*",
];
Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/src/config/constants.ts
Line: 52-60

Comment:
**`DEFAULT_IGNORE_PATTERNS` only matches root-level directories**

The glob patterns here (e.g. `node_modules`, `.git`) compile to anchored regexes like `^node_modules$` and `^\.git$` (see `globToRegex` in `file-indexer.ts` lines 161–180). This means a top-level `node_modules` is correctly ignored. However, in monorepos with nested directories — e.g. `packages/app/node_modules` or `packages/web/node_modules` — the relative paths like `packages/app/node_modules` will **not** match `^node_modules$`, causing the entire nested dependency tree to be indexed, resulting in severe performance degradation and bloated cache.

The patterns need a `**/` prefix to match at any depth:

```suggestion
export const DEFAULT_IGNORE_PATTERNS = [
	"**/.git",
	"**/node_modules",
	"**/.ralphy-sandboxes",
	"**/.ralphy-worktrees",
	"**/.ralphy",
	"agent-*",
	"sandbox-*",
];
```

How can I resolve this? If you propose a fix, please make it concise.

// All patterns use bounded quantifiers to prevent ReDoS
// Patterns are designed to match specific token formats with fixed lengths
const patterns = [
{ regex: /sk-[a-zA-Z0-9]{48}/g, replacement: "[API_KEY_REDACTED]" },
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sk- pattern won't match Anthropic API keys

The pattern sk-[a-zA-Z0-9]{48} was designed for the legacy OpenAI key format. Anthropic API keys — the primary credential for this Claude-based CLI — follow the format sk-ant-api03-[base64-like segment], which contains hyphens and has a different overall length. Because the regex only allows [a-zA-Z0-9] after the sk- prefix, it will never match a real Anthropic key, and those keys will leak into logs unredacted.

Broaden the pattern to cover both formats:

Suggested change
{ regex: /sk-[a-zA-Z0-9]{48}/g, replacement: "[API_KEY_REDACTED]" },
{ regex: /sk-[a-zA-Z0-9-]{20,100}/g, replacement: "[API_KEY_REDACTED]" },

Using a bounded range {20,100} preserves ReDoS protection while covering both the legacy format and the sk-ant-api03-... family.

Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/src/utils/sanitization.ts
Line: 34

Comment:
**`sk-` pattern won't match Anthropic API keys**

The pattern `sk-[a-zA-Z0-9]{48}` was designed for the legacy OpenAI key format. Anthropic API keys — the primary credential for this Claude-based CLI — follow the format `sk-ant-api03-[base64-like segment]`, which contains **hyphens** and has a different overall length. Because the regex only allows `[a-zA-Z0-9]` after the `sk-` prefix, it will never match a real Anthropic key, and those keys will leak into logs unredacted.

Broaden the pattern to cover both formats:

```suggestion
		{ regex: /sk-[a-zA-Z0-9-]{20,100}/g, replacement: "[API_KEY_REDACTED]" },
```

Using a bounded range `{20,100}` preserves ReDoS protection while covering both the legacy format and the `sk-ant-api03-...` family.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +31 to +33
export const StepStartSchema = z.object({
type: z.literal("step_start"),
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

StepStartSchema silently drops session IDs

StepFinishSchema (lines 25–28) explicitly declares sessionID, sessionId, and session_id as optional fields so extractSessionId can recover the session context from finish events. StepStartSchema has no such fields, and Zod's z.object strips unknown keys by default. If the upstream AI engine attaches a session ID to a step_start event, it will be silently removed. Any downstream call to extractSessionId(event) will return null, losing the session context from the very first event.

Add the same session ID fields to StepStartSchema:

Suggested change
export const StepStartSchema = z.object({
type: z.literal("step_start"),
});
export const StepStartSchema = z.object({
type: z.literal("step_start"),
// Session ID fields (various naming conventions)
sessionID: z.string().optional(),
sessionId: z.string().optional(),
session_id: z.string().optional(),
});
Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/src/utils/json-validation.ts
Line: 31-33

Comment:
**`StepStartSchema` silently drops session IDs**

`StepFinishSchema` (lines 25–28) explicitly declares `sessionID`, `sessionId`, and `session_id` as optional fields so `extractSessionId` can recover the session context from finish events. `StepStartSchema` has no such fields, and Zod's `z.object` strips unknown keys by default. If the upstream AI engine attaches a session ID to a `step_start` event, it will be silently removed. Any downstream call to `extractSessionId(event)` will return `null`, losing the session context from the very first event.

Add the same session ID fields to `StepStartSchema`:

```suggestion
export const StepStartSchema = z.object({
	type: z.literal("step_start"),
	// Session ID fields (various naming conventions)
	sessionID: z.string().optional(),
	sessionId: z.string().optional(),
	session_id: z.string().optional(),
});
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +242 to 247
log("error", component, ...args);
}

/**
* Log debug message (only in verbose mode)
*/
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setDebug(false) fails to restore verbose mode

After calling setDebug(true) (which sets loggerState.verboseMode = true), calling setDebug(false) does not reset verboseMode back to false. The if (debug) branch is skipped, so loggerState.verboseMode remains true even after debug mode is disabled. Any component that reads getVerboseMode() or loggerState.verboseMode after setDebug(false) will still see verboseMode === true, causing debug logs to continue appearing silently.

Trace:

  1. setDebug(true)loggerState.debugMode = true, loggerState.verboseMode = true
  2. setDebug(false)loggerState.debugMode = false, loggerState.verboseMode stays true

The fix requires resetting verboseMode when debug is disabled (assuming verbose wasn't independently set):

export function setDebug(debug: boolean): void {
    loggerState.debugMode = debug;
    if (debug) {
        loggerState.verboseMode = true;
    } else if (!loggerState._explicitVerbose) {
        // Only reset verboseMode if it wasn't explicitly set via setVerbose
        loggerState.verboseMode = false;
    }
    verboseMode = loggerState.verboseMode;
}

A simpler approach is to track the pre-debug verbose state and restore it on setDebug(false).

Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/src/ui/logger.ts
Line: 242-247

Comment:
**`setDebug(false)` fails to restore verbose mode**

After calling `setDebug(true)` (which sets `loggerState.verboseMode = true`), calling `setDebug(false)` does not reset `verboseMode` back to `false`. The `if (debug)` branch is skipped, so `loggerState.verboseMode` remains `true` even after debug mode is disabled. Any component that reads `getVerboseMode()` or `loggerState.verboseMode` after `setDebug(false)` will still see `verboseMode === true`, causing debug logs to continue appearing silently.

Trace:
1. `setDebug(true)``loggerState.debugMode = true`, `loggerState.verboseMode = true`
2. `setDebug(false)``loggerState.debugMode = false`, `loggerState.verboseMode` stays `true`

The fix requires resetting `verboseMode` when debug is disabled (assuming verbose wasn't independently set):

```typescript
export function setDebug(debug: boolean): void {
    loggerState.debugMode = debug;
    if (debug) {
        loggerState.verboseMode = true;
    } else if (!loggerState._explicitVerbose) {
        // Only reset verboseMode if it wasn't explicitly set via setVerbose
        loggerState.verboseMode = false;
    }
    verboseMode = loggerState.verboseMode;
}
```

A simpler approach is to track the pre-debug verbose state and restore it on `setDebug(false)`.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +108 to +131

await Promise.allSettled(promises);
cleanupRegistry.clear();
isCleaningUp = false;
}

let isShuttingDown = false;

/**
* Setup process signal handlers for cleanup
*/
export function setupSignalHandlers(): void {
const signals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"];

for (const signal of signals) {
process.on(signal, async () => {
// Prevent duplicate cleanup runs
if (isShuttingDown) {
process.stdout.write(`\nReceived ${signal}, cleanup already in progress...\n`);
return;
}
isShuttingDown = true;

// Use writeSync to avoid event loop issues during exit
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setupSignalHandlers accumulates duplicate listeners on repeated calls

There is no guard preventing setupSignalHandlers from being called more than once. Each call unconditionally adds a new async handler to both SIGINT and SIGTERM. If a module import tree calls this at startup and again in a test harness (or if the module is re-evaluated), duplicate handlers accumulate. Node.js emits a MaxListenersExceededWarning after 10 listeners on a single event, and with async handlers the isShuttingDown guard only prevents subsequent handlers in the same signal delivery — it does not prevent multiple handlers from firing in response to different signals or calls.

Consider a one-time initialization guard:

let signalHandlersRegistered = false;

export function setupSignalHandlers(): void {
    if (signalHandlersRegistered) return;
    signalHandlersRegistered = true;

    const signals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"];
    // ... existing handler registration
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/src/utils/cleanup.ts
Line: 108-131

Comment:
**`setupSignalHandlers` accumulates duplicate listeners on repeated calls**

There is no guard preventing `setupSignalHandlers` from being called more than once. Each call unconditionally adds a new `async` handler to both `SIGINT` and `SIGTERM`. If a module import tree calls this at startup and again in a test harness (or if the module is re-evaluated), duplicate handlers accumulate. Node.js emits a `MaxListenersExceededWarning` after 10 listeners on a single event, and with `async` handlers the `isShuttingDown` guard only prevents *subsequent* handlers in the same signal delivery — it does not prevent multiple handlers from firing in response to different signals or calls.

Consider a one-time initialization guard:

```typescript
let signalHandlersRegistered = false;

export function setupSignalHandlers(): void {
    if (signalHandlersRegistered) return;
    signalHandlersRegistered = true;

    const signals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"];
    // ... existing handler registration
}
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +129 to +140
export function createErrorWithContext(error: unknown, context: Record<string, unknown>): RalphyError {
const standardized = standardizeError(error);

if (standardized.context) {
return new RalphyError(standardized.message, standardized.code, {
...standardized.context,
...context,
});
}

return new RalphyError(standardized.message, standardized.code, context);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createErrorWithContext silently loses error subclass

standardizeError returns a RalphyError subclass (e.g., TimeoutError) unchanged. However, createErrorWithContext then wraps it in new RalphyError(...), losing the original subclass type. Any caller that subsequently does error instanceof TimeoutError will get false, even though error.code === "TIMEOUT_ERROR".

To preserve the subclass, construct a new instance of the original error's constructor rather than always using RalphyError:

Suggested change
export function createErrorWithContext(error: unknown, context: Record<string, unknown>): RalphyError {
const standardized = standardizeError(error);
if (standardized.context) {
return new RalphyError(standardized.message, standardized.code, {
...standardized.context,
...context,
});
}
return new RalphyError(standardized.message, standardized.code, context);
}
export function createErrorWithContext(error: unknown, context: Record<string, unknown>): RalphyError {
const standardized = standardizeError(error);
const ErrorClass = (standardized.constructor as typeof RalphyError) || RalphyError;
return new ErrorClass(standardized.message, standardized.code, {
...(standardized.context ?? {}),
...context,
});
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/src/utils/errors.ts
Line: 129-140

Comment:
`createErrorWithContext` silently loses error subclass

`standardizeError` returns a `RalphyError` subclass (e.g., `TimeoutError`) unchanged. However, `createErrorWithContext` then wraps it in `new RalphyError(...)`, losing the original subclass type. Any caller that subsequently does `error instanceof TimeoutError` will get `false`, even though `error.code === "TIMEOUT_ERROR"`.

To preserve the subclass, construct a new instance of the original error's constructor rather than always using `RalphyError`:

```suggestion
export function createErrorWithContext(error: unknown, context: Record<string, unknown>): RalphyError {
	const standardized = standardizeError(error);
	const ErrorClass = (standardized.constructor as typeof RalphyError) || RalphyError;

	return new ErrorClass(standardized.message, standardized.code, {
		...(standardized.context ?? {}),
		...context,
	});
}
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +114 to +155
export function formatParsedStep(step: ParsedStep, agentNum?: number): string | null {
const prefix = agentNum !== undefined ? `Agent ${agentNum}: ` : "";

// Prioritize concrete actions over generic thoughts
if (step.writing) {
return `${prefix}${step.writing} `;
}

if (step.reading) {
return `${prefix}${step.reading} `;
}

if (step.running) {
return `${prefix}${step.running} `;
}

if (step.thought) {
// Clean up common "Thinking:" prefixes since we wrap in {} later
const cleanedThought = step.thought.replace(/^(Thinking|Analyzing|Considering|Warning|Waiting)[:\s]*/i, "").trim();
return `${prefix}${cleanedThought} `;
}

if (step.executed) {
return `${prefix} Done: ${step.executed.substring(0, 100)} `;
}

if (step.tool) {
return `${prefix} Tool: ${step.tool} `;
}

// If raw but couldn't parse, truncate and show
if (step.raw && step.raw.length > 0) {
const trimmed = step.raw.trim();
if (trimmed.startsWith("{") || trimmed.startsWith('"')) {
// It's JSON we couldn't parse, skip it
return null;
}
return `${prefix}${trimmed.substring(0, 100)} `;
}

return null;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

formatParsedStep truncates field values inconsistently

step.raw and step.executed are capped at 100 characters, but step.reading, step.writing, step.running, and step.thought are returned unbounded. For complex AI output (e.g., JSON tool inputs), this can produce extremely long display strings in log output.

Apply consistent truncation across all fields:

Suggested change
export function formatParsedStep(step: ParsedStep, agentNum?: number): string | null {
const prefix = agentNum !== undefined ? `Agent ${agentNum}: ` : "";
// Prioritize concrete actions over generic thoughts
if (step.writing) {
return `${prefix}${step.writing} `;
}
if (step.reading) {
return `${prefix}${step.reading} `;
}
if (step.running) {
return `${prefix}${step.running} `;
}
if (step.thought) {
// Clean up common "Thinking:" prefixes since we wrap in {} later
const cleanedThought = step.thought.replace(/^(Thinking|Analyzing|Considering|Warning|Waiting)[:\s]*/i, "").trim();
return `${prefix}${cleanedThought} `;
}
if (step.executed) {
return `${prefix} Done: ${step.executed.substring(0, 100)} `;
}
if (step.tool) {
return `${prefix} Tool: ${step.tool} `;
}
// If raw but couldn't parse, truncate and show
if (step.raw && step.raw.length > 0) {
const trimmed = step.raw.trim();
if (trimmed.startsWith("{") || trimmed.startsWith('"')) {
// It's JSON we couldn't parse, skip it
return null;
}
return `${prefix}${trimmed.substring(0, 100)} `;
}
return null;
}
export function formatParsedStep(step: ParsedStep, agentNum?: number): string | null {
const prefix = agentNum !== undefined ? `Agent ${agentNum}: ` : "";
const MAX_DISPLAY_LENGTH = 100;
// Prioritize concrete actions over generic thoughts
if (step.writing) {
return `${prefix}${step.writing.substring(0, MAX_DISPLAY_LENGTH)} `;
}
if (step.reading) {
return `${prefix}${step.reading.substring(0, MAX_DISPLAY_LENGTH)} `;
}
if (step.running) {
return `${prefix}${step.running.substring(0, MAX_DISPLAY_LENGTH)} `;
}
if (step.thought) {
const cleanedThought = step.thought.replace(/^(Thinking|Analyzing|Considering|Warning|Waiting)[:\s]*/i, "").trim();
return `${prefix}${cleanedThought.substring(0, MAX_DISPLAY_LENGTH)} `;
}
if (step.executed) {
return `${prefix} Done: ${step.executed.substring(0, MAX_DISPLAY_LENGTH)} `;
}
if (step.tool) {
return `${prefix} Tool: ${step.tool} `;
}
// If raw but couldn't parse, truncate and show
if (step.raw && step.raw.length > 0) {
const trimmed = step.raw.trim();
if (trimmed.startsWith("{") || trimmed.startsWith('"')) {
// It's JSON we couldn't parse, skip it
return null;
}
return `${prefix}${trimmed.substring(0, MAX_DISPLAY_LENGTH)} `;
}
return null;
}

Additionally, at line 86, rename the local variable from shortArgs to argSummary to match the field it's assigned from and eliminate the misleading implication that the argument has been shortened.

Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/src/utils/ai-output-parser.ts
Line: 114-155

Comment:
`formatParsedStep` truncates field values inconsistently

`step.raw` and `step.executed` are capped at 100 characters, but `step.reading`, `step.writing`, `step.running`, and `step.thought` are returned unbounded. For complex AI output (e.g., JSON tool inputs), this can produce extremely long display strings in log output.

Apply consistent truncation across all fields:

```suggestion
export function formatParsedStep(step: ParsedStep, agentNum?: number): string | null {
	const prefix = agentNum !== undefined ? `Agent ${agentNum}: ` : "";
	const MAX_DISPLAY_LENGTH = 100;

	// Prioritize concrete actions over generic thoughts
	if (step.writing) {
		return `${prefix}${step.writing.substring(0, MAX_DISPLAY_LENGTH)} `;
	}

	if (step.reading) {
		return `${prefix}${step.reading.substring(0, MAX_DISPLAY_LENGTH)} `;
	}

	if (step.running) {
		return `${prefix}${step.running.substring(0, MAX_DISPLAY_LENGTH)} `;
	}

	if (step.thought) {
		const cleanedThought = step.thought.replace(/^(Thinking|Analyzing|Considering|Warning|Waiting)[:\s]*/i, "").trim();
		return `${prefix}${cleanedThought.substring(0, MAX_DISPLAY_LENGTH)} `;
	}

	if (step.executed) {
		return `${prefix} Done: ${step.executed.substring(0, MAX_DISPLAY_LENGTH)} `;
	}

	if (step.tool) {
		return `${prefix} Tool: ${step.tool} `;
	}

	// If raw but couldn't parse, truncate and show
	if (step.raw && step.raw.length > 0) {
		const trimmed = step.raw.trim();
		if (trimmed.startsWith("{") || trimmed.startsWith('"')) {
			// It's JSON we couldn't parse, skip it
			return null;
		}
		return `${prefix}${trimmed.substring(0, MAX_DISPLAY_LENGTH)} `;
	}

	return null;
}
```

Additionally, at line 86, rename the local variable from `shortArgs` to `argSummary` to match the field it's assigned from and eliminate the misleading implication that the argument has been shortened.

How can I resolve this? If you propose a fix, please make it concise.

isEscaped = true;
continue;
}
if (char === '"' && !isEscaped) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dead condition !isEscaped in quote-detection branch

At this point in the loop, isEscaped is guaranteed to be false. The guard at lines 124–127 handles the true case with continue, so only false can reach line 132. The condition can be removed:

Suggested change
if (char === '"' && !isEscaped) {
if (char === '"') {
inString = !inString;
continue;
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/src/utils/json-validation.ts
Line: 132

Comment:
Dead condition `!isEscaped` in quote-detection branch

At this point in the loop, `isEscaped` is guaranteed to be `false`. The guard at lines 124–127 handles the true case with `continue`, so only `false` can reach line 132. The condition can be removed:

```suggestion
			if (char === '"') {
				inString = !inString;
				continue;
			}
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +52 to +60
export const DEFAULT_IGNORE_PATTERNS = [
".git",
"node_modules",
".ralphy-sandboxes",
".ralphy-worktrees",
".ralphy",
"agent-*",
"sandbox-*",
];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DEFAULT_IGNORE_PATTERNS missing common build-output directories

The list covers version-control and Ralphy-internal directories but omits the most common build artifact directories for TypeScript/JS, Rust, and Python ecosystems. In a mid-size monorepo, these can contain hundreds of thousands of files, making an unfiltered index extremely slow.

Add at minimum:

Suggested change
export const DEFAULT_IGNORE_PATTERNS = [
".git",
"node_modules",
".ralphy-sandboxes",
".ralphy-worktrees",
".ralphy",
"agent-*",
"sandbox-*",
];
export const DEFAULT_IGNORE_PATTERNS = [
".git",
"node_modules",
".ralphy-sandboxes",
".ralphy-worktrees",
".ralphy",
"agent-*",
"sandbox-*",
// Build artifacts
"dist",
"build",
"out",
".next",
".nuxt",
".svelte-kit",
"target", // Rust/Maven
"__pycache__",
".cache",
"coverage",
".turbo",
];

Note: these patterns are currently anchored to workspace root by globToRegex. Consider also updating globToRegex to support **/ prefixes for matching nested dependencies (e.g., packages/app/node_modules).

Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/src/config/constants.ts
Line: 52-60

Comment:
`DEFAULT_IGNORE_PATTERNS` missing common build-output directories

The list covers version-control and Ralphy-internal directories but omits the most common build artifact directories for TypeScript/JS, Rust, and Python ecosystems. In a mid-size monorepo, these can contain hundreds of thousands of files, making an unfiltered index extremely slow.

Add at minimum:

```suggestion
export const DEFAULT_IGNORE_PATTERNS = [
	".git",
	"node_modules",
	".ralphy-sandboxes",
	".ralphy-worktrees",
	".ralphy",
	"agent-*",
	"sandbox-*",
	// Build artifacts
	"dist",
	"build",
	"out",
	".next",
	".nuxt",
	".svelte-kit",
	"target",       // Rust/Maven
	"__pycache__",
	".cache",
	"coverage",
	".turbo",
];
```

Note: these patterns are currently anchored to workspace root by `globToRegex`. Consider also updating `globToRegex` to support `**/` prefixes for matching nested dependencies (e.g., `packages/app/node_modules`).

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +84 to +113
interface WebhookSessionData {
sessionId: string;
engine: string;
mode: string;
cliVersion: string;
platform: string;
totalTokensIn: number;
totalTokensOut: number;
totalDurationMs: number;
taskCount: number;
successCount: number;
failedCount: number;
toolCalls: {
toolName: string;
callCount: number;
successCount: number;
failedCount: number;
avgDurationMs: number;
}[];
tags?: string[];
}

/**
* Full session details for webhook (full privacy mode)
*/
interface WebhookSessionDetails {
prompt?: string;
response?: string;
filePaths?: string[];
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WebhookSessionData and WebhookSessionDetails not exported

These two interfaces form the public contract of TelemetryWebhookPayload (as field types session and details) but are not exported. Consumers that need to work with these fields (e.g., to type-check a webhook handler) cannot declare typed local variables without repeating the shape inline.

Export them explicitly:

Suggested change
interface WebhookSessionData {
sessionId: string;
engine: string;
mode: string;
cliVersion: string;
platform: string;
totalTokensIn: number;
totalTokensOut: number;
totalDurationMs: number;
taskCount: number;
successCount: number;
failedCount: number;
toolCalls: {
toolName: string;
callCount: number;
successCount: number;
failedCount: number;
avgDurationMs: number;
}[];
tags?: string[];
}
/**
* Full session details for webhook (full privacy mode)
*/
interface WebhookSessionDetails {
prompt?: string;
response?: string;
filePaths?: string[];
}
export interface WebhookSessionData {
sessionId: string;
engine: string;
mode: string;
cliVersion: string;
platform: string;
totalTokensIn: number;
totalTokensOut: number;
totalDurationMs: number;
taskCount: number;
successCount: number;
failedCount: number;
toolCalls: {
toolName: string;
callCount: number;
successCount: number;
failedCount: number;
avgDurationMs: number;
}[];
tags?: string[];
}
/**
* Full session details for webhook (full privacy mode)
*/
export interface WebhookSessionDetails {
prompt?: string;
response?: string;
filePaths?: string[];
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/src/telemetry/types.ts
Line: 84-113

Comment:
`WebhookSessionData` and `WebhookSessionDetails` not exported

These two interfaces form the public contract of `TelemetryWebhookPayload` (as field types `session` and `details`) but are not exported. Consumers that need to work with these fields (e.g., to type-check a webhook handler) cannot declare typed local variables without repeating the shape inline.

Export them explicitly:

```suggestion
export interface WebhookSessionData {
	sessionId: string;
	engine: string;
	mode: string;
	cliVersion: string;
	platform: string;
	totalTokensIn: number;
	totalTokensOut: number;
	totalDurationMs: number;
	taskCount: number;
	successCount: number;
	failedCount: number;
	toolCalls: {
		toolName: string;
		callCount: number;
		successCount: number;
		failedCount: number;
		avgDurationMs: number;
	}[];
	tags?: string[];
}

/**
 * Full session details for webhook (full privacy mode)
 */
export interface WebhookSessionDetails {
	prompt?: string;
	response?: string;
	filePaths?: string[];
}
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +327 to +460
function isCommonWord(word: string): boolean {
const commonWords = new Set([
"the",
"and",
"for",
"are",
"but",
"not",
"you",
"all",
"can",
"had",
"her",
"was",
"one",
"our",
"out",
"day",
"get",
"has",
"him",
"his",
"how",
"its",
"may",
"new",
"now",
"old",
"see",
"two",
"who",
"boy",
"did",
"she",
"use",
"way",
"many",
"oil",
"sit",
"set",
"run",
"eat",
"far",
"sea",
"eye",
"ago",
"off",
"too",
"any",
"say",
"man",
"try",
"ask",
"end",
"why",
"let",
"put",
"own",
"tell",
"very",
"when",
"come",
"here",
"just",
"like",
"long",
"make",
"over",
"such",
"take",
"than",
"them",
"well",
"were",
"will",
"with",
"have",
"from",
"they",
"know",
"want",
"been",
"good",
"much",
"some",
"time",
"this",
"that",
"would",
"there",
"their",
"what",
"said",
"each",
"which",
"about",
"could",
"other",
"after",
"first",
"never",
"these",
"think",
"where",
"being",
"every",
"great",
"might",
"shall",
"still",
"those",
"while",
"true",
"false",
"null",
"undefined",
"return",
"import",
"export",
"default",
"async",
"await",
"yield",
"throw",
"catch",
"finally",
"break",
"continue",
"switch",
"case",
"try",
"new",
]);
return commonWords.has(word.toLowerCase());
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate entries in isCommonWord Set

The Set literal contains duplicate entries. Set deduplicates on construction so there's no runtime impact, but the duplicates are misleading:

  • "try" appears at lines 378 and 457
  • "new" appears at line 352 and 458

Remove the duplicates to keep the list clean and maintainable.

Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/src/utils/file-indexer.ts
Line: 327-460

Comment:
Duplicate entries in `isCommonWord` Set

The Set literal contains duplicate entries. `Set` deduplicates on construction so there's no runtime impact, but the duplicates are misleading:
- `"try"` appears at lines 378 and 457
- `"new"` appears at line 352 and 458

Remove the duplicates to keep the list clean and maintainable.

How can I resolve this? If you propose a fix, please make it concise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant