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
27 changes: 26 additions & 1 deletion bin/cascade-tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,32 @@ import { Config, run } from '@oclif/core';
// runs commands lazily, and Spec 006/5 removed the legacy self-bootstrap
// path, so side-effect imports have to fire at the entry point.
// Without this, `cascade-tools pm <cmd>` throws `Unknown PM integration type`.
await import('../dist/cli/bootstrap.js');
//
// 2026-05-10: agents in worker containers sometimes invoke this script as
// `node bin/cascade-tools.js …` from a fresh repo checkout where dist/ is
// not built (the installed `cascade-tools` binary in PATH is the right
// invocation). The raw ERR_MODULE_NOT_FOUND stack trace hid the recovery
// path from the agent on prod run ff6adf00. Trap that one specific case
// and emit a one-line stderr explainer instead. Any other module-not-found
// — e.g. a real missing dist file mid-bootstrap — propagates unchanged.
try {
await import('../dist/cli/bootstrap.js');
} catch (err) {
if (
err &&
err.code === 'ERR_MODULE_NOT_FOUND' &&
typeof err.message === 'string' &&
/dist\/cli\/bootstrap/.test(err.message)
) {
process.stderr.write(
'cascade-tools: dist/ is not built in this checkout. ' +
'Use the installed `cascade-tools` from PATH (already available in the worker container), ' +
'or run `npm run build` first if you need to invoke `node bin/cascade-tools.js` directly.\n',
);
process.exit(1);
}
throw err;
}

// cascade-tools uses its own oclif config independent of package.json,
// which now points to the dashboard CLI (cascade binary).
Expand Down
27 changes: 17 additions & 10 deletions src/friction/types.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import type { ProjectConfig } from '../types/index.js';

export type FrictionCategory =
| 'tooling'
| 'environment'
| 'permissions'
| 'dependency'
| 'test-failure'
| 'pm-data'
| 'scm-data'
| 'other';
/**
* Free-form labels accepted as-is from the agent.
*
* Originally typed as a closed union (8 categories × 4 severities). Loosened
* on 2026-05-10 after prod run `ff6adf00` showed an agent recognizing a
* textbook friction (CASCADE_ORG_ID env-var leak in tests) but failing to
* file because oclif's enum gate rejected `--severity 'medium slowdown'`
* (the agent took the describe text literally — the gadget describe was
* `'Severity: low annoyance, medium slowdown, ...'`). The agent then crashed
* trying to discover correct values via `node bin/cascade-tools.js --help`
* because dist/ wasn't built in the workspace checkout, and gave up.
*
* Free-form for now lets the agent file any reasonable label. We can
* cluster + re-tighten after a week of real usage data.
*/
export type FrictionCategory = string;

export type FrictionSeverity = 'low' | 'medium' | 'high' | 'critical';
export type FrictionSeverity = string;

export interface FrictionReport {
/** Stable id used for sidecar deduplication across queued/filed events. */
Expand Down
44 changes: 12 additions & 32 deletions src/gadgets/pm/core/reportFriction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,7 @@ import {
appendFiledFrictionReport,
appendQueuedFrictionReport,
} from '../../../friction/sidecar.js';
import type {
FrictionCategory,
FrictionReport,
FrictionSeverity,
} from '../../../friction/types.js';
import type { FrictionReport } from '../../../friction/types.js';
import type { ProjectConfig } from '../../../types/index.js';
import {
FRICTION_SIDECAR_ENV_VAR,
Expand All @@ -30,29 +26,18 @@ import {

const DEFAULT_FRICTION_SIDECAR_PATH = '.cascade/friction-reports.jsonl';

const CATEGORIES = [
'tooling',
'environment',
'permissions',
'dependency',
'test-failure',
'pm-data',
'scm-data',
'other',
] as const satisfies readonly FrictionCategory[];

const SEVERITIES = [
'low',
'medium',
'high',
'critical',
] as const satisfies readonly FrictionSeverity[];

export interface ReportFrictionParams {
summary: string;
details: string;
category: FrictionCategory;
severity: FrictionSeverity;
/**
* Free-form category and severity. Originally validated against an
* 8-member / 4-member enum, but loosened on 2026-05-10 (run ff6adf00)
* after agents took the gadget describe text literally and oclif
* rejected `--severity 'medium slowdown'`. Cluster + re-tighten later
* once we have real usage data.
*/
category: string;
severity: string;
whileDoing?: string;
project?: ProjectConfig;
sidecarPath?: string;
Expand Down Expand Up @@ -130,11 +115,6 @@ function projectFromEnv(): ProjectConfig {
} as ProjectConfig;
}

function requireEnum<T extends string>(value: string, allowed: readonly T[], name: string): T {
if ((allowed as readonly string[]).includes(value)) return value as T;
throw new Error(`${name} must be one of: ${allowed.join(', ')}`);
}

function parseOptionalInt(value: string | undefined): number | undefined {
if (!value) return undefined;
const parsed = Number.parseInt(value, 10);
Expand Down Expand Up @@ -167,8 +147,8 @@ function buildReport(params: ReportFrictionParams, project: ProjectConfig): Fric
reportId: randomUUID(),
summary: params.summary,
details: params.details,
category: requireEnum(params.category, CATEGORIES, 'category'),
severity: requireEnum(params.severity, SEVERITIES, 'severity'),
category: params.category,
severity: params.severity,
whileDoing: params.whileDoing?.trim() || 'not specified',
createdAt: new Date().toISOString(),
context: {
Expand Down
20 changes: 4 additions & 16 deletions src/gadgets/pm/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,26 +182,14 @@ export const reportFrictionDef: ToolDefinition = {
required: true,
},
category: {
type: 'enum',
options: [
'tooling',
'environment',
'permissions',
'dependency',
'test-failure',
'pm-data',
'scm-data',
'other',
],
type: 'string',
describe:
'Friction category: tooling, environment, permissions, dependency, test-failure, pm-data, scm-data, or other',
'Friction category (e.g. tooling, environment, permissions, dependency, test-failure, pm-data, scm-data, other)',
required: true,
},
severity: {
type: 'enum',
options: ['low', 'medium', 'high', 'critical'],
describe:
'Severity: low annoyance, medium slowdown, high blocker risk, or critical hard blocker',
type: 'string',
describe: 'Severity (e.g. low, medium, high, critical)',
required: true,
},
whileDoing: {
Expand Down
12 changes: 11 additions & 1 deletion tests/unit/backends/toolManifests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,12 @@ describe('getToolManifests', () => {
expect(names).toContain('PMDeleteChecklistItem');
});

it('ReportFriction has enum category/severity and details-file support', () => {
it('ReportFriction has free-form string category/severity and details-file support', () => {
// 2026-05-10: category/severity were originally enum-typed but
// loosened to free-form strings after prod run `ff6adf00` showed
// agents misreading the gadget describe text. The manifest already
// emitted `type: 'string'` for these (manifest generator coerces
// enum → string); the underlying gadget def now matches.
const manifests = getToolManifests();
const reportFriction = manifests.find((m) => m.name === 'ReportFriction');
expect(reportFriction).toBeDefined();
Expand All @@ -60,6 +65,11 @@ describe('getToolManifests', () => {
severity: { type: 'string', required: true },
'details-file': { type: 'string' },
});
// Pin no `options` array on the manifest — proves the loosening
// reached the agent-facing surface.
const params = reportFriction?.parameters as Record<string, { options?: unknown }>;
expect(params.category.options).toBeUndefined();
expect(params.severity.options).toBeUndefined();
});

it('includes GitHub PR tools', () => {
Expand Down
42 changes: 31 additions & 11 deletions tests/unit/gadgets/pm/core/reportFriction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,17 +149,37 @@ describe('reportFriction', () => {
rmSync(path, { force: true });
});

it('validates category and severity in the core function', async () => {
await expect(
reportFriction({
project,
sidecarPath: sidecarPath(),
summary: 'Bad category',
details: 'Invalid classification.',
category: 'invalid' as never,
severity: 'medium',
}),
).rejects.toThrow('category must be one of');
it('accepts any string for category and severity (loosened 2026-05-10 — see plan)', async () => {
// Originally enforced an enum (8 categories × 4 severities) via
// `requireEnum`. Loosened after prod run `ff6adf00` showed an agent
// taking the gadget describe text literally and oclif rejecting
// `--severity 'medium slowdown'`. We now pass through whatever the
// agent provides; cluster + re-tighten once we have real usage data.
const path = sidecarPath();
const result = await reportFriction({
project,
sidecarPath: path,
summary: 'Quirky friction',
details: 'Agent invented a label that used to reject.',
category: 'something-not-in-the-old-enum',
severity: 'medium slowdown',
});

// Either filed (if materializer succeeds in this test setup) or
// queued_slot_missing (if the project under test lacks a friction
// slot). Both prove the validation gate no longer rejects.
expect(['filed', 'queued_slot_missing', 'queued_for_retry']).toContain(result.status);

// The queued sidecar event records the values verbatim — pin that
// the report stored what the agent passed.
const events = readFileSync(path, 'utf-8')
.trim()
.split('\n')
.map((line) => JSON.parse(line));
const queued = events.find((e) => e.event === 'queued');
expect(queued?.report.category).toBe('something-not-in-the-old-enum');
expect(queued?.report.severity).toBe('medium slowdown');
rmSync(path, { force: true });
});

it('uses SessionState for run/work-item/PR metadata when env vars are absent (LLMist in-process path)', async () => {
Expand Down
42 changes: 23 additions & 19 deletions tests/unit/gadgets/pm/definitions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,27 +210,31 @@ describe('PM gadget definitions', () => {
expect(reportFrictionDef.parameters.severity?.required).toBe(true);
});

it('category and severity are enums with expected options', () => {
it('category and severity accept any string with example values surfaced via describe', () => {
// 2026-05-10: deliberately loosened from `type: 'enum'` after prod run
// `ff6adf00` showed an agent recognizing a textbook friction but
// failing to file because oclif's enum gate rejected
// `--severity 'medium slowdown'` (the agent took the prior describe
// text "Severity: low annoyance, medium slowdown, ..." literally).
// Free-form for now; cluster + re-tighten once we have real usage data.
const category = reportFrictionDef.parameters.category;
const severity = reportFrictionDef.parameters.severity;
expect(category?.type).toBe('enum');
expect((category as { options?: string[] }).options).toEqual([
'tooling',
'environment',
'permissions',
'dependency',
'test-failure',
'pm-data',
'scm-data',
'other',
]);
expect(severity?.type).toBe('enum');
expect((severity as { options?: string[] }).options).toEqual([
'low',
'medium',
'high',
'critical',
]);

expect(category?.type).toBe('string');
expect(severity?.type).toBe('string');

// Pin the explicit absence of `options` so a future revert to enum
// fails this test loudly.
expect((category as { options?: unknown }).options).toBeUndefined();
expect((severity as { options?: unknown }).options).toBeUndefined();

// Pin the new describe text — values listed inside parentheses
// instead of mixed with prose, which is what made the agent take
// the description as the literal value.
expect(category?.describe).toBe(
'Friction category (e.g. tooling, environment, permissions, dependency, test-failure, pm-data, scm-data, other)',
);
expect(severity?.describe).toBe('Severity (e.g. low, medium, high, critical)');
});

it('has details file input alternative', () => {
Expand Down
34 changes: 34 additions & 0 deletions tests/unit/web/jira-wizard-generator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
* This file is the new coverage for the migrated wizard definition.
*/

import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { jiraManifest } from '../../../src/integrations/pm/jira/manifest.js';
import {
Expand All @@ -14,6 +17,19 @@ import {
import { IssueTypeMappingStep } from '../../../web/src/components/projects/pm-providers/jira/issue-type-step.js';
import { jiraProviderWizard } from '../../../web/src/components/projects/pm-providers/jira/wizard.js';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const REPO_ROOT = resolve(__dirname, '..', '..', '..');
const JIRA_HOOKS_PATH = resolve(
REPO_ROOT,
'web/src/components/projects/pm-providers/jira/hooks.ts',
);
const JIRA_WIZARD_PATH = resolve(
REPO_ROOT,
'web/src/components/projects/pm-providers/jira/wizard.ts',
);
const PM_WIZARD_HOOKS_PATH = resolve(REPO_ROOT, 'web/src/components/projects/pm-wizard-hooks.ts');

describe('JIRA wizardSpec through the shared generator (post plan 011/3)', () => {
it('each declared standard step dispatches to the corresponding real shared component', () => {
const steps = jiraManifest.wizardSpec?.steps ?? [];
Expand Down Expand Up @@ -82,3 +98,21 @@ describe('jiraProviderWizard (post plan 011/3)', () => {
expect(jiraProviderWizard.useProviderHooks).toBeDefined();
});
});

describe('jiraProviderWizard provider-owned hooks', () => {
it('imports JIRA-specific hooks from the JIRA provider folder', () => {
const source = readFileSync(JIRA_WIZARD_PATH, 'utf8');
expect(source).toContain("} from './hooks.js'");
expect(source).not.toContain("} from '../../pm-wizard-hooks.js'");
});

it('keeps JIRA-specific hook definitions out of the shared wizard hook module', () => {
const jiraHooks = readFileSync(JIRA_HOOKS_PATH, 'utf8');
const sharedHooks = readFileSync(PM_WIZARD_HOOKS_PATH, 'utf8');

for (const hookName of ['useJiraDiscovery', 'useJiraCustomFieldCreation']) {
expect(jiraHooks).toContain(`export function ${hookName}`);
expect(sharedHooks).not.toContain(`export function ${hookName}`);
}
});
});
23 changes: 23 additions & 0 deletions tests/unit/web/linear-wizard-generator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ const LINEAR_WIZARD_PATH = resolve(
REPO_ROOT,
'web/src/components/projects/pm-providers/linear/wizard.ts',
);
const LINEAR_HOOKS_PATH = resolve(
REPO_ROOT,
'web/src/components/projects/pm-providers/linear/hooks.ts',
);
const PM_WIZARD_HOOKS_PATH = resolve(REPO_ROOT, 'web/src/components/projects/pm-wizard-hooks.ts');

import { linearManifest } from '../../../src/integrations/pm/linear/manifest.js';
import {
Expand Down Expand Up @@ -114,3 +119,21 @@ describe('linearProviderWizard — webhook URL construction guard', () => {
).not.toContain('/webhooks/');
});
});

describe('linearProviderWizard provider-owned hooks', () => {
it('imports Linear-specific hooks from the Linear provider folder', () => {
const source = readFileSync(LINEAR_WIZARD_PATH, 'utf8');
expect(source).toContain("} from './hooks.js'");
expect(source).not.toContain("} from '../../pm-wizard-hooks.js'");
});

it('keeps Linear-specific hook definitions out of the shared wizard hook module', () => {
const linearHooks = readFileSync(LINEAR_HOOKS_PATH, 'utf8');
const sharedHooks = readFileSync(PM_WIZARD_HOOKS_PATH, 'utf8');

for (const hookName of ['useLinearDiscovery', 'useLinearLabelCreation']) {
expect(linearHooks).toContain(`export function ${hookName}`);
expect(sharedHooks).not.toContain(`export function ${hookName}`);
}
});
});
Loading
Loading