Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,5 @@ test-results/
# Claude Code — commit settings.json but exclude local settings (may contain secrets)
.claude/settings.local.json

# Progress comment state file (ephemeral, written by ProgressMonitor)
# Progress comment state file (legacy — kept to ignore stale files from older runs)
.cascade-progress-comment-id

13 changes: 12 additions & 1 deletion src/backends/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ import { withGitHubToken } from '../github/client.js';
import type { AgentInput, AgentResult, CascadeConfig, ProjectConfig } from '../types/index.js';
import { postProcessResult } from './postProcess.js';
import { createProgressMonitor } from './progress.js';
import { augmentProjectSecrets, resolveGitHubToken } from './secretBuilder.js';
import {
augmentProjectSecrets,
injectProgressCommentId,
resolveGitHubToken,
} from './secretBuilder.js';
import type { AgentBackend, AgentBackendInput } from './types.js';

/**
Expand Down Expand Up @@ -115,6 +119,13 @@ async function buildBackendInput(
// Build per-project secrets with CASCADE env var injections
const projectSecrets = await augmentProjectSecrets(project, agentType, input);

// Inject pre-seeded progress comment ID so the subprocess finds it at startup
injectProgressCommentId(
projectSecrets,
cardId,
input.ackCommentId as string | number | undefined,
);

// Override GITHUB_TOKEN in subprocess secrets with agent-scoped token
if (gitHubToken && profile.needsGitHubToken) {
projectSecrets.GITHUB_TOKEN = gitHubToken;
Expand Down
5 changes: 5 additions & 0 deletions src/backends/claude-code/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
* server-side secrets from leaking into agent environments.
*/

import { ENV_VAR_NAME as PROGRESS_COMMENT_ENV_VAR } from '../progressState.js';

/** Exact variable names to pass through. */
export const ALLOWED_ENV_EXACT = new Set([
// System
Expand All @@ -27,6 +29,9 @@ export const ALLOWED_ENV_EXACT = new Set([
// Squint
'SQUINT_DB_PATH',

// Progress comment state (pre-seeded ack comment ID)
PROGRESS_COMMENT_ENV_VAR,

// Node
'NODE_PATH',
'NODE_EXTRA_CA_CERTS',
Expand Down
11 changes: 1 addition & 10 deletions src/backends/claude-code/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import type {
PreToolUseHookInput,
SyncHookJSONOutput,
} from '@anthropic-ai/claude-agent-sdk';
import { STATE_FILE_NAME } from '../progressState.js';
import type { LogWriter } from '../types.js';

/**
Expand Down Expand Up @@ -132,15 +131,7 @@ function checkUncommittedChanges(logWriter: LogWriter, repoDir: string): SyncHoo
}).trim();
if (!status) return null;

// Filter out CASCADE internal state files that are not code changes
const meaningful = status
.split('\n')
.filter((line) => !line.endsWith(STATE_FILE_NAME))
.join('\n')
.trim();
if (!meaningful) return null;

logWriter('WARN', 'Stop hook blocked: uncommitted changes', { status: meaningful });
logWriter('WARN', 'Stop hook blocked: uncommitted changes', { status });
return {
decision: 'block',
reason: 'You have uncommitted changes. Stage, commit, then use cascade-tools github create-pr.',
Expand Down
18 changes: 6 additions & 12 deletions src/backends/progressMonitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@ export class ProgressMonitor implements ProgressReporter {
? new PMProgressPoster({
agentType: config.agentType,
cardId: config.trello.cardId,
repoDir: config.repoDir,
logWriter: config.logWriter,
})
: null;
Expand Down Expand Up @@ -125,13 +124,9 @@ export class ProgressMonitor implements ProgressReporter {
commentId: this.config.preSeededCommentId,
});

// Write state file so PostComment gadget can find it
if (this.config.repoDir && this.config.trello) {
writeProgressCommentId(
this.config.repoDir,
this.config.trello.cardId,
this.config.preSeededCommentId,
);
// Write env var so PostComment gadget can find it
if (this.config.trello) {
writeProgressCommentId(this.config.trello.cardId, this.config.preSeededCommentId);
}
} else if (this.pmPoster) {
// Post initial comment immediately (fire-and-forget)
Expand All @@ -148,12 +143,11 @@ export class ProgressMonitor implements ProgressReporter {

stop(): void {
this.scheduler.stop();
// Clean up state file on stop (best-effort — stop() is called from finally
// blocks, so an rmSync failure must not mask the actual agent result)
// Clean up env var on stop (best-effort — stop() is called from finally blocks)
try {
clearProgressCommentId(this.config.repoDir);
clearProgressCommentId();
} catch {
// State file cleanup is best-effort
// Env var cleanup is best-effort
}
}

Expand Down
82 changes: 27 additions & 55 deletions src/backends/progressState.ts
Original file line number Diff line number Diff line change
@@ -1,83 +1,55 @@
/**
* File-based state bridge for sharing the progress comment ID between
* Env-var-based state bridge for sharing the progress comment ID between
* the ProgressMonitor (which creates the initial comment) and the
* PostComment gadget (which posts the final summary).
*
* Uses a state file `.cascade-progress-comment-id` written to the repo
* working directory. This approach works for both the llmist backend
* (same process) and the Claude Code backend (subprocess), since both
* share the same filesystem.
* Uses the `CASCADE_PROGRESS_COMMENT_ID` environment variable following
* the existing `CASCADE_*` naming pattern. The env var format is
* `<workItemId>:<commentId>`.
*
* File format: `<workItemId>:<commentId>`
* For the pre-seeded case (~90% of runs), the env var is injected into
* the Claude Code subprocess via `projectSecrets` before subprocess launch,
* so it is available from startup. For the dynamic case (ProgressMonitor
* `postInitial()`), `process.env` is updated in-process — same-process
* consumers see it immediately; cross-process visibility is an accepted gap.
*/

import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';

export const STATE_FILE_NAME = '.cascade-progress-comment-id';
export const ENV_VAR_NAME = 'CASCADE_PROGRESS_COMMENT_ID';

/**
* Writes the progress comment ID to the state file in the given repo directory.
* Writes the progress comment ID to the env var.
*
* @param repoDir - The working directory where the state file will be written.
* @param workItemId - The work item ID (Trello card ID or JIRA issue key).
* @param commentId - The comment ID returned by addComment().
*/
export function writeProgressCommentId(
repoDir: string,
workItemId: string,
commentId: string,
): void {
const filePath = join(repoDir, STATE_FILE_NAME);
writeFileSync(filePath, `${workItemId}:${commentId}`, 'utf-8');
export function writeProgressCommentId(workItemId: string, commentId: string): void {
process.env[ENV_VAR_NAME] = `${workItemId}:${commentId}`;
}

/**
* Reads the progress comment state from the state file.
* Reads the progress comment state from the env var.
*
* @param repoDir - Optional directory containing the state file. Defaults to
* `process.cwd()` if not provided. For cross-process usage
* (e.g., Claude Code subprocess), the caller should ensure
* `process.chdir(repoDir)` has been called, or pass `repoDir`
* explicitly.
* @returns `{ workItemId, commentId }` if the state file exists and is valid,
* @returns `{ workItemId, commentId }` if the env var is set and valid,
* or `null` if not found or malformed.
*/
export function readProgressCommentId(
repoDir?: string,
): { workItemId: string; commentId: string } | null {
const dir = repoDir ?? process.cwd();
const filePath = join(dir, STATE_FILE_NAME);

if (!existsSync(filePath)) return null;
export function readProgressCommentId(): { workItemId: string; commentId: string } | null {
const value = process.env[ENV_VAR_NAME];
if (!value) return null;

try {
const content = readFileSync(filePath, 'utf-8').trim();
const colonIndex = content.indexOf(':');
if (colonIndex === -1) return null;
const colonIndex = value.indexOf(':');
if (colonIndex === -1) return null;

const workItemId = content.slice(0, colonIndex);
const commentId = content.slice(colonIndex + 1);
const workItemId = value.slice(0, colonIndex);
const commentId = value.slice(colonIndex + 1);

if (!workItemId || !commentId) return null;
if (!workItemId || !commentId) return null;

return { workItemId, commentId };
} catch {
return null;
}
return { workItemId, commentId };
}

/**
* Deletes the progress comment state file.
*
* @param repoDir - Optional directory containing the state file. Defaults to
* `process.cwd()` if not provided.
* Clears the progress comment state by deleting the env var.
*/
export function clearProgressCommentId(repoDir?: string): void {
const dir = repoDir ?? process.cwd();
const filePath = join(dir, STATE_FILE_NAME);

if (existsSync(filePath)) {
rmSync(filePath);
}
export function clearProgressCommentId(): void {
delete process.env[ENV_VAR_NAME];
}
29 changes: 11 additions & 18 deletions src/backends/progressState/pmPoster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* PM (Project Management) progress comment poster.
*
* Manages the create-once/update-in-place/fallback-to-new lifecycle
* for progress comments on Trello/JIRA work items. Handles state file
* for progress comments on Trello/JIRA work items. Handles env-var
* coordination with the PostComment gadget subprocess.
*/

Expand All @@ -14,7 +14,6 @@ import type { LogWriter } from '../types.js';
export interface PMProgressPosterConfig {
agentType: string;
cardId: string;
repoDir?: string;
logWriter: LogWriter;
}

Expand All @@ -38,12 +37,6 @@ export class PMProgressPoster {
);
}

private maybeWriteStateFile(commentId: string | null): void {
if (this.config.repoDir && commentId) {
writeProgressCommentId(this.config.repoDir, this.config.cardId, commentId);
}
}

async postInitial(): Promise<void> {
const provider = getPMProviderOrNull();
if (!provider) return;
Expand All @@ -55,8 +48,8 @@ export class PMProgressPoster {
commentId: this.progressCommentId,
});

// Write state file so PostComment gadget can update this comment
this.maybeWriteStateFile(this.progressCommentId);
// Write env var so PostComment gadget can update this comment
writeProgressCommentId(this.config.cardId, this.progressCommentId);
}

async update(summary: string): Promise<void> {
Expand All @@ -66,11 +59,11 @@ export class PMProgressPoster {
const { cardId } = this.config;

if (this.progressCommentId) {
// If the PostComment gadget (subprocess) cleared the state file,
// If the PostComment gadget cleared the env var,
// the agent has posted its final comment to this ID — do not overwrite.
const stateFile = readProgressCommentId(this.config.repoDir);
if (!stateFile) {
this.config.logWriter('DEBUG', 'State file cleared by agent — skipping progress update', {
const envVarState = readProgressCommentId();
if (!envVarState) {
this.config.logWriter('DEBUG', 'Env var cleared by agent — skipping progress update', {
commentId: this.progressCommentId,
});
this.progressCommentId = null;
Expand All @@ -94,8 +87,8 @@ export class PMProgressPoster {
cardId,
commentId: this.progressCommentId,
});
// Update state file with new comment ID
this.maybeWriteStateFile(this.progressCommentId);
// Update env var with new comment ID
writeProgressCommentId(cardId, this.progressCommentId);
}
} else {
// First tick: create the comment and store its ID.
Expand All @@ -106,8 +99,8 @@ export class PMProgressPoster {
cardId,
commentId: this.progressCommentId,
});
// Write state file so PostComment gadget can find this comment
this.maybeWriteStateFile(this.progressCommentId);
// Write env var so PostComment gadget can find this comment
writeProgressCommentId(cardId, this.progressCommentId);
}
}
}
18 changes: 18 additions & 0 deletions src/backends/secretBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getPersonaToken } from '../github/personas.js';
import { getJiraConfig } from '../pm/config.js';
import type { AgentInput, ProjectConfig } from '../types/index.js';
import { parseRepoFullName } from '../utils/repo.js';
import { ENV_VAR_NAME } from './progressState.js';

/**
* Resolve the GitHub token for profiles that need GitHub client access.
Expand Down Expand Up @@ -62,3 +63,20 @@ export async function augmentProjectSecrets(

return projectSecrets;
}

/**
* Inject the pre-seeded progress comment ID into project secrets so the
* Claude Code subprocess can find it via the CASCADE_PROGRESS_COMMENT_ID env var.
*
* Only injects when ackCommentId is a string (PM comment) and cardId is set.
* GitHub ack comments (numeric IDs) are handled separately via session state.
*/
export function injectProgressCommentId(
projectSecrets: Record<string, string>,
cardId: string | undefined,
ackCommentId: string | number | undefined,
): void {
if (cardId && typeof ackCommentId === 'string' && ackCommentId) {
projectSecrets[ENV_VAR_NAME] = `${cardId}:${ackCommentId}`;
}
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No unit tests for injectProgressCommentId. The existing secretBuilder.test.ts has thorough coverage for the other two exports — adding a few cases (string ackCommentId with cardId injects, numeric ackCommentId skips, missing cardId skips) would maintain the same standard.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added 5 unit tests for injectProgressCommentId in secretBuilder.test.ts, covering all branches: string ackCommentId with cardId (injects), numeric ackCommentId (skips), missing cardId (skips), undefined ackCommentId (skips), and empty string ackCommentId (skips).

34 changes: 0 additions & 34 deletions tests/unit/backends/claude-code-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,40 +223,6 @@ describe('buildStopHooks', () => {
);
});

it('ignores .cascade-progress-comment-id in uncommitted changes', async () => {
mockExecSync.mockReturnValueOnce('?? .cascade-progress-comment-id');
mockExecSync.mockReturnValueOnce(''); // git log (no unpushed)

const logWriter = makeLogWriter();
const [matcher] = buildStopHooks(logWriter, '/tmp/repo');
const [hook] = matcher.hooks;

const result = await hook(makeStopInput(), undefined, { signal: AbortSignal.timeout(5000) });

expect(result).toEqual({ decision: 'approve' });
expect(logWriter).not.toHaveBeenCalledWith('WARN', expect.anything(), expect.anything());
});

it('still blocks when real changes exist alongside .cascade-progress-comment-id', async () => {
mockExecSync.mockReturnValueOnce('?? .cascade-progress-comment-id\n M src/index.ts');

const logWriter = makeLogWriter();
const [matcher] = buildStopHooks(logWriter, '/tmp/repo');
const [hook] = matcher.hooks;

const result = await hook(makeStopInput(), undefined, { signal: AbortSignal.timeout(5000) });

expect(result).toEqual({
decision: 'block',
reason: expect.stringContaining('uncommitted changes'),
});
expect(logWriter).toHaveBeenCalledWith(
'WARN',
'Stop hook blocked: uncommitted changes',
expect.objectContaining({ status: 'M src/index.ts' }),
);
});

it('blocks when unpushed commits exist (no upstream tracking)', async () => {
// git status --porcelain returns clean
mockExecSync.mockReturnValueOnce('');
Expand Down
Loading