Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
692827e
feat(queue): add durable debug-analysis job-state helpers to dashboar…
aaight Jun 24, 2026
8304127
feat(db): add org_memberships table + sessions.active_org_id (spec 02…
Jun 24, 2026
7135f7f
Merge pull request #1439 from mongrel-intelligence/feat/mng-1671-org-…
zbigniewsobiecki Jun 24, 2026
98d68d9
feat(auth): membership-aware org resolution, active-org switching, pe…
Jun 24, 2026
1d9b8f4
Merge pull request #1440 from mongrel-intelligence/feat/mng-1672-mult…
zbigniewsobiecki Jun 24, 2026
58718b5
feat(users): grant membership, graceful create, member listing + CLI …
Jun 24, 2026
4f7a894
fix(users): reconcile member listing roles + backfill membership gaps…
Jun 24, 2026
db78212
fix(cli): users list renders global role, not per-org membership role
Jun 24, 2026
774b6ae
fix(users): sync home-org membership role on global-role edit (PR #14…
zbigniewsobiecki Jun 25, 2026
291f610
feat(users): add "remove from this org" for guest members (PR #1441 r…
zbigniewsobiecki Jun 25, 2026
3804d49
Merge pull request #1441 from mongrel-intelligence/feat/mng-1673-mult…
zbigniewsobiecki Jun 25, 2026
699002c
feat(web): multi-org membership UI — org switcher, add-to-org form, m…
Jun 25, 2026
d28d377
fix(review): re-resolve work item from live PR when JIRA key added af…
zbigniewsobiecki Jun 25, 2026
ecee78a
Merge pull request #1442 from mongrel-intelligence/feat/mng-1674-mult…
zbigniewsobiecki Jun 25, 2026
75e71e1
feat(config): add shared UpdateChannel type and gating helpers (#1444)
aaight Jun 25, 2026
fca7f57
feat(config): persist agent update_channel and surface agentUpdateCha…
aaight Jun 25, 2026
379877d
Merge pull request #1443 from mongrel-intelligence/fix/review-workite…
zbigniewsobiecki Jun 25, 2026
bf6100d
feat(backends): gate agent posting tools per update channel for both …
aaight Jun 25, 2026
e5f510e
feat(posting): gate system-driven PM/SCM posts by agent update channe…
aaight Jun 25, 2026
7397bb6
docs(agents): document per-agent updateChannel setting and gating bou…
aaight Jun 25, 2026
88deacb
feat(agents): set agent update channel via tRPC API and CLI (#1449)
aaight Jun 25, 2026
c86f830
feat(dashboard): add update-channel selector to agent config form (#1…
aaight Jun 25, 2026
7e57870
feat(web): add pure run-pending decision helper (#1451)
aaight Jun 25, 2026
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ All notable user-visible changes to CASCADE are documented here. The format is l

### Added

- **The per-agent `updateChannel` setting is now documented** ([MNG-1687](https://linear.app/issue/MNG-1687)). Each agent type has an optional `updateChannel` (`none` / `scm-only` / `pm-only` / `both`, default `both`) that gates *where* it posts **communication-only** status updates — independently for the **PM** (work-item comments) and **SCM** (pull-request comments / reviews) surfaces. The catalog, resolver, and posting matrix live in `src/config/updateChannel.ts`; the per-agent value is stored in the `agent_configs.update_channel` column (`NULL` / absent / unrecognized → inherit the default `both`) and read at runtime via `resolveUpdateChannel(project, agentType)`. The channel silences acks, progress updates, lifecycle status comments, agent summaries / reviews, and the agent's own PM/SCM posting tools (`PostComment`; `PostPRComment`, `UpdatePRComment`, `CreatePRReview`, `ReplyToReviewComment`) in both the native-tool and LLMist engine paths — but never PR creation, status moves, label writes, checklist sync, PR linking, friction reports, or the "eyes" reaction. Documented in `CLAUDE.md` / `AGENTS.md` (kept byte-identical), `docs/architecture/04-agent-system.md` (gated-vs-not-gated surface map), and `docs/architecture/08-config-credentials.md` (per-agent config field). Closes [MNG-1687](https://linear.app/mongrel/issue/MNG-1687).

- **`cascade-tools` now suggests the closest command when an agent typos a topic or subcommand** ([MNG-1442](https://linear.app/issue/MNG-1442)). `bin/cascade-tools.js` registers an oclif `command_not_found` hook that turns command typos into the same structured spec-014 envelope every other CLI failure emits: JSON on stdout, a one-line prose summary on stderr, and a runnable `did you mean` hint when within the shared Levenshtein budget (MNG-1440). Unknown top-level topics (`cascade-tools sm get-pr-diff`) surface a topic enumeration in `expected` and a hint that preserves the user's trailing segments (`did you mean 'cascade-tools scm get-pr-diff'?`). Known-topic / unknown-subcommand typos (`cascade-tools pm reaad-work-item`) surface the topic's subcommand enumeration and a corrected hint (`did you mean 'cascade-tools pm read-work-item'?`). Far-away typos drop `hint` but still surface `expected` so the agent has a concrete recovery path. Exit code is **`2`** for `unknown-command` — preserved from oclif's historical `command_not_found` default and distinct from every other envelope's exit code `1`; existing exit-code consumers see no change. The hook lives at `src/cli/_shared/command-not-found-hook.ts`, intentionally inside `_shared/` so oclif's command-discovery glob in `bin/cascade-tools.js` excludes it. It is wired through `pjson.oclif.hooks` so oclif loads it dynamically only when needed — no static import is added, which preserves the existing friendly `dist/cli/bootstrap.js` missing path in the entrypoint. The pure suggestion logic lives in `src/cli/_shared/commandSuggestions.ts` (MNG-1441) and is unit-tested directly without booting oclif; candidates come strictly from the loaded oclif config (`config.commandIDs` plus non-hidden `pjson.oclif.topics`), so the `cascade-tools` binary never suggests dashboard topics that its discovery glob excludes. Existing `unknown-flag` handling from `createCLICommand()` is untouched. Closes [MNG-1442](https://linear.app/mongrel/issue/MNG-1442).

### Fixed
Expand Down
15 changes: 15 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,21 @@ cascade projects credentials-set <id> --key GITHUB_TOKEN_REVIEWER --value ghp_..
- `check-suite-success` checks reviews from the **reviewer** persona specifically.
- All trigger handlers use `isCascadeBot(login)` to filter self-events.

## Agent update channel

Each agent type has an optional **`updateChannel`** that gates *where* it posts **communication-only** status updates back to humans. Two independent posting surfaces exist: **PM** (work-item comments on the Trello card / JIRA issue / Linear issue) and **SCM** (comments and reviews on the GitHub PR). The channel catalog, resolver, and posting-matrix helpers are the single source of truth in `src/config/updateChannel.ts`.

| `updateChannel` | PM posting | SCM posting |
|---|:---:|:---:|
| `none` | ❌ | ❌ |
| `pm-only` | ✅ | ❌ |
| `scm-only` | ❌ | ✅ |
| `both` (default) | ✅ | ✅ |

**Resolution.** The per-agent override is stored in the `agent_configs.update_channel` column (one row per `(projectId, agentType)`). A `NULL`, absent, or unrecognized value inherits the default **`both`** — the historical "post everywhere" behavior. The config mapper validates the stored string against the channel catalog (`UPDATE_CHANNELS`) and surfaces the per-agent map as `ProjectConfig.agentUpdateChannels`; runtime code reads it via `resolveUpdateChannel(project, agentType)` and branches on `isPmPostingEnabled(channel)` / `isScmPostingEnabled(channel)`.

**Communication-only, not workflow.** The channel only silences human-facing status chatter; it never blocks an agent from doing real work. **Gated** surfaces: system-driven **acks** (router PR / PM-focused-agent ack comments), **progress** updates (the progress monitor's PM/SCM posters), **lifecycle comments** (the `PR created` fallback plus failure / budget-exceeded / budget-warning / error comments in `PMLifecycleManager`), agent **summaries / reviews** posted back to the work item, and the agent's own **posting tools** — `filterPostingGadgetNames` drops the disabled surface's communication-only gadgets (PM: `PostComment`; SCM: `PostPRComment`, `UpdatePRComment`, `CreatePRReview`, `ReplyToReviewComment`) in **both** the native-tool (`buildExecutionPlan`) and LLMist engine paths, so a `none` / `pm-only` / `scm-only` agent literally cannot call a disabled-surface comment/review tool. **Not gated** (workflow actions always run): PR creation (`CreatePR`), status moves (`MoveWorkItem` and lifecycle `moveOnPrepare` / `moveOnSuccess`), label add/remove, checklist sync (`syncChecklist`), PR linking (`linkPR`), friction reporting (`ReportFriction`), and the "eyes" acknowledgment reaction.

## Agent triggers

Trigger format is category-prefixed: `{category}:{event}`
Expand Down
7 changes: 4 additions & 3 deletions docs/architecture/01-services.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,8 @@ Module-load phase (runs at import time, before `startDashboard()`):
### tRPC context

Every tRPC request builds a context containing:
- `user` — resolved from session cookie via `resolveUserFromSession()`
- `effectiveOrgId` — computed from user's org membership or `x-org-context` header
- `user` — resolved from session cookie via `resolveUserFromSession()` (also returns the session's `active_org_id`)
- `effectiveOrgId` — computed by `computeEffectiveOrgId()`. For a **superadmin** the `x-org-context` header selects any org (unchanged). For everyone else the session's `active_org_id` governs, validated against `org_memberships`, and falls back to the user's home org (`users.org_id`) when there is no active org or the membership is gone — so deleting an org / losing a membership never logs a user out (spec 021 plan 2).
- `token` — the session token, used by `auth.setActiveOrg` to switch the session's active org

Procedure types enforce auth levels: `publicProcedure`, `protectedProcedure`, `adminProcedure`, `superAdminProcedure`.
Procedure types enforce auth levels: `publicProcedure`, `protectedProcedure`, `adminProcedure`, `superAdminProcedure`. User-management permission checks additionally consume `resolveActorRoleInOrg()` so the caller's **per-org** membership role — not the global `users.role` — governs (an org admin who switches into an org where they are only a member cannot act as an admin there).
39 changes: 39 additions & 0 deletions docs/architecture/04-agent-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,45 @@ PM card management during agent execution:
| `linkPR` | Link the created PR to the work item |
| `syncChecklist` | Sync todo list back to PM card checklists |

## Update Channel (posting surfaces)

Each agent type carries an optional **`updateChannel`** (`none` / `scm-only` / `pm-only` / `both`, default `both`) that gates *where* the agent posts **communication-only** status updates. It distinguishes two posting surfaces — **PM** (work-item comments) and **SCM** (PR comments and reviews) — without ever touching the agent's real work. The catalog, resolver, and posting-matrix helpers live in `src/config/updateChannel.ts`; per-agent values are configured in the `agent_configs.update_channel` column and surfaced on `ProjectConfig.agentUpdateChannels` (see [`08-config-credentials.md`](./08-config-credentials.md#agent-update-channel)).

| `updateChannel` | PM posting | SCM posting |
|---|:---:|:---:|
| `none` | ❌ | ❌ |
| `pm-only` | ✅ | ❌ |
| `scm-only` | ❌ | ✅ |
| `both` (default) | ✅ | ✅ |

Runtime code resolves the channel with `resolveUpdateChannel(project, agentType)` and branches on `isPmPostingEnabled()` / `isScmPostingEnabled()`. A `NULL` / absent / unrecognized value inherits the default `both`.

### Gated posting surfaces (communication-only)

These exist purely to post human-facing status updates, so suppressing them never stops the agent from reading code, opening PRs, or moving cards:

| Surface | Where | Gating |
|---|---|---|
| Router ack comments | `src/router/adapters/{github,trello,jira,linear}.ts` | PM-focused-agent ack needs PM posting; regular PR ack needs SCM posting |
| Progress updates | `buildProgressMonitorConfig` (`src/backends/progressLifecycle.ts`) | Omits the PM (`trello`) / SCM (`github`) progress poster the channel disables |
| Lifecycle comments | `PMLifecycleManager` (`src/pm/lifecycle.ts`) | `pmPostingEnabled` suppresses the `PR created` fallback, failure, budget-exceeded/warning, and error comments — labels / moves / `linkPR` still run |
| Agent summary / review | `postAgentSummaryToPM` (`src/triggers/shared/agent-pm-summary.ts`) | Early-returns when PM posting is disabled |
| Agent posting tools | `buildExecutionPlan` (`src/backends/secretOrchestrator.ts`) + LLMist (`src/backends/llmist/index.ts`) | `filterPostingGadgetNames` removes disabled-surface gadgets — PM: `PostComment`; SCM: `PostPRComment`, `UpdatePRComment`, `CreatePRReview`, `ReplyToReviewComment` |

The tool-level gate runs in **both** engine families (native-tool and LLMist), so a disabled channel means the agent's tool list never includes the silenced surface's comment/review gadget — it cannot post even if instructed.

### Not gated (workflow actions)

The channel is communication-only; these always run regardless of channel:

- **PR creation** (`CreatePR`)
- **Status moves** (`MoveWorkItem`, plus lifecycle `moveOnPrepare` / `moveOnSuccess`)
- **Label** add/remove (processing / processed / error)
- **Checklist sync** (`syncChecklist`)
- **PR linking** (`linkPR`)
- **Friction reporting** (`ReportFriction`)
- The **"eyes"** acknowledgment reaction on PRs

## Agent Profiles

`src/agents/definitions/profiles.ts`
Expand Down
31 changes: 31 additions & 0 deletions docs/architecture/08-config-credentials.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,43 @@ interface ProjectConfig {
agentEngine?: { default: string; overrides: Record<string, string> };
engineSettings?: EngineSettings;
agentEngineSettings?: Record<string, EngineSettings>;
agentUpdateChannels?: Record<string, 'none' | 'scm-only' | 'pm-only' | 'both'>; // per-agent posting gate; default 'both'
runLinksEnabled: boolean;
maxInFlightItems?: number; // hard cap on TODO+IN_PROGRESS+IN_REVIEW; default 1
// ... PM config (trello/jira/linear), agent models, snapshot settings
}
```

### Agent update channel

`src/config/updateChannel.ts`

Each agent type has an optional **`updateChannel`** that gates *where* the agent
posts **communication-only** status updates — independently for the **PM**
(work-item comments) and **SCM** (PR comments / reviews) surfaces. It is a
per-agent override stored in the `agent_configs.update_channel` column (one row
per `(projectId, agentType)`), surfaced on `ProjectConfig.agentUpdateChannels`
by the config mapper (`buildAgentMaps`) and read at runtime via
`resolveUpdateChannel(project, agentType)`.

| `updateChannel` | PM posting | SCM posting |
|---|:---:|:---:|
| `none` | ❌ | ❌ |
| `pm-only` | ✅ | ❌ |
| `scm-only` | ❌ | ✅ |
| `both` (default) | ✅ | ✅ |

A `NULL` / absent column — or any value the config mapper does not recognize —
inherits the default **`both`** (post everywhere, the historical behavior). The
mapper validates the stored string against the channel catalog
(`UPDATE_CHANNELS`) so a stale or invalid value never breaks config loading. The
channel is **communication-only**: it suppresses acks, progress updates,
lifecycle status comments, agent summaries, and the agent's own comment/review
tools, but never PR creation, status moves, label writes, checklist sync, PR
linking, friction reports, or the "eyes" reaction. See
[`04-agent-system.md`](./04-agent-system.md#update-channel-posting-surfaces) for
the full gated-vs-not-gated surface map.

### PM workflow slots

PM provider config maps CASCADE lifecycle concepts onto provider-native lists or statuses. The friction-reporting slot is optional but recognized consistently across providers:
Expand Down
15 changes: 13 additions & 2 deletions docs/architecture/09-database.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ CASCADE uses PostgreSQL with [Drizzle ORM](https://orm.drizzle.team/) for type-s
erDiagram
organizations ||--o{ projects : "has"
organizations ||--o{ users : "has"
organizations ||--o{ org_memberships : "has"
organizations ||--o{ prompt_partials : "has"

projects ||--o{ project_integrations : "has"
Expand All @@ -25,13 +26,21 @@ erDiagram
agent_runs ||--o| debug_analyses : "analyzed by"

users ||--o{ sessions : "has"
users ||--o{ org_memberships : "has"

organizations {
text id PK
text name
jsonb settings
}

org_memberships {
uuid id PK
uuid user_id FK
text org_id FK
text role
}

projects {
text id PK
text org_id FK
Expand Down Expand Up @@ -73,6 +82,7 @@ erDiagram
integer max_concurrency
text system_prompt
text task_prompt
text update_channel
}

agent_trigger_configs {
Expand Down Expand Up @@ -137,8 +147,9 @@ erDiagram
| `prompt_partials` | Org-scoped prompt template customizations | UNIQUE(`org_id`, `name`) |
| `pr_work_items` | Maps PRs and external alert sources to PM work items for run-link display and alert idempotency | Partial unique indexes on `(project_id, pr_number)`, `(project_id, work_item_id)`, and `(project_id, external_source, external_id)` when those values are present |
| `webhook_logs` | Raw webhook payloads for debugging | — |
| `users` | Dashboard users (email, bcrypt hash, role) | Org-scoped |
| `sessions` | Session tokens for cookie auth (30-day expiry) | — |
| `users` | Dashboard users (email, bcrypt hash, role) | Org-scoped (`org_id` = home org, `role` = global role) |
| `org_memberships` | Multi-org membership: links a user to an org with a per-org role, so one account can belong to many orgs. `users.org_id`/`users.role` remain the home org + global role. Read by effective-org resolution + the per-org actor-role helper (spec 021 plan 2); written by the grant mutation (`users.addExistingUserToOrg`) and the membership-mirroring create, and read by membership-based member listing (spec 021 plan 3). The listing returns BOTH the per-org `role` and the global `users.role` so the Settings → Users editor keeps targeting the global role until the plan-4 UI reconciliation. The idempotent home-org backfill runs in migration `0053` and is re-run by `0054` so accounts created via the old `createUser` (and bootstrap superadmins) never vanish from the inner-join listing. | UNIQUE(`user_id`, `org_id`) |
| `sessions` | Session tokens for cookie auth (30-day expiry); `active_org_id` (nullable) tracks the org the session is currently acting in for multi-org | — |
| `debug_analyses` | AI debug analysis results | — |

## Repositories
Expand Down
17 changes: 15 additions & 2 deletions src/api/auth/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,21 @@ import {
getUserById,
} from '../../db/repositories/usersRepository.js';

export async function resolveUserFromSession(token: string): Promise<DashboardUser | null> {
/** The user behind a session token plus the session's active-org pointer. */
export interface ResolvedSession {
user: DashboardUser;
/**
* The org this session is currently acting in (spec 021 plan 2). `null` when
* the session predates a switch or the org was deleted — effective-org
* resolution falls back to the user's home org so nobody is logged out.
*/
activeOrgId: string | null;
}

export async function resolveUserFromSession(token: string): Promise<ResolvedSession | null> {
const session = await getSessionByToken(token);
if (!session) return null;
return getUserById(session.userId);
const user = await getUserById(session.userId);
if (!user) return null;
return { user, activeOrgId: session.activeOrgId ?? null };
}
68 changes: 65 additions & 3 deletions src/api/context.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,76 @@
import { getOrgMembership } from '../db/repositories/orgMembershipsRepository.js';
import { getOrganization } from '../db/repositories/settingsRepository.js';
import type { TRPCUser } from './trpc.js';

/** Role used for permission evaluation. Same union as `TRPCUser['role']`. */
export type ActorRole = TRPCUser['role'];

/**
* Resolve the org a request acts in (spec 021 plan 2).
*
* Two distinct paths preserve the existing superadmin contract while moving
* regular users onto the membership model:
*
* - **Superadmin** (unchanged — spec AC #7): the `x-org-context` header selects
* any existing org. `active_org_id` and membership do not apply to the global
* superadmin role.
* - **Everyone else**: the session's `active_org_id` governs, validated against
* the user's membership. It falls back to the home org (`users.org_id`) when
* there is no active org or the membership no longer exists — the no-logout
* guarantee (deleting an org / losing a membership never logs a user out).
*/
export async function computeEffectiveOrgId(
user: TRPCUser | null,
requestedOrgId: string | undefined,
activeOrgId?: string | null,
): Promise<string | null> {
if (!user) return null;
if (requestedOrgId && requestedOrgId !== user.orgId && user.role === 'superadmin') {
const org = await getOrganization(requestedOrgId);
return org ? requestedOrgId : user.orgId;

if (user.role === 'superadmin') {
if (requestedOrgId && requestedOrgId !== user.orgId) {
const org = await getOrganization(requestedOrgId);
return org ? requestedOrgId : user.orgId;
}
return user.orgId;
}

// Non-superadmin: the active org only takes effect when it differs from the
// home org and the user still has a membership there. The home org is always
// valid, so it needs no lookup and is the universal fallback.
if (activeOrgId && activeOrgId !== user.orgId) {
const membership = await getOrgMembership(user.id, activeOrgId);
if (membership) return activeOrgId;
}
return user.orgId;
}

/**
* Resolve a user's effective role *in a specific org* (spec 021 plan 2).
*
* Per-org membership — not the global `users.role` — governs permissions
* (spec AC #4), so an org admin who switches to an org where they are only a
* member cannot act as an admin there (spec AC #8).
*
* - **Superadmin** is a global role (spec AC #7): always `'superadmin'`,
* regardless of membership.
* - Otherwise the per-org membership role wins.
* - With no membership row, acting in the **home org** falls back to the global
* role (no-logout guard / pre-backfill safety); any other org gets least
* privilege (`'member'`).
*/
export async function resolveActorRoleInOrg(params: {
userId: string;
globalRole: ActorRole;
homeOrgId: string;
orgId: string;
}): Promise<ActorRole> {
if (params.globalRole === 'superadmin') return 'superadmin';

const membership = await getOrgMembership(params.userId, params.orgId);
if (membership) {
return membership.role === 'admin' ? 'admin' : 'member';
}

if (params.orgId === params.homeOrgId) return params.globalRole;
return 'member';
}
Loading
Loading