diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 200d04e1d..cc828d96c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -351,9 +351,10 @@ jobs: # Runs shep security enforce to validate dependency risk, release integrity, # and governance posture. Gates release and dev-release jobs. # - # Master kill switch: set the repository variable or workflow env - # SHEP_SUPPLY_CHAIN_SECURITY=false to make this job a no-op. - # The CLI honors the env var and exits 0 with a "flag disabled" note. + # Supply-chain security is part of ASPM, which is off on a fresh install, + # so SHEP_SUPPLY_CHAIN_SECURITY=true opts this job in explicitly. Set the + # repository variable to false to make it a no-op: the CLI then exits 0 + # with a "flag disabled" note. # =========================================================================== security-enforce: name: Security Enforce diff --git a/.github/workflows/contributor-maintenance.yml b/.github/workflows/contributor-maintenance.yml new file mode 100644 index 000000000..18821a9bd --- /dev/null +++ b/.github/workflows/contributor-maintenance.yml @@ -0,0 +1,106 @@ +# ============================================================================= +# Contributor maintenance (scheduled) +# ============================================================================= +# Time-driven half of the contributor pipeline (spec 097, FR-31/FR-42). +# These used to run as watchers inside every user's `shep` daemon; they are +# maintainer automation for shep-ai/shep, so they run here instead (spec 135). +# +# stale-issues daily `shep contributors stale-issues` — good-first-issues +# with no activity for 30+ days, listed in the run's +# step summary +# recap monthly `shep contributors recap` — the previous month's +# recap, written to recaps/YYYY-MM.md and attached to +# the run as an artifact +# +# Both jobs can also be dispatched by hand from the Actions tab. + +name: Contributor maintenance + +on: + schedule: + # Daily stale-issue sweep at 06:17 UTC. + - cron: '17 6 * * *' + # Monthly recap at 07:23 UTC on the 1st. + - cron: '23 7 1 * *' + workflow_dispatch: + inputs: + task: + description: 'Which task to run' + type: choice + options: [stale-issues, recap] + default: stale-issues + +permissions: + contents: read + issues: read + +concurrency: + group: contributor-maintenance-${{ github.event.schedule || inputs.task }} + cancel-in-progress: false + +jobs: + stale-issues: + name: Stale good-first-issues + if: github.event.schedule == '17 6 * * *' || inputs.task == 'stale-issues' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: List stale good-first-issues + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -o pipefail + pnpm --silent dev:cli contributors stale-issues --repo "$GITHUB_REPOSITORY" | tee stale.txt + { echo '## Stale good-first-issues'; echo; cat stale.txt; } >> "$GITHUB_STEP_SUMMARY" + + recap: + name: Monthly contributor recap + if: github.event.schedule == '23 7 1 * *' || inputs.task == 'recap' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Generate and publish the recap + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: pnpm --silent dev:cli contributors recap + + - name: Add the recap to the step summary + run: cat "recaps/$(date -u -d "$(date -u +%Y-%m-01) -1 month" +%Y-%m).md" >> "$GITHUB_STEP_SUMMARY" + + - name: Upload the recap + uses: actions/upload-artifact@v4 + with: + name: contributor-recap + path: recaps/ + if-no-files-found: warn diff --git a/.storybook/mocks/app/actions/set-feature-flag.ts b/.storybook/mocks/app/actions/set-feature-flag.ts new file mode 100644 index 000000000..a7934cffd --- /dev/null +++ b/.storybook/mocks/app/actions/set-feature-flag.ts @@ -0,0 +1,6 @@ +export async function setFeatureFlag( + _key: string, + _enabled: boolean +): Promise<{ ok: boolean; error?: string }> { + return { ok: true }; +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 83b6887c1..d893309a6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -128,6 +128,10 @@ When you **open** your first PR — not when it merges — [`.github/workflows/w Recognition itself is not automated yet — see the note under [Contributor Ladder](#contributor-ladder). Monthly recaps go to `recaps/YYYY-MM.md`, GitHub Discussions, and Discord via the publishers in `packages/core/src/infrastructure/services/recap/`. +Cadence-driven maintenance runs in [`.github/workflows/contributor-maintenance.yml`](./.github/workflows/contributor-maintenance.yml), never on users' machines: `pnpm dev:cli contributors stale-issues` lists good-first-issues with no activity for 30 days (daily), and `pnpm dev:cli contributors recap` writes the previous month's recap (monthly). Both read the same use cases you can run locally. + +With the web UI running (`pnpm dev:web`) and the Collaboration flag on, `/contributors` shows the lane chooser, the contributor leaderboard and the doctor summary. It is not in the sidebar — it is tooling for working on Shep, not for using it. + --- ## Quick Contributions (no spec workflow needed) diff --git a/LESSONS.md b/LESSONS.md index ba15e3b33..94dfc5bff 100644 --- a/LESSONS.md +++ b/LESSONS.md @@ -161,6 +161,10 @@ Rules: process and then deletes files is a Windows-only failure waiting for main. 4. **One retry budget, one home.** Six suites had each inlined their own `{ maxRetries, retryDelay }`; that is `@tests/helpers/remove-dir.helper.ts` now. +5. **The shell's exit is not the ConPTY host's exit.** On Windows node-pty's `conhost.exe` + inherits the shell's cwd and can outlive the exit event (CI killed it as an orphan at job + end), so even exit-wait + retries can still hit `EBUSY`. A test whose temp dir served only as + the shell's cwd may leave it to the OS on that one Windows error; every other error fails. ## A one-shot download inside a build step is an unguarded failure @@ -797,9 +801,9 @@ Feature flags are persisted in the Settings singleton and toggled via the Settin - UPDATE SET clause 6. `packages/core/src/domain/factories/settings-defaults.factory.ts` — add `: false` to the `FeatureFlags` defaults object 7. `src/presentation/web/lib/feature-flags.ts` — add field to `FeatureFlagsState` interface, to the DB-primary branch, and to the env-var fallback branch (+ optional deprecated accessor) -8. `src/presentation/web/components/features/settings/settings-page-client.tsx` — add `` inside the Feature Flags `SettingsSection` and add the key to the fallback object at the top (`const featureFlags = settings.featureFlags ?? { ... }`). -9. Translation strings in EVERY locale — `translations//web.json` → `settings.featureFlags.` and `settings.featureFlags.Description`. Missing keys render as the raw key path on-screen. Locales: `en, ar, es, de, fr, he, pt, uk, ru`. -10. Gate the UI on `featureFlags.` wherever the feature is exposed (sidebar, routes, search, FAB actions). **If the feature ships any pages under `src/presentation/web/app//`, you MUST also ADD a `SidebarNavItem` in `app-sidebar.tsx` gated on the flag — "gate the existing sidebar entry" silently passes when there is no entry to gate. See the "New Feature Pages Must Be Reachable" lesson below.** +8. `packages/core/src/domain/shared/feature-flag-catalog.ts` — add the flag to `FEATURE_FLAG_CATALOG` with its group and a one-line description (a missing entry is a compile error). The Settings page's Feature Flags section, `/settings/feature-flags` and `shep settings flags` all render from it (spec 135) — do not hand-write a ``. +9. Translation strings in EVERY locale — `translations//web.json` → `settings.featureFlags.` and `settings.featureFlags.Description`, named exactly after the flag key (the list builds the key from it). Missing keys render as the raw key path on-screen. Locales: `en, ar, es, de, fr, he, pt, uk, ru`. +10. Gate the UI on `featureFlags.` wherever the feature is exposed (sidebar `flag` in `sidebar-links.ts`, `requireFeaturePage()` in pages, `requireFeatureFlag`/404 in API routes, `gateByFeatureFlag()` for CLI groups, search, FAB actions). **If the feature ships any pages under `src/presentation/web/app//`, you MUST also ADD a `SidebarNavItem` in `app-sidebar.tsx` gated on the flag — "gate the existing sidebar entry" silently passes when there is no entry to gate. See the "New Feature Pages Must Be Reachable" lesson below.** 11. Update hardcoded `FeatureFlags` / `FeatureFlagsState` fixtures across stories, tests, and hooks. `tsc --noEmit` will surface every one — run `pnpm typecheck` BEFORE committing so the pre-commit hook doesn't bounce. Known fixture locations (grow this list when a new one shows up): - `src/presentation/web/hooks/feature-flags-context.tsx` - `src/presentation/web/components/features/settings/settings-page-client.tsx` (fallback object) @@ -3056,6 +3060,29 @@ needs at least one test against the migrated SQLite schema; and a new process en (an MCP server, a worker) gets one real spawn-and-call smoke before it is called done — that run also caught the entry skipping `initializeSettings()`. +## Agent worktrees under `.claude/worktrees/` are not gitignored + +A subagent started with worktree isolation checks out under `.claude/worktrees//` inside the +main clone, and `.gitignore` does not cover that path, so `git add -A` or `git status`-driven lint +and prettier runs pick up the whole second checkout. Add `.claude/worktrees/` to +`.git/info/exclude` before starting one, and stage with `git add -u` plus explicit new paths. + +## Never isolate git config with `NUL` on Windows + +`GIT_CONFIG_GLOBAL=NUL` worked until the Windows runner moved to git 2.56, which fails every +command with `fatal: unable to access 'NUL': Invalid argument`. Point `GIT_CONFIG_GLOBAL` and +`GIT_CONFIG_SYSTEM` at an empty temp file (as `tests/helpers/harness/temp-git-repo.ts` does) — it +reads the same on every platform and git version, so never branch on `process.platform` for it. + +## Wait for a specific server action in e2e, never "the first `next-action` POST" + +The i18n spec waited for any server-action response, then called `response.finished()` to stop the +page closing mid-save. Pages fire several actions on load (model catalog, routing plan), so the wait +could match the wrong one, and a server action's response streams the re-rendered page, so +`finished()` can hang past the test timeout even after the action has persisted. Match the action +by its request body (`request.postData()` contains the field it saves), treat its response headers +as "persisted", and verify through a reload rather than `finished()`. + ## Fields that identify a person are opt-in, even when usage metrics are opt-out Spec 133 first shipped "Include my identity" on by default (account hash, GitHub username and diff --git a/ROADMAP.md b/ROADMAP.md index 264ad1457..a188796c4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,7 +11,7 @@ Want to influence the roadmap? Open a [feature request](./.github/ISSUE_TEMPLATE Specs being implemented or about to merge. - [111 — Fleet control plane](./specs/111-fleet-control-plane/) — fleet status bar and triage drawer for running many agents at once. **11 of 19 tasks done**; the first slice merged in #868. -- [098 — ASPM platform](./specs/098-aspm-platform/) — application security posture management: ownership import, scanners, findings surface. **82 of 84 tasks done**, merged behind a feature flag in #628, with the SSE scan stream deferred. +- [098 — ASPM platform](./specs/098-aspm-platform/) — application security posture management: ownership import, scanners, findings surface. **82 of 84 tasks done**, merged in #628 behind the `aspm` feature flag, which is **off by default** (turn it on in Settings → Feature Flags), with the SSE scan stream deferred. ## Next — designed, queued diff --git a/SECURITY.md b/SECURITY.md index a8341c5e4..6f39da251 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -49,7 +49,7 @@ Three jobs in [`.github/workflows/ci.yml`](./.github/workflows/ci.yml) run on ev | --- | ------------ | | **Gitleaks** (`security-gitleaks`) | Installs the gitleaks CLI (pinned to 8.30.1) and runs `gitleaks detect --source . --verbose --redact --config .gitleaks.toml --gitleaks-ignore-path .gitleaksignore` over the **full history** (`fetch-depth: 0`). Add a rule to [`.gitleaks.toml`](./.gitleaks.toml) or a fingerprint to [`.gitleaksignore`](./.gitleaksignore) for a verified false positive — never by deleting the finding. | | **Semgrep** (`security-semgrep`) | SAST via `returntocorp/semgrep-action@v1` with the `p/typescript`, `p/javascript` and `p/security-audit` rule packs. Has `security-events: write` so results can surface in the Security tab. | -| **Security Enforce** (`security-enforce`) | Shep scanning itself: `pnpm dev:cli security enforce --output json`, which validates dependency risk, release integrity and governance posture. Gated by `SHEP_SUPPLY_CHAIN_SECURITY` (repository variable, default `true`); setting it to `false` makes the CLI exit 0 with a "flag disabled" note. | +| **Security Enforce** (`security-enforce`) | Shep scanning itself: `pnpm dev:cli security enforce --output json`, which validates dependency risk, release integrity and governance posture. Gated by `SHEP_SUPPLY_CHAIN_SECURITY` (repository variable, default `true`, which opts in even though the `aspm` flag that owns supply-chain security is off on a fresh install); setting it to `false` makes the CLI exit 0 with a "flag disabled" note. | A fourth job, **Security Summary** (`security-summary`), posts an aggregated comment on the PR — but only when Gitleaks or Semgrep actually failed. diff --git a/apis/json-schema/AgentType.yaml b/apis/json-schema/AgentType.yaml index 1e88917ba..f55ff2c65 100644 --- a/apis/json-schema/AgentType.yaml +++ b/apis/json-schema/AgentType.yaml @@ -7,8 +7,6 @@ enum: - codex-cli - copilot-cli - gemini-cli - - aider - - continue - cursor - cline - openrouter diff --git a/apis/json-schema/CloudDeploymentProvider.yaml b/apis/json-schema/CloudDeploymentProvider.yaml index 578871381..8bc676e5c 100644 --- a/apis/json-schema/CloudDeploymentProvider.yaml +++ b/apis/json-schema/CloudDeploymentProvider.yaml @@ -3,8 +3,4 @@ $id: CloudDeploymentProvider.yaml type: string enum: - CloudflarePages - - Vercel - - Netlify - - AwsAmplify - - GcpCloudRun description: Supported cloud deployment providers for generated applications diff --git a/apis/json-schema/FeatureFlagGroup.yaml b/apis/json-schema/FeatureFlagGroup.yaml new file mode 100644 index 000000000..0539437e3 --- /dev/null +++ b/apis/json-schema/FeatureFlagGroup.yaml @@ -0,0 +1,8 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: FeatureFlagGroup.yaml +type: string +enum: + - platform + - software-factory + - experimental +description: Section a feature flag is listed under diff --git a/apis/json-schema/FeatureFlags.yaml b/apis/json-schema/FeatureFlags.yaml index 9e1cb5cad..377464e05 100644 --- a/apis/json-schema/FeatureFlags.yaml +++ b/apis/json-schema/FeatureFlags.yaml @@ -36,16 +36,12 @@ properties: description: Enable WhatsApp-native task dispatch and interactive control (spec 101) aspm: type: boolean - default: true - description: Enable the Application Security Posture Management (ASPM) module — /aspm web routes, `shep aspm` CLI command tree, and the posture SSE stream (spec 098) + default: false + description: "Enable the Application Security Posture Management (ASPM) module — /aspm web routes, `shep aspm` CLI command tree, and the posture SSE stream (spec 098). Off by default: ASPM is a separate product category users opt into (spec 135)." clusters: type: boolean default: false description: Enable Clusters navigation and Kubernetes cluster management in the web UI - supplyChainSecurity: - type: boolean - default: true - description: Enable the supply chain security feature (policy engine, badges, settings, CLI, CI gate). When false, the feature is inert regardless of SecurityMode. scheduledWorkflows: type: boolean default: false @@ -58,6 +54,54 @@ properties: type: boolean default: false description: "Enable the experimental query-aware agent harness: the Shep Harness agent, /harness pages, the feature Context tab and `shep harness` commands (spec 119)" + spaces: + type: boolean + default: true + description: "Software factory: spaces and product lines — /spaces and `shep space` (spec 120)" + trackers: + type: boolean + default: true + description: "Software factory: Linear and Jira tracker sync — /connections, `shep connection`, `shep sync` and the daemon sync loop (spec 122)" + knowledge: + type: boolean + default: true + description: "Software factory: Notion knowledge sources — `shep knowledge` and the daemon knowledge sync (spec 125)" + signals: + type: boolean + default: true + description: "Software factory: customer and incident signals — `shep signal` (spec 126)" + opportunities: + type: boolean + default: true + description: "Software factory: ranked opportunities — /opportunities and `shep opportunity` (spec 126)" + feedback: + type: boolean + default: true + description: "Software factory: feedback intake and themes — POST /api/feedback and `shep feedback` (spec 127)" + discovery: + type: boolean + default: true + description: "Software factory: agent discovery of opportunities — `shep discovery` and its daemon schedule (spec 128)" + incidents: + type: boolean + default: true + description: "Software factory: incident triage — /incidents, POST /api/alerts and `shep incident` (spec 129)" + outcomes: + type: boolean + default: true + description: "Software factory: shipped-work outcomes — `shep outcome` and the daemon outcome tracker (spec 130)" + docsFirst: + type: boolean + default: true + description: "Software factory: docs-first spaces — the docs-first space policy, planning instructions and merge gate (spec 131)" + autopilot: + type: boolean + default: true + description: "Software factory: autopilot — `shep autopilot` and the daemon autopilot pass (spec 132)" + factory: + type: boolean + default: true + description: "Software factory: factory status — /factory and `shep factory` (spec 132)" required: - envDeploy - debug @@ -69,8 +113,19 @@ required: - whatsappDispatch - aspm - clusters - - supplyChainSecurity - scheduledWorkflows - githubImport - queryAwareHarness + - spaces + - trackers + - knowledge + - signals + - opportunities + - feedback + - discovery + - incidents + - outcomes + - docsFirst + - autopilot + - factory description: Feature flag toggles for runtime feature control diff --git a/apis/json-schema/SystemConfig.yaml b/apis/json-schema/SystemConfig.yaml index 5ed363ebb..7c3b3acfe 100644 --- a/apis/json-schema/SystemConfig.yaml +++ b/apis/json-schema/SystemConfig.yaml @@ -2,15 +2,10 @@ $schema: https://json-schema.org/draft/2020-12/schema $id: SystemConfig.yaml type: object properties: - autoUpdate: - type: boolean - default: true - description: CLI auto-update preference logLevel: type: string default: info description: Log level for CLI output required: - - autoUpdate - logLevel description: System configuration diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 33b037cc5..86034f4b0 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -357,23 +357,39 @@ Three channels with granular event control: #### Feature Flags -Toggle experimental features. Several of them gate whole command groups: - -| Flag | Gates | -| --------------------- | ---------------------------------------------- | -| `envDeploy` | Environment deployment workflows | -| `debug` | Verbose logging and debug panels | -| `reactFileManager` | The in-app file manager | -| `projects` | `shep project` / `item` / `cycle` / `intake` | -| `codeReview` | `shep review` | -| `collaboration` | Supervisor agent, agent questions and messages | -| `bedrockIntegration` | `shep bedrock` | -| `whatsappDispatch` | `shep whatsapp` | -| `aspm` | `shep aspm` | -| `clusters` | `shep cluster` | -| `supplyChainSecurity` | `shep security enforce` | -| `scheduledWorkflows` | `shep workflow` | -| `githubImport` | GitHub repository import | +Every flag is listed, with a one-line description, its default and a switch, at +**Settings → Feature Flags → Open the feature flags view** (`/settings/feature-flags`), and from +the CLI with `shep settings flags` (`shep settings flags enable|disable `). A flag that is +off hides its pages and navigation, 404s its URLs, and hides its CLI group (running it prints how +to turn the flag on). + +| Flag | Default | Gates | +| -------------------- | ------- | ---------------------------------------------------------------- | +| `envDeploy` | on | Environment deployment workflows | +| `projects` | on | `shep project` / `item` / `cycle` / `intake` | +| `codeReview` | on | `shep review` | +| `collaboration` | on | Supervisor agent, agent questions and messages | +| `githubImport` | on | GitHub repository import | +| `bedrockIntegration` | on | `shep bedrock` | +| `aspm` | off | `shep aspm`, `/aspm`, supply-chain enforcement (`shep security`) | +| `spaces` | on | `/spaces`, `shep space` | +| `trackers` | on | `/connections`, `shep connection`, `shep sync`, tracker sync | +| `knowledge` | on | `shep knowledge`, Notion sync | +| `signals` | on | `shep signal`, the signal inbox | +| `opportunities` | on | `/opportunities`, `shep opportunity` | +| `feedback` | on | `POST /api/feedback`, `shep feedback`, feedback themes and keys | +| `discovery` | on | `shep discovery`, scheduled discovery | +| `incidents` | on | `/incidents`, `POST /api/alerts`, `shep incident` | +| `outcomes` | on | `shep outcome`, outcome tracking | +| `docsFirst` | on | Docs-first space policy, planning instructions and merge gate | +| `autopilot` | on | `shep autopilot`, autopilot passes | +| `factory` | on | `/factory`, `shep factory` | +| `clusters` | off | `shep cluster` | +| `scheduledWorkflows` | off | `shep workflow` | +| `queryAwareHarness` | off | `shep harness`, `/harness` | +| `whatsappDispatch` | off | `shep whatsapp` | +| `reactFileManager` | off | The in-app file manager | +| `debug` | off | Verbose logging and debug panels | #### Database diff --git a/docs/api/domain-models.md b/docs/api/domain-models.md index a07f11b74..ba34e61fe 100644 --- a/docs/api/domain-models.md +++ b/docs/api/domain-models.md @@ -272,7 +272,6 @@ export type EnvironmentConfig = { ```typescript export type SystemConfig = { - autoUpdate: boolean; logLevel: string; }; ``` @@ -393,15 +392,28 @@ export type FeatureFlags = { whatsappDispatch: boolean; aspm: boolean; clusters: boolean; - supplyChainSecurity: boolean; scheduledWorkflows: boolean; githubImport: boolean; + queryAwareHarness: boolean; + // Software-factory areas (spec 135), all on by default + spaces: boolean; + trackers: boolean; + knowledge: boolean; + signals: boolean; + opportunities: boolean; + feedback: boolean; + discovery: boolean; + incidents: boolean; + outcomes: boolean; + docsFirst: boolean; + autopilot: boolean; + factory: boolean; }; ``` Several command groups are gated on these flags — for example `shep aspm` on `aspm`, `shep supervisor` on `collaboration`, and `shep security enforce` on -`supplyChainSecurity`. +`aspm` too: supply-chain security is part of ASPM. ### GanttViewData @@ -535,8 +547,6 @@ enum AgentType { CodexCli = 'codex-cli', CopilotCli = 'copilot-cli', GeminiCli = 'gemini-cli', - Aider = 'aider', // Coming Soon — not executable - Continue = 'continue', // Coming Soon — not executable Cursor = 'cursor', Cline = 'cline', OpenRouter = 'openrouter', @@ -547,10 +557,9 @@ enum AgentType { } ``` -Twelve of the fourteen members are supported today. `aider` and `continue` are -declared for future extensibility only: they have `supported: false` in the -agent catalog, no binary and no executor, and are surfaced in pickers as -"Coming Soon". Per-agent facts (label, kind, binary, tool id, supported flag, +Every member is supported. (The `aider` and `continue` placeholders were removed in +spec 135; a settings row still holding either reads back as the default agent.) +Per-agent facts (label, kind, binary, tool id, supported flag, model list) live in one place — `packages/core/src/domain/shared/agent-catalog.ts`, a total `Record`, so adding a member here is a compile error diff --git a/docs/architecture/agent-system.md b/docs/architecture/agent-system.md index e96f4f142..8113ff869 100644 --- a/docs/architecture/agent-system.md +++ b/docs/architecture/agent-system.md @@ -338,9 +338,9 @@ factory picks one from `settings.agent.type`; nothing else may. | `llmproxy` | `llmproxy-executor.service.ts` | sdk | | `dev` | `dev-executor.service.ts` | mock | -**`aider` and `continue` have no executor.** They are `supported: false` in the -agent catalog, with `binary: null` and `toolId: null`, and must never reach the -executor factory. There is no `aider-executor.service.ts`. +Every `AgentType` has an executor. A catalog entry marked `supported: false` +would be shown as "Coming Soon" and must never reach the executor factory; none +exist today (the `aider` and `continue` placeholders were removed in spec 135). Supporting files in the same directory: diff --git a/docs/architecture/settings-service.md b/docs/architecture/settings-service.md index 9beb5598b..edb8de5d6 100644 --- a/docs/architecture/settings-service.md +++ b/docs/architecture/settings-service.md @@ -298,7 +298,7 @@ export interface SettingsRow { created_at: string; // ISO 8601 string (SQLite TEXT) updated_at: string; model_default: string; // Flattened from models.default - sys_auto_update: number; // Boolean -> Integer (SQLite limitation) + sys_log_level: string; agent_type: string; // ... many more flattened columns } @@ -308,7 +308,7 @@ export function toDatabase(settings: Settings): SettingsRow { id: settings.id, created_at: settings.createdAt.toISOString(), model_default: settings.models.default, - sys_auto_update: settings.system.autoUpdate ? 1 : 0, + sys_log_level: settings.system.logLevel, agent_type: settings.agent.type, // ... }; @@ -322,7 +322,7 @@ export function fromDatabase(row: SettingsRow): Settings { default: row.model_default, }, system: { - autoUpdate: row.sys_auto_update === 1, + logLevel: row.sys_log_level, // ... }, agent: { diff --git a/docs/cli/commands.md b/docs/cli/commands.md index 23891570d..d48d71213 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -543,6 +543,18 @@ Override how worktrees are created. **Source**: `src/presentation/cli/commands/settings/worktree.command.ts` +### `shep settings flags` + +List every feature flag with its state, default and a one-line description, grouped into +Platform, Software factory and Experimental. `shep settings flags enable ` and +`shep settings flags disable ` turn one on or off; an unknown flag exits 1. The web UI's +`/settings/feature-flags` view shows the same list. + +A command group whose flag is off (for example `shep space` with `spaces` off, or `shep aspm` +with `aspm` off) is hidden from `--help`, and running it prints the `enable` command to use. + +**Source**: `src/presentation/cli/commands/settings/flags.command.ts` + | Option | Description | | ---------------------------- | -------------------------------------------------- | | `--create-command ` | Command that replaces `git worktree add` | @@ -1106,16 +1118,17 @@ feature flag. ### `shep security enforce` -Evaluate repository security posture and enforce policy. Gated on the -`supplyChainSecurity` feature flag; when the flag is off the command is a no-op -that exits 0. +Evaluate repository security posture and enforce policy. Supply-chain security +is part of ASPM, so the command is gated on the `aspm` feature flag; when the +flag is off the command is a no-op that exits 0. | Option | Description | Default | | ----------------------- | ------------------------------------------ | ------- | | `-r, --repo ` | Repository to evaluate | cwd | | `-o, --output ` | Output format (`table` or `json`) | `table` | -`SHEP_SUPPLY_CHAIN_SECURITY=false` is the CI kill-switch. +`SHEP_SUPPLY_CHAIN_SECURITY` overrides the flag for CI: `false` (or `0`) turns +enforcement off, `true` (or `1`) turns it on even while `aspm` is off. **Source**: `src/presentation/cli/commands/security.command.ts` diff --git a/docs/development/adding-agent-types.md b/docs/development/adding-agent-types.md index b9f8b5491..7b27ac395 100644 --- a/docs/development/adding-agent-types.md +++ b/docs/development/adding-agent-types.md @@ -508,8 +508,8 @@ So for Kimi (`i18nKey: 'kimiCode'`), each locale's `tui.json` gains: } ``` -Unsupported ("Coming Soon") agents carry `name` + `disabled` instead of `name` + `description` — -see the `aider` entry. +An unsupported ("Coming Soon") agent would carry `name` + `disabled` instead of `name` + +`description`; no current agent does. **The picker itself is generated from the catalog.** You add translations, not picker entries: diff --git a/packages/core/src/application/ports/output/services/cloud-deployment-provider-registry.interface.ts b/packages/core/src/application/ports/output/services/cloud-deployment-provider-registry.interface.ts index 9bf525550..838aafdba 100644 --- a/packages/core/src/application/ports/output/services/cloud-deployment-provider-registry.interface.ts +++ b/packages/core/src/application/ports/output/services/cloud-deployment-provider-registry.interface.ts @@ -13,21 +13,18 @@ import type { ICloudDeploymentProvider } from './cloud-deployment-provider.inter export interface CloudDeploymentProviderDescriptor { id: CloudDeploymentProvider; displayName: string; - enabled: boolean; } export interface ICloudDeploymentProviderRegistry { /** - * Return a descriptor for every provider known to the system — both live - * and disabled stubs. Used by the UI dropdown + ListCloudProvidersUseCase. + * Return a descriptor for every provider that has a registered adapter. + * Used by the UI provider list + ListCloudProvidersUseCase. */ listAll(): CloudDeploymentProviderDescriptor[]; /** * Return the concrete provider instance for the given id. - * Throws if the id is unknown. Disabled providers may be returned by this - * method; callers that want to enforce enabled-only should check the - * `enabled` flag before calling deploy()/validateToken(). + * Throws if no adapter is registered for the id. */ get(id: CloudDeploymentProvider): ICloudDeploymentProvider; } diff --git a/packages/core/src/application/ports/output/services/cloud-deployment-provider.interface.ts b/packages/core/src/application/ports/output/services/cloud-deployment-provider.interface.ts index 2ce793543..2417731b3 100644 --- a/packages/core/src/application/ports/output/services/cloud-deployment-provider.interface.ts +++ b/packages/core/src/application/ports/output/services/cloud-deployment-provider.interface.ts @@ -1,9 +1,8 @@ /** * Cloud Deployment Provider (port) * - * One implementation per cloud (CloudflarePages, Vercel, ...). Only a subset - * is live in v1 — stubs expose `enabled = false` and throw on deploy() so the - * UI and registry can list them uniformly. + * One implementation per CloudDeploymentProvider member. Cloudflare Pages + * is the only one today. * * Spec 089 — one-click-cloud-deploy. */ @@ -59,18 +58,15 @@ export interface ICloudDeploymentProvider { readonly providerId: CloudDeploymentProvider; /** Human-friendly label shown in the UI dropdown. */ readonly displayName: string; - /** Whether this provider is live in v1 (false = "Coming soon"). */ - readonly enabled: boolean; /** * Returns true if a token is stored and passes a cheap remote validation call. - * Stubs always return false. */ isConnected(): Promise; /** * Validate a raw token (pre-persistence). Called by ConnectCloudProviderUseCase. - * Throws if invalid. Stubs throw ProviderNotImplementedError. + * Throws if invalid. */ validateToken(token: string): Promise; @@ -79,8 +75,7 @@ export interface ICloudDeploymentProvider { * Invokes onProgress for each status transition. Optionally invokes * onLog for every meaningful internal step (HTTP call, subprocess, error) * — the orchestrating use case captures these and persists them as - * OperationLogEntry rows. Stubs throw ProviderNotImplementedError - * immediately. + * OperationLogEntry rows. */ deploy( input: CloudDeployInput, diff --git a/packages/core/src/application/use-cases/cloud-deploy/connect-cloud-provider.use-case.ts b/packages/core/src/application/use-cases/cloud-deploy/connect-cloud-provider.use-case.ts index 2e26f2a57..335c00e25 100644 --- a/packages/core/src/application/use-cases/cloud-deploy/connect-cloud-provider.use-case.ts +++ b/packages/core/src/application/use-cases/cloud-deploy/connect-cloud-provider.use-case.ts @@ -2,7 +2,6 @@ import { inject, injectable } from 'tsyringe'; import type { ICloudProviderTokensRepository } from '../../ports/output/repositories/cloud-provider-tokens.repository.interface.js'; import type { ICloudDeploymentProviderRegistry } from '../../ports/output/services/cloud-deployment-provider-registry.interface.js'; -import { ProviderNotImplementedError } from '../../../domain/errors/provider-not-implemented.error.js'; import type { CloudDeploymentProvider } from '../../../domain/generated/output.js'; export interface ConnectCloudProviderInput { @@ -21,9 +20,6 @@ export class ConnectCloudProviderUseCase { async execute(input: ConnectCloudProviderInput): Promise { const provider = this.registry.get(input.provider); - if (!provider.enabled) { - throw new ProviderNotImplementedError(input.provider); - } // Throws if the token is invalid — we do NOT persist on failure. await provider.validateToken(input.token); await this.tokens.set(input.provider, input.token); diff --git a/packages/core/src/application/use-cases/cloud-deploy/initiate-cloud-deployment.use-case.ts b/packages/core/src/application/use-cases/cloud-deploy/initiate-cloud-deployment.use-case.ts index ec5811c3a..f42e3381a 100644 --- a/packages/core/src/application/use-cases/cloud-deploy/initiate-cloud-deployment.use-case.ts +++ b/packages/core/src/application/use-cases/cloud-deploy/initiate-cloud-deployment.use-case.ts @@ -18,7 +18,6 @@ import { ApplicationNotFoundError } from '../../../domain/errors/application-not import { NoProviderSelectedError } from '../../../domain/errors/no-provider-selected.error.js'; import { BuildOutputNotFoundError } from '../../../domain/errors/build-output-not-found.error.js'; import { CloudProviderNotConnectedError } from '../../../domain/errors/cloud-provider-not-connected.error.js'; -import { ProviderNotImplementedError } from '../../../domain/errors/provider-not-implemented.error.js'; export interface InitiateCloudDeploymentInput { applicationId: string; @@ -120,10 +119,6 @@ export class InitiateCloudDeploymentUseCase { await this.opLog.info(opKind, opId, `Starting deploy to ${providerId}`); const provider = this.registry.get(providerId); - if (!provider.enabled) { - await this.opLog.error(opKind, opId, `Provider ${providerId} is not enabled in this build`); - throw new ProviderNotImplementedError(providerId); - } if (!(await provider.isConnected())) { await this.opLog.error( opKind, @@ -255,7 +250,7 @@ export class InitiateCloudDeploymentUseCase { /** * Walk the provider registry in declared order and return the first - * provider that is both `enabled` AND `isConnected()`. Used to pick + * provider that reports `isConnected()`. Used to pick * a sensible default when a deploy is initiated on an application * that has no explicit `cloudDeploymentProvider` yet. * @@ -266,7 +261,6 @@ export class InitiateCloudDeploymentUseCase { private async findFirstConnectedProvider(): Promise { const descriptors = this.registry.listAll(); for (const descriptor of descriptors) { - if (!descriptor.enabled) continue; const provider = this.registry.get(descriptor.id); try { if (await provider.isConnected()) return descriptor.id; diff --git a/packages/core/src/application/use-cases/cloud-deploy/list-cloud-providers.use-case.ts b/packages/core/src/application/use-cases/cloud-deploy/list-cloud-providers.use-case.ts index 0994cb985..e53921ed1 100644 --- a/packages/core/src/application/use-cases/cloud-deploy/list-cloud-providers.use-case.ts +++ b/packages/core/src/application/use-cases/cloud-deploy/list-cloud-providers.use-case.ts @@ -7,7 +7,6 @@ import type { CloudDeploymentProvider } from '../../../domain/generated/output.j export interface ListedCloudProvider { id: CloudDeploymentProvider; displayName: string; - enabled: boolean; connected: boolean; } @@ -26,7 +25,6 @@ export class ListCloudProvidersUseCase { return descriptors.map((d) => ({ id: d.id, displayName: d.displayName, - enabled: d.enabled, connected: connectedSet.has(d.id), })); } diff --git a/packages/core/src/application/use-cases/docs-first/check-docs-gate.use-case.ts b/packages/core/src/application/use-cases/docs-first/check-docs-gate.use-case.ts index 0282fe25a..ddb5ff589 100644 --- a/packages/core/src/application/use-cases/docs-first/check-docs-gate.use-case.ts +++ b/packages/core/src/application/use-cases/docs-first/check-docs-gate.use-case.ts @@ -2,11 +2,17 @@ * CheckDocsGateUseCase (spec 131): whether a change in a repository may merge * on its own as far as documentation goes. In a docs-first space a change * passes only when one of its files is under the space's documentation - * paths; elsewhere the gate asks nothing. + * paths; elsewhere — or with the docsFirst feature flag off — the gate asks + * nothing. */ import { injectable, inject } from 'tsyringe'; -import { docsPathsOf, documentationChanges } from '../../../domain/shared/docs-first.js'; +import { + docsPathsOf, + documentationChanges, + isDocsFirstActive, +} from '../../../domain/shared/docs-first.js'; +import type { ISettingsRepository } from '../../ports/output/repositories/settings.repository.interface.js'; import { ResolveSpaceContextUseCase } from '../spaces/resolve-space-context.use-case.js'; export interface DocsGate { @@ -24,12 +30,15 @@ export interface DocsGate { export class CheckDocsGateUseCase { constructor( @inject(ResolveSpaceContextUseCase) - private readonly resolveSpaceContext: ResolveSpaceContextUseCase + private readonly resolveSpaceContext: ResolveSpaceContextUseCase, + @inject('ISettingsRepository') + private readonly settingsRepository: ISettingsRepository ) {} async execute(repositoryPath: string, changedFiles: readonly string[]): Promise { const { space } = await this.resolveSpaceContext.execute(repositoryPath); - if (!space.agentSettings?.docsFirst) { + const flags = (await this.settingsRepository.load())?.featureFlags; + if (!space.agentSettings || !isDocsFirstActive(space.agentSettings, flags)) { return { required: false, passed: true, docsPaths: [], documentation: [] }; } const docsPaths = docsPathsOf(space.agentSettings); diff --git a/packages/core/src/application/use-cases/project-memory/select-project-memory.use-case.ts b/packages/core/src/application/use-cases/project-memory/select-project-memory.use-case.ts index 1b1494b29..6d81bdc17 100644 --- a/packages/core/src/application/use-cases/project-memory/select-project-memory.use-case.ts +++ b/packages/core/src/application/use-cases/project-memory/select-project-memory.use-case.ts @@ -29,7 +29,12 @@ import { import { renderMemoryBlob } from './render-memory-blob.js'; import { MEMORY_TOKEN_BUDGET, CHARS_PER_TOKEN } from './project-memory.constants.js'; import { SelectKnowledgeUseCase } from '../knowledge/select-knowledge.use-case.js'; -import { docsFirstInstructions, docsPathsOf } from '../../../domain/shared/docs-first.js'; +import { + docsFirstInstructions, + docsPathsOf, + isDocsFirstActive, +} from '../../../domain/shared/docs-first.js'; +import type { ISettingsRepository } from '../../ports/output/repositories/settings.repository.interface.js'; export interface SelectProjectMemoryInput { /** Normalised repository path whose memory should be considered. */ @@ -62,7 +67,8 @@ export class SelectProjectMemoryUseCase { private readonly scorer: IMemoryRelevanceScorer, @inject(ResolveSpaceContextUseCase) private readonly resolveSpaceContext: ResolveSpaceContextUseCase, - @inject(SelectKnowledgeUseCase) private readonly selectKnowledge: SelectKnowledgeUseCase + @inject(SelectKnowledgeUseCase) private readonly selectKnowledge: SelectKnowledgeUseCase, + @inject('ISettingsRepository') private readonly settingsRepository: ISettingsRepository ) {} async execute(input: SelectProjectMemoryInput): Promise { @@ -78,8 +84,9 @@ export class SelectProjectMemoryUseCase { taskText: input.taskText ?? '', }); const settings = context.space.agentSettings; + const flags = (await this.settingsRepository.load())?.featureFlags; const docsFirst = - settings?.docsFirst && input.phase + isDocsFirstActive(settings, flags) && input.phase ? docsFirstInstructions(input.phase, docsPathsOf(settings)) : ''; return { diff --git a/packages/core/src/application/use-cases/settings/index.ts b/packages/core/src/application/use-cases/settings/index.ts index 4ed5011f7..c5042b0dc 100644 --- a/packages/core/src/application/use-cases/settings/index.ts +++ b/packages/core/src/application/use-cases/settings/index.ts @@ -16,3 +16,7 @@ export { CompleteOnboardingUseCase } from './complete-onboarding.use-case.js'; export type { CompleteOnboardingInput } from './complete-onboarding.use-case.js'; export { CompleteWebOnboardingUseCase } from './complete-web-onboarding.use-case.js'; export type { CompleteWebOnboardingInput } from './complete-web-onboarding.use-case.js'; +export { ListFeatureFlagsUseCase } from './list-feature-flags.use-case.js'; +export type { FeatureFlagState } from './list-feature-flags.use-case.js'; +export { SetFeatureFlagUseCase } from './set-feature-flag.use-case.js'; +export type { SetFeatureFlagInput } from './set-feature-flag.use-case.js'; diff --git a/packages/core/src/application/use-cases/settings/list-feature-flags.use-case.ts b/packages/core/src/application/use-cases/settings/list-feature-flags.use-case.ts new file mode 100644 index 000000000..912f8b3d1 --- /dev/null +++ b/packages/core/src/application/use-cases/settings/list-feature-flags.use-case.ts @@ -0,0 +1,42 @@ +/** + * List Feature Flags Use Case (spec 135) + * + * Every feature flag with its group, one-line description, current value and + * default — what the feature-flags view and `shep settings flags` show. + */ + +import { injectable, inject } from 'tsyringe'; +import type { FeatureFlagGroup } from '../../../domain/generated/output.js'; +import { createDefaultSettings } from '../../../domain/factories/settings-defaults.factory.js'; +import { + listFeatureFlagDescriptors, + type FeatureFlagKey, +} from '../../../domain/shared/feature-flag-catalog.js'; +import type { ISettingsRepository } from '../../ports/output/repositories/settings.repository.interface.js'; + +export interface FeatureFlagState { + key: FeatureFlagKey; + group: FeatureFlagGroup; + description: string; + enabled: boolean; + /** The value a fresh install starts with. */ + defaultEnabled: boolean; +} + +@injectable() +export class ListFeatureFlagsUseCase { + constructor( + @inject('ISettingsRepository') + private readonly settingsRepository: ISettingsRepository + ) {} + + async execute(): Promise { + const defaults = createDefaultSettings().featureFlags!; + const current = (await this.settingsRepository.load())?.featureFlags ?? defaults; + return listFeatureFlagDescriptors().map((descriptor) => ({ + ...descriptor, + enabled: current[descriptor.key] ?? defaults[descriptor.key], + defaultEnabled: defaults[descriptor.key], + })); + } +} diff --git a/packages/core/src/application/use-cases/settings/set-feature-flag.use-case.ts b/packages/core/src/application/use-cases/settings/set-feature-flag.use-case.ts new file mode 100644 index 000000000..383d09176 --- /dev/null +++ b/packages/core/src/application/use-cases/settings/set-feature-flag.use-case.ts @@ -0,0 +1,48 @@ +/** + * Set Feature Flag Use Case (spec 135) + * + * Turns one feature flag on or off. The web feature-flags view, the Settings + * page and `shep settings flags` all go through here, so an unknown flag is + * rejected in one place and the other flags are never rebuilt. + */ + +import { injectable, inject } from 'tsyringe'; +import type { Settings } from '../../../domain/generated/output.js'; +import { createDefaultSettings } from '../../../domain/factories/settings-defaults.factory.js'; +import { isFeatureFlagKey } from '../../../domain/shared/feature-flag-catalog.js'; +import type { ISettingsRepository } from '../../ports/output/repositories/settings.repository.interface.js'; + +export interface SetFeatureFlagInput { + /** A FeatureFlags key; anything else is rejected. */ + key: string; + enabled: boolean; +} + +@injectable() +export class SetFeatureFlagUseCase { + constructor( + @inject('ISettingsRepository') + private readonly settingsRepository: ISettingsRepository + ) {} + + async execute(input: SetFeatureFlagInput): Promise { + if (!isFeatureFlagKey(input.key)) { + throw new Error(`Unknown feature flag "${input.key}"`); + } + const settings = await this.settingsRepository.load(); + if (!settings) { + throw new Error('Settings not initialized'); + } + + const next: Settings = { + ...settings, + featureFlags: { + ...(settings.featureFlags ?? createDefaultSettings().featureFlags!), + [input.key]: input.enabled, + }, + updatedAt: new Date(), + }; + await this.settingsRepository.update(next); + return next; + } +} diff --git a/packages/core/src/domain/errors/provider-not-implemented.error.ts b/packages/core/src/domain/errors/provider-not-implemented.error.ts deleted file mode 100644 index dc4830bc4..000000000 --- a/packages/core/src/domain/errors/provider-not-implemented.error.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Provider Not Implemented Error - * - * Thrown by stub cloud deployment provider adapters that are listed in the UI - * but not yet live (enabled = false). Used for "Coming soon" entries. - */ -export class ProviderNotImplementedError extends Error { - readonly code = 'PROVIDER_NOT_IMPLEMENTED'; - constructor(public readonly provider: string) { - super(`Cloud deployment provider ${provider} is not yet implemented`); - this.name = 'ProviderNotImplementedError'; - Object.setPrototypeOf(this, new.target.prototype); - } -} diff --git a/packages/core/src/domain/factories/settings-defaults.factory.ts b/packages/core/src/domain/factories/settings-defaults.factory.ts index e0af35fb7..96cb769ef 100644 --- a/packages/core/src/domain/factories/settings-defaults.factory.ts +++ b/packages/core/src/domain/factories/settings-defaults.factory.ts @@ -116,7 +116,6 @@ export function createDefaultSettings(): Settings { }; const system: SystemConfig = { - autoUpdate: true, logLevel: DEFAULT_LOG_LEVEL, }; @@ -240,14 +239,26 @@ export function createDefaultSettings(): Settings { projects: true, codeReview: true, collaboration: true, - aspm: true, + aspm: false, bedrockIntegration: true, whatsappDispatch: false, clusters: false, - supplyChainSecurity: true, scheduledWorkflows: false, githubImport: true, queryAwareHarness: false, + // Software-factory areas (spec 135): on, so nothing changes for users. + spaces: true, + trackers: true, + knowledge: true, + signals: true, + opportunities: true, + feedback: true, + discovery: true, + incidents: true, + outcomes: true, + docsFirst: true, + autopilot: true, + factory: true, }; const whatsapp: WhatsAppConfig = { diff --git a/packages/core/src/domain/generated/output.ts b/packages/core/src/domain/generated/output.ts index 845295ced..700af15ab 100644 --- a/packages/core/src/domain/generated/output.ts +++ b/packages/core/src/domain/generated/output.ts @@ -360,10 +360,6 @@ export type EnvironmentConfig = { * System configuration */ export type SystemConfig = { - /** - * CLI auto-update preference - */ - autoUpdate: boolean; /** * Log level for CLI output */ @@ -563,8 +559,6 @@ export enum AgentType { CodexCli = 'codex-cli', CopilotCli = 'copilot-cli', GeminiCli = 'gemini-cli', - Aider = 'aider', - Continue = 'continue', Cursor = 'cursor', Cline = 'cline', OpenRouter = 'openrouter', @@ -760,17 +754,13 @@ export type FeatureFlags = { */ whatsappDispatch: boolean; /** - * Enable the Application Security Posture Management (ASPM) module — /aspm web routes, `shep aspm` CLI command tree, and the posture SSE stream (spec 098) + * Enable the Application Security Posture Management (ASPM) module — /aspm web routes, `shep aspm` CLI command tree, and the posture SSE stream (spec 098). Off by default: ASPM is a separate product category users opt into (spec 135). */ aspm: boolean; /** * Enable Clusters navigation and Kubernetes cluster management in the web UI */ clusters: boolean; - /** - * Enable the supply chain security feature (policy engine, badges, settings, CLI, CI gate). When false, the feature is inert regardless of SecurityMode. - */ - supplyChainSecurity: boolean; /** * Enable scheduled workflows feature — workflow creation, scheduling, and execution */ @@ -783,6 +773,54 @@ export type FeatureFlags = { * Enable the experimental query-aware agent harness: the Shep Harness agent, /harness pages, the feature Context tab and `shep harness` commands (spec 119) */ queryAwareHarness: boolean; + /** + * Software factory: spaces and product lines — /spaces and `shep space` (spec 120) + */ + spaces: boolean; + /** + * Software factory: Linear and Jira tracker sync — /connections, `shep connection`, `shep sync` and the daemon sync loop (spec 122) + */ + trackers: boolean; + /** + * Software factory: Notion knowledge sources — `shep knowledge` and the daemon knowledge sync (spec 125) + */ + knowledge: boolean; + /** + * Software factory: customer and incident signals — `shep signal` (spec 126) + */ + signals: boolean; + /** + * Software factory: ranked opportunities — /opportunities and `shep opportunity` (spec 126) + */ + opportunities: boolean; + /** + * Software factory: feedback intake and themes — POST /api/feedback and `shep feedback` (spec 127) + */ + feedback: boolean; + /** + * Software factory: agent discovery of opportunities — `shep discovery` and its daemon schedule (spec 128) + */ + discovery: boolean; + /** + * Software factory: incident triage — /incidents, POST /api/alerts and `shep incident` (spec 129) + */ + incidents: boolean; + /** + * Software factory: shipped-work outcomes — `shep outcome` and the daemon outcome tracker (spec 130) + */ + outcomes: boolean; + /** + * Software factory: docs-first spaces — the docs-first space policy, planning instructions and merge gate (spec 131) + */ + docsFirst: boolean; + /** + * Software factory: autopilot — `shep autopilot` and the daemon autopilot pass (spec 132) + */ + autopilot: boolean; + /** + * Software factory: factory status — /factory and `shep factory` (spec 132) + */ + factory: boolean; }; export enum WhatsAppAdapterKind { Baileys = 'baileys', @@ -2468,10 +2506,6 @@ export enum ApplicationStatus { } export enum CloudDeploymentProvider { CloudflarePages = 'CloudflarePages', - Vercel = 'Vercel', - Netlify = 'Netlify', - AwsAmplify = 'AwsAmplify', - GcpCloudRun = 'GcpCloudRun', } /** @@ -9418,6 +9452,11 @@ export enum WhatsAppThreadTargetKind { Feature = 'feature', Application = 'application', } +export enum FeatureFlagGroup { + Platform = 'platform', + SoftwareFactory = 'software-factory', + Experimental = 'experimental', +} export enum TelemetryEvent { InstallHeartbeat = 'install.heartbeat', CliCommand = 'cli.command', diff --git a/packages/core/src/domain/shared/agent-catalog.ts b/packages/core/src/domain/shared/agent-catalog.ts index 7d1a39411..5c70df3a0 100644 --- a/packages/core/src/domain/shared/agent-catalog.ts +++ b/packages/core/src/domain/shared/agent-catalog.ts @@ -701,36 +701,6 @@ export const AGENT_CATALOG: Record = { experimental: true, i18nKey: 'shepHarness', }, - [AgentType.Aider]: { - type: AgentType.Aider, - label: 'Aider', - description: 'Aider AI coding assistant', - kind: 'cli', - supported: false, - binary: null, - versionArgs: VERSION_FLAG, - toolId: null, - models: NO_MODELS, - order: 100, - requiresToken: false, - docsUrl: 'https://aider.chat/', - i18nKey: 'aider', - }, - [AgentType.Continue]: { - type: AgentType.Continue, - label: 'Continue', - description: 'Continue IDE extension', - kind: 'cli', - supported: false, - binary: null, - versionArgs: VERSION_FLAG, - toolId: null, - models: NO_MODELS, - order: 101, - requiresToken: false, - docsUrl: 'https://continue.dev/', - i18nKey: 'continue', - }, [AgentType.Dev]: { type: AgentType.Dev, label: 'Demo', diff --git a/packages/core/src/domain/shared/cloud-deployment-provider.ts b/packages/core/src/domain/shared/cloud-deployment-provider.ts new file mode 100644 index 000000000..3e5da8e99 --- /dev/null +++ b/packages/core/src/domain/shared/cloud-deployment-provider.ts @@ -0,0 +1,27 @@ +/** + * Narrow an untrusted value (a request body, a CLI argument, a persisted + * column) to a CloudDeploymentProvider. Unknown ids — including the + * placeholder providers removed in spec 135 (Vercel, Netlify, AwsAmplify, + * GcpCloudRun), which older databases may still hold — yield undefined. + * + * Pure: no I/O. Per the domain/ convention, relative imports carry no + * extension. + */ + +import { CloudDeploymentProvider } from '../generated/output'; + +export interface ParseCloudDeploymentProviderOptions { + /** Match ids regardless of case (CLI arguments such as `cloudflarepages`). */ + ignoreCase?: boolean; +} + +export function parseCloudDeploymentProvider( + value: unknown, + options: ParseCloudDeploymentProviderOptions = {} +): CloudDeploymentProvider | undefined { + if (typeof value !== 'string') return undefined; + const wanted = options.ignoreCase ? value.toLowerCase() : value; + return Object.values(CloudDeploymentProvider).find( + (id) => (options.ignoreCase ? id.toLowerCase() : id) === wanted + ); +} diff --git a/packages/core/src/domain/shared/docs-first.ts b/packages/core/src/domain/shared/docs-first.ts index 040e6354b..153c1db64 100644 --- a/packages/core/src/domain/shared/docs-first.ts +++ b/packages/core/src/domain/shared/docs-first.ts @@ -8,7 +8,7 @@ * extension. */ -import type { SpaceAgentSettings } from '../generated/output'; +import type { FeatureFlags, SpaceAgentSettings } from '../generated/output'; import { isAbsolutePath } from './absolute-path'; import { normalizePath } from './normalize-path'; @@ -19,6 +19,17 @@ export const DOCS_FIRST_PHASES = { plan: 'plan', implement: 'implement' } as con const CURRENT_DIR_PREFIX = /^(\.\/)+/; +/** + * Whether docs first applies: the space asks for it and the `docsFirst` + * feature flag is on (spec 135). Missing flags count as on, the default. + */ +export function isDocsFirstActive( + settings: SpaceAgentSettings | undefined, + flags: Pick | undefined +): boolean { + return settings?.docsFirst === true && flags?.docsFirst !== false; +} + /** The space's documentation path prefixes. */ export function docsPathsOf(settings: SpaceAgentSettings | undefined): readonly string[] { return settings?.docsPaths && settings.docsPaths.length > 0 diff --git a/packages/core/src/domain/shared/feature-flag-catalog.ts b/packages/core/src/domain/shared/feature-flag-catalog.ts new file mode 100644 index 000000000..eb1282422 --- /dev/null +++ b/packages/core/src/domain/shared/feature-flag-catalog.ts @@ -0,0 +1,141 @@ +/** + * Every feature flag with the section it is listed under and a one-line + * description (spec 135). The feature-flags view and `shep settings flags` + * both render this list. + * + * `FEATURE_FLAG_CATALOG` is a total Record over the FeatureFlags keys, so a + * flag added in TypeSpec without a catalog entry is a compile error. + */ +import { FeatureFlagGroup, type FeatureFlags } from '../generated/output'; + +export type FeatureFlagKey = keyof FeatureFlags; + +export interface FeatureFlagDescriptor { + key: FeatureFlagKey; + group: FeatureFlagGroup; + /** One line, in English: what turning the flag on enables. */ + description: string; +} + +/** Listing order within a group follows the declaration order below. */ +export const FEATURE_FLAG_CATALOG: Readonly< + Record> +> = { + envDeploy: { + group: FeatureFlagGroup.Platform, + description: 'Environment deployment workflows', + }, + projects: { + group: FeatureFlagGroup.Platform, + description: 'Projects pages and `shep project`, `item`, `cycle` and `intake`', + }, + codeReview: { + group: FeatureFlagGroup.Platform, + description: 'AI code review of pull requests and `shep review`', + }, + collaboration: { + group: FeatureFlagGroup.Platform, + description: 'Agent questions, supervisor agent and agent-to-agent messages', + }, + githubImport: { + group: FeatureFlagGroup.Platform, + description: 'Import (clone or fork) GitHub repositories into Shep', + }, + bedrockIntegration: { + group: FeatureFlagGroup.Platform, + description: 'Project Bedrock markdown memory for coding agents', + }, + aspm: { + group: FeatureFlagGroup.Platform, + description: 'Security posture management (/aspm, `shep aspm`) and supply-chain enforcement', + }, + spaces: { + group: FeatureFlagGroup.SoftwareFactory, + description: 'Spaces and product lines: /spaces and `shep space`', + }, + trackers: { + group: FeatureFlagGroup.SoftwareFactory, + description: 'Linear and Jira sync: /connections, `shep connection`, `shep sync`', + }, + knowledge: { + group: FeatureFlagGroup.SoftwareFactory, + description: 'Notion knowledge sources synced into a space: `shep knowledge`', + }, + signals: { + group: FeatureFlagGroup.SoftwareFactory, + description: 'Customer and incident signals: `shep signal`', + }, + opportunities: { + group: FeatureFlagGroup.SoftwareFactory, + description: 'Ranked opportunities: /opportunities and `shep opportunity`', + }, + feedback: { + group: FeatureFlagGroup.SoftwareFactory, + description: 'Feedback intake and themes: POST /api/feedback and `shep feedback`', + }, + discovery: { + group: FeatureFlagGroup.SoftwareFactory, + description: 'Agent discovery of opportunities: `shep discovery`', + }, + incidents: { + group: FeatureFlagGroup.SoftwareFactory, + description: 'Incident triage: /incidents, POST /api/alerts and `shep incident`', + }, + outcomes: { + group: FeatureFlagGroup.SoftwareFactory, + description: 'Outcomes of shipped work: `shep outcome`', + }, + docsFirst: { + group: FeatureFlagGroup.SoftwareFactory, + description: 'Docs-first spaces: docs written while planning and a docs merge gate', + }, + autopilot: { + group: FeatureFlagGroup.SoftwareFactory, + description: 'Autopilot passes per space: `shep autopilot`', + }, + factory: { + group: FeatureFlagGroup.SoftwareFactory, + description: 'Factory status: /factory and `shep factory`', + }, + clusters: { + group: FeatureFlagGroup.Experimental, + description: 'Kubernetes clusters: /clusters and `shep cluster`', + }, + scheduledWorkflows: { + group: FeatureFlagGroup.Experimental, + description: 'Scheduled workflows: /workflows and `shep workflow`', + }, + queryAwareHarness: { + group: FeatureFlagGroup.Experimental, + description: 'Query-aware agent harness: Shep Harness agent, /harness, `shep harness`', + }, + whatsappDispatch: { + group: FeatureFlagGroup.Experimental, + description: 'Dispatch and steer agents from WhatsApp: `shep whatsapp`', + }, + reactFileManager: { + group: FeatureFlagGroup.Experimental, + description: 'Built-in file manager instead of the native folder picker', + }, + debug: { + group: FeatureFlagGroup.Experimental, + description: 'Debug panels and verbose client-side logging', + }, +}; + +const GROUP_ORDER: readonly FeatureFlagGroup[] = Object.values(FeatureFlagGroup); + +/** Every flag, grouped in FeatureFlagGroup order. */ +export function listFeatureFlagDescriptors(): FeatureFlagDescriptor[] { + const entries = Object.entries(FEATURE_FLAG_CATALOG) as [ + FeatureFlagKey, + Omit, + ][]; + return GROUP_ORDER.flatMap((group) => + entries.filter(([, entry]) => entry.group === group).map(([key, entry]) => ({ key, ...entry })) + ); +} + +export function isFeatureFlagKey(value: string): value is FeatureFlagKey { + return Object.prototype.hasOwnProperty.call(FEATURE_FLAG_CATALOG, value); +} diff --git a/packages/core/src/domain/shared/previous-year-month.ts b/packages/core/src/domain/shared/previous-year-month.ts new file mode 100644 index 000000000..623c6e16f --- /dev/null +++ b/packages/core/src/domain/shared/previous-year-month.ts @@ -0,0 +1,12 @@ +/** + * The UTC `YYYY-MM` of the calendar month before `now`. + * + * Monthly contributor recaps always cover the previous month so they never + * run on a partial month. + */ +export function previousYearMonth(now: Date): string { + const prev = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1)); + const yyyy = prev.getUTCFullYear(); + const mm = String(prev.getUTCMonth() + 1).padStart(2, '0'); + return `${yyyy}-${mm}`; +} diff --git a/packages/core/src/domain/shared/supply-chain-security.ts b/packages/core/src/domain/shared/supply-chain-security.ts new file mode 100644 index 000000000..957fb21c8 --- /dev/null +++ b/packages/core/src/domain/shared/supply-chain-security.ts @@ -0,0 +1,22 @@ +/** + * Supply-chain security is part of ASPM (spec 135): it has no flag of its + * own and is on exactly when the `aspm` feature flag is on. + * + * `override` is the `SHEP_SUPPLY_CHAIN_SECURITY` environment value, read by + * the CLI: "false"/"0" force it off (a CI kill switch) and "true"/"1" force + * it on (CI runs enforcement on a fresh install where ASPM is off). Any + * other value is ignored. + */ +import type { FeatureFlags } from '../generated/output'; + +const FORCE_OFF: readonly string[] = ['false', '0']; +const FORCE_ON: readonly string[] = ['true', '1']; + +export function isSupplyChainSecurityEnabled( + flags: Pick | undefined, + override?: string +): boolean { + if (override !== undefined && FORCE_OFF.includes(override)) return false; + if (override !== undefined && FORCE_ON.includes(override)) return true; + return flags?.aspm === true; +} diff --git a/packages/core/src/infrastructure/di/modules/register-cloud-deploy.ts b/packages/core/src/infrastructure/di/modules/register-cloud-deploy.ts index 144f094c2..589d4932b 100644 --- a/packages/core/src/infrastructure/di/modules/register-cloud-deploy.ts +++ b/packages/core/src/infrastructure/di/modules/register-cloud-deploy.ts @@ -1,4 +1,4 @@ -import type { DependencyContainer } from 'tsyringe'; +import { instanceCachingFactory, type DependencyContainer } from 'tsyringe'; import type Database from 'better-sqlite3'; import type { ICloudProviderTokensRepository } from '../../../application/ports/output/repositories/cloud-provider-tokens.repository.interface.js'; @@ -15,10 +15,6 @@ import { CLOUD_DEPLOYMENT_PROVIDER_TOKEN, } from '../../services/cloud-deploy/cloud-deployment-provider.registry.js'; import { CloudflarePagesProvider } from '../../services/cloud-deploy/cloudflare-pages.provider.js'; -import { VercelProviderStub } from '../../services/cloud-deploy/vercel.provider.stub.js'; -import { NetlifyProviderStub } from '../../services/cloud-deploy/netlify.provider.stub.js'; -import { AwsAmplifyProviderStub } from '../../services/cloud-deploy/aws-amplify.provider.stub.js'; -import { GcpCloudRunProviderStub } from '../../services/cloud-deploy/gcp-cloud-run.provider.stub.js'; import { InMemoryCloudDeploymentEventBus } from '../../services/events/in-memory-cloud-deployment-event-bus.js'; import { GitRemoteService } from '../../services/git/git-remote.service.js'; import { CloudDeploymentProvider } from '../../../domain/generated/output.js'; @@ -36,7 +32,7 @@ import { SyncRepoUseCase } from '../../../application/use-cases/cloud-deploy/syn /** * Cloud deployment registrations (spec 089). * - * Registers the secret box, per-provider adapters, provider registry, event bus, + * Registers the secret box, the Cloudflare Pages adapter, provider registry, event bus, * git-remote service, token repository, and the cloud-deploy use cases. */ export function registerCloudDeploy(container: DependencyContainer): void { @@ -65,32 +61,18 @@ export function registerCloudDeploy(container: DependencyContainer): void { }, }); - // Per-provider registrations (mirrors AgentSessionRepositoryRegistry pattern). + // One registration per CloudDeploymentProvider member, keyed by + // CLOUD_DEPLOYMENT_PROVIDER_TOKEN. Cloudflare Pages is the only one. container.registerSingleton( CLOUD_DEPLOYMENT_PROVIDER_TOKEN(CloudDeploymentProvider.CloudflarePages), CloudflarePagesProvider ); - container.registerSingleton( - CLOUD_DEPLOYMENT_PROVIDER_TOKEN(CloudDeploymentProvider.Vercel), - VercelProviderStub - ); - container.registerSingleton( - CLOUD_DEPLOYMENT_PROVIDER_TOKEN(CloudDeploymentProvider.Netlify), - NetlifyProviderStub - ); - container.registerSingleton( - CLOUD_DEPLOYMENT_PROVIDER_TOKEN(CloudDeploymentProvider.AwsAmplify), - AwsAmplifyProviderStub - ); - container.registerSingleton( - CLOUD_DEPLOYMENT_PROVIDER_TOKEN(CloudDeploymentProvider.GcpCloudRun), - GcpCloudRunProviderStub - ); - container.registerSingleton( - 'ICloudDeploymentProviderRegistry', - CloudDeploymentProviderRegistry - ); + // The factory hands the registry the container that resolves it, so the + // provider tokens above are looked up there rather than in the global root. + container.register('ICloudDeploymentProviderRegistry', { + useFactory: instanceCachingFactory((c) => new CloudDeploymentProviderRegistry(c)), + }); container.registerSingleton( 'ICloudDeploymentEventBus', InMemoryCloudDeploymentEventBus diff --git a/packages/core/src/infrastructure/di/modules/register-use-cases.ts b/packages/core/src/infrastructure/di/modules/register-use-cases.ts index e421d33c7..119b35e42 100644 --- a/packages/core/src/infrastructure/di/modules/register-use-cases.ts +++ b/packages/core/src/infrastructure/di/modules/register-use-cases.ts @@ -116,6 +116,8 @@ import { InitializeSettingsUseCase } from '../../../application/use-cases/settin import { LoadSettingsUseCase } from '../../../application/use-cases/settings/load-settings.use-case.js'; import { UpdateSettingsUseCase } from '../../../application/use-cases/settings/update-settings.use-case.js'; import { SetDefaultEffortUseCase } from '../../../application/use-cases/settings/set-default-effort.use-case.js'; +import { ListFeatureFlagsUseCase } from '../../../application/use-cases/settings/list-feature-flags.use-case.js'; +import { SetFeatureFlagUseCase } from '../../../application/use-cases/settings/set-feature-flag.use-case.js'; import { GetAdaptiveModelPlanUseCase } from '../../../application/use-cases/settings/get-adaptive-model-plan.use-case.js'; import { CompleteOnboardingUseCase } from '../../../application/use-cases/settings/complete-onboarding.use-case.js'; import { CompleteWebOnboardingUseCase } from '../../../application/use-cases/settings/complete-web-onboarding.use-case.js'; @@ -300,6 +302,8 @@ export function registerUseCases(container: DependencyContainer): void { container.registerSingleton(LoadSettingsUseCase); container.registerSingleton(UpdateSettingsUseCase); container.registerSingleton(SetDefaultEffortUseCase); + container.registerSingleton(ListFeatureFlagsUseCase); + container.registerSingleton(SetFeatureFlagUseCase); container.registerSingleton(GetAdaptiveModelPlanUseCase); container.registerSingleton(CompleteOnboardingUseCase); container.registerSingleton(CompleteWebOnboardingUseCase); @@ -587,6 +591,12 @@ export function registerUseCases(container: DependencyContainer): void { container.register('SetDefaultEffortUseCase', { useFactory: (c) => c.resolve(SetDefaultEffortUseCase), }); + container.register('ListFeatureFlagsUseCase', { + useFactory: (c) => c.resolve(ListFeatureFlagsUseCase), + }); + container.register('SetFeatureFlagUseCase', { + useFactory: (c) => c.resolve(SetFeatureFlagUseCase), + }); container.register('CompleteWebOnboardingUseCase', { useFactory: (c) => c.resolve(CompleteWebOnboardingUseCase), }); diff --git a/packages/core/src/infrastructure/persistence/sqlite/mappers/application.mapper.ts b/packages/core/src/infrastructure/persistence/sqlite/mappers/application.mapper.ts index 6d88a648d..f8dd26e08 100644 --- a/packages/core/src/infrastructure/persistence/sqlite/mappers/application.mapper.ts +++ b/packages/core/src/infrastructure/persistence/sqlite/mappers/application.mapper.ts @@ -11,13 +11,13 @@ import type { Application, - CloudDeploymentProvider, CloudDeploymentStatus, Criticality, DataClassification, Exposure, ScannerProfile, } from '../../../../domain/generated/output.js'; +import { parseCloudDeploymentProvider } from '../../../../domain/shared/cloud-deployment-provider.js'; /** * Database row type matching the applications table schema. @@ -133,8 +133,9 @@ export function fromDatabase(row: ApplicationRow): Application { setupComplete: row.setup_complete === 1, agentSessionId: row.agent_session_id ?? undefined, gitRemoteUrl: row.git_remote_url ?? undefined, - cloudDeploymentProvider: - (row.cloud_deployment_provider as CloudDeploymentProvider | null) ?? undefined, + // Unknown ids (e.g. the Vercel/Netlify/AwsAmplify/GcpCloudRun placeholders + // removed in spec 135, selectable before then) read back as no selection. + cloudDeploymentProvider: parseCloudDeploymentProvider(row.cloud_deployment_provider), cloudDeploymentStatus: (row.cloud_deployment_status as CloudDeploymentStatus | null) ?? undefined, cloudDeploymentId: row.cloud_deployment_id ?? undefined, diff --git a/packages/core/src/infrastructure/persistence/sqlite/mappers/settings.mapper.ts b/packages/core/src/infrastructure/persistence/sqlite/mappers/settings.mapper.ts index 9fa0f1866..3b3cfa77d 100644 --- a/packages/core/src/infrastructure/persistence/sqlite/mappers/settings.mapper.ts +++ b/packages/core/src/infrastructure/persistence/sqlite/mappers/settings.mapper.ts @@ -24,6 +24,7 @@ import type { import { createDefaultSettings } from '../../../../domain/factories/settings-defaults.factory.js'; import { normalizeWorktreeConfig } from '../../../../domain/shared/worktree-config.js'; import { parseAgentEffort } from '../../../../domain/shared/agent-effort.js'; +import { getAgentDescriptor } from '../../../../domain/shared/agent-catalog.js'; import { resolveHarnessConfig } from '../../../../domain/harness/harness-config.js'; import { clampMaxParallelFeatures, @@ -42,6 +43,13 @@ import { type WhatsAppConnectionStatus, } from '../../../../domain/generated/output.js'; +/** + * Value written to the legacy `sys_auto_update` column (NOT NULL, no default). + * 1 matches what the removed `autoUpdate` setting defaulted to, so older builds + * reading the column see their usual value. + */ +const LEGACY_SYS_AUTO_UPDATE = 1; + /** * Database row type matching the settings table schema. * Uses snake_case column names with flattened nested objects. @@ -87,7 +95,11 @@ export interface SettingsRow extends SettingsTelemetryRow { env_terminal_preference: string; // SystemConfig (system.*) - sys_auto_update: number; // Boolean stored as INTEGER + /** + * Legacy NOT NULL column of the removed `system.autoUpdate` setting (spec 135). + * Always written as LEGACY_SYS_AUTO_UPDATE and never read. + */ + sys_auto_update: number; sys_log_level: string; // AgentConfig (agent.*) @@ -165,11 +177,23 @@ export interface SettingsRow extends SettingsTelemetryRow { feature_flag_whatsapp_dispatch: number; feature_flag_aspm: number; feature_flag_clusters: number; - feature_flag_supply_chain_security: number; feature_flag_scheduled_workflows: number; feature_flag_github_import?: number; // Query-aware harness flag (migration 151) feature_flag_query_aware_harness?: number; + // Software-factory area flags (spec 135, migration 166) + feature_flag_spaces: number; + feature_flag_trackers: number; + feature_flag_knowledge: number; + feature_flag_signals: number; + feature_flag_opportunities: number; + feature_flag_feedback: number; + feature_flag_discovery: number; + feature_flag_incidents: number; + feature_flag_outcomes: number; + feature_flag_docs_first: number; + feature_flag_autopilot: number; + feature_flag_factory: number; // Interactive agent config (added in migration 046) interactive_agent_enabled: number; interactive_agent_auto_timeout_minutes: number; @@ -281,7 +305,7 @@ export function toDatabase(settings: Settings): SettingsRow { env_terminal_preference: settings.environment.terminalPreference, // SystemConfig - sys_auto_update: settings.system.autoUpdate ? 1 : 0, + sys_auto_update: LEGACY_SYS_AUTO_UPDATE, sys_log_level: settings.system.logLevel, // AgentConfig (optional token → NULL) @@ -361,10 +385,21 @@ export function toDatabase(settings: Settings): SettingsRow { feature_flag_whatsapp_dispatch: settings.featureFlags?.whatsappDispatch ? 1 : 0, feature_flag_aspm: settings.featureFlags?.aspm ? 1 : 0, feature_flag_clusters: settings.featureFlags?.clusters ? 1 : 0, - feature_flag_supply_chain_security: settings.featureFlags?.supplyChainSecurity ? 1 : 0, feature_flag_scheduled_workflows: settings.featureFlags?.scheduledWorkflows ? 1 : 0, feature_flag_github_import: settings.featureFlags?.githubImport !== false ? 1 : 0, feature_flag_query_aware_harness: settings.featureFlags?.queryAwareHarness ? 1 : 0, + feature_flag_spaces: settings.featureFlags?.spaces ? 1 : 0, + feature_flag_trackers: settings.featureFlags?.trackers ? 1 : 0, + feature_flag_knowledge: settings.featureFlags?.knowledge ? 1 : 0, + feature_flag_signals: settings.featureFlags?.signals ? 1 : 0, + feature_flag_opportunities: settings.featureFlags?.opportunities ? 1 : 0, + feature_flag_feedback: settings.featureFlags?.feedback ? 1 : 0, + feature_flag_discovery: settings.featureFlags?.discovery ? 1 : 0, + feature_flag_incidents: settings.featureFlags?.incidents ? 1 : 0, + feature_flag_outcomes: settings.featureFlags?.outcomes ? 1 : 0, + feature_flag_docs_first: settings.featureFlags?.docsFirst ? 1 : 0, + feature_flag_autopilot: settings.featureFlags?.autopilot ? 1 : 0, + feature_flag_factory: settings.featureFlags?.factory ? 1 : 0, // InteractiveAgentConfig (boolean → 0/1, integer fields; defaults applied here) interactive_agent_enabled: (settings.interactiveAgent?.enabled ?? true) ? 1 : 0, @@ -750,15 +785,16 @@ export function fromDatabase(row: SettingsRow): Settings { terminalPreference: (row.env_terminal_preference ?? 'system') as TerminalType, }, - // SystemConfig (INTEGER → boolean) system: { - autoUpdate: row.sys_auto_update === 1, logLevel: row.sys_log_level, }, // AgentConfig (NULL → undefined for optional token) agent: { - type: row.agent_type as AgentType, + // A removed agent type (aider, continue — spec 135) reads back as the default. + type: getAgentDescriptor(row.agent_type) + ? (row.agent_type as AgentType) + : createDefaultSettings().agent.type, authMethod: row.agent_auth_method as AgentAuthMethod, ...(row.agent_token !== null && { token: row.agent_token }), }, @@ -829,11 +865,22 @@ export function fromDatabase(row: SettingsRow): Settings { aspm: row.feature_flag_aspm === 1, clusters: row.feature_flag_clusters === 1, // Default true when column is missing/null (pre-migration upgrades) - supplyChainSecurity: (row.feature_flag_supply_chain_security ?? 1) !== 0, scheduledWorkflows: row.feature_flag_scheduled_workflows === 1, // Default true when column is missing/null (pre-migration upgrades) githubImport: (row.feature_flag_github_import ?? 1) !== 0, queryAwareHarness: row.feature_flag_query_aware_harness === 1, + spaces: row.feature_flag_spaces === 1, + trackers: row.feature_flag_trackers === 1, + knowledge: row.feature_flag_knowledge === 1, + signals: row.feature_flag_signals === 1, + opportunities: row.feature_flag_opportunities === 1, + feedback: row.feature_flag_feedback === 1, + discovery: row.feature_flag_discovery === 1, + incidents: row.feature_flag_incidents === 1, + outcomes: row.feature_flag_outcomes === 1, + docsFirst: row.feature_flag_docs_first === 1, + autopilot: row.feature_flag_autopilot === 1, + factory: row.feature_flag_factory === 1, }, // InteractiveAgentConfig (INTEGER 0/1 → boolean, integer → number) diff --git a/packages/core/src/infrastructure/persistence/sqlite/migrations/131-add-feature-flag-supply-chain-security.ts b/packages/core/src/infrastructure/persistence/sqlite/migrations/131-add-feature-flag-supply-chain-security.ts index 4acf97b99..364042bba 100644 --- a/packages/core/src/infrastructure/persistence/sqlite/migrations/131-add-feature-flag-supply-chain-security.ts +++ b/packages/core/src/infrastructure/persistence/sqlite/migrations/131-add-feature-flag-supply-chain-security.ts @@ -8,6 +8,9 @@ * When this flag is 0, the entire feature goes inert: no badge on the canvas, * no Settings section, no agent pre-check, no CLI enforce, no CI gate — * regardless of the SecurityMode value in the security config. + * + * Read-ignored since spec 135: supply-chain security folded into ASPM and + * follows `feature_flag_aspm`. The column stays so older builds still work. */ import type { MigrationParams } from 'umzug'; diff --git a/packages/core/src/infrastructure/persistence/sqlite/migrations/169-add-software-factory-feature-flags.ts b/packages/core/src/infrastructure/persistence/sqlite/migrations/169-add-software-factory-feature-flags.ts new file mode 100644 index 000000000..456aee6fd --- /dev/null +++ b/packages/core/src/infrastructure/persistence/sqlite/migrations/169-add-software-factory-feature-flags.ts @@ -0,0 +1,39 @@ +/** + * Migration 169: one feature flag per software-factory area (spec 135). + * + * Specs 120–132 shipped with no flag. Each area — spaces, trackers, knowledge, + * signals, opportunities, feedback, discovery, incidents, outcomes, docs + * first, autopilot and factory — gets a `feature_flag_*` column. They default + * to 1 so existing installs keep every area they already have. + * + * Additive and idempotent. + */ + +import type { MigrationParams } from 'umzug'; +import type Database from 'better-sqlite3'; +import { addColumn } from '../add-column.js'; + +const FLAG_COLUMNS = [ + 'feature_flag_spaces', + 'feature_flag_trackers', + 'feature_flag_knowledge', + 'feature_flag_signals', + 'feature_flag_opportunities', + 'feature_flag_feedback', + 'feature_flag_discovery', + 'feature_flag_incidents', + 'feature_flag_outcomes', + 'feature_flag_docs_first', + 'feature_flag_autopilot', + 'feature_flag_factory', +] as const; + +export async function up({ context: db }: MigrationParams): Promise { + for (const column of FLAG_COLUMNS) { + addColumn(db, 'settings', column, 'INTEGER NOT NULL DEFAULT 1'); + } +} + +export async function down(_params: MigrationParams): Promise { + // Additive migration: columns with defaults are harmless to an older build. +} diff --git a/packages/core/src/infrastructure/repositories/sqlite-cloud-provider-tokens.repository.ts b/packages/core/src/infrastructure/repositories/sqlite-cloud-provider-tokens.repository.ts index 151a7f643..044f15bf0 100644 --- a/packages/core/src/infrastructure/repositories/sqlite-cloud-provider-tokens.repository.ts +++ b/packages/core/src/infrastructure/repositories/sqlite-cloud-provider-tokens.repository.ts @@ -10,6 +10,7 @@ import { injectable } from 'tsyringe'; import type { ICloudProviderTokensRepository } from '../../application/ports/output/repositories/cloud-provider-tokens.repository.interface.js'; import type { CloudDeploymentProvider } from '../../domain/generated/output.js'; +import { parseCloudDeploymentProvider } from '../../domain/shared/cloud-deployment-provider.js'; import { LocalSecretBox } from '../services/crypto/local-secret-box.js'; interface CloudProviderTokenRow { @@ -76,6 +77,11 @@ export class SQLiteCloudProviderTokensRepository implements ICloudProviderTokens const rows = this.db .prepare<[], { provider: string }>('SELECT provider FROM cloud_provider_tokens') .all(); - return rows.map((r) => r.provider as CloudDeploymentProvider); + // Skip rows for provider ids that no longer exist (see spec 135) — nothing + // can deploy to them, so they must not count as connected. + return rows.flatMap((r) => { + const provider = parseCloudDeploymentProvider(r.provider); + return provider ? [provider] : []; + }); } } diff --git a/packages/core/src/infrastructure/repositories/sqlite-settings.repository.ts b/packages/core/src/infrastructure/repositories/sqlite-settings.repository.ts index 5d62b1b3c..a342ce035 100644 --- a/packages/core/src/infrastructure/repositories/sqlite-settings.repository.ts +++ b/packages/core/src/infrastructure/repositories/sqlite-settings.repository.ts @@ -80,7 +80,6 @@ export class SQLiteSettingsRepository implements ISettingsRepository { feature_flag_whatsapp_dispatch, feature_flag_aspm, feature_flag_clusters, - feature_flag_supply_chain_security, workflow_enable_evidence, workflow_commit_evidence, hide_ci_status, default_mode, interactive_agent_enabled, interactive_agent_auto_timeout_minutes, @@ -97,6 +96,10 @@ export class SQLiteSettingsRepository implements ISettingsRepository { security_mode, security_last_evaluation_at, security_policy_source, worktree_create_command, worktree_post_create_command, worktree_command_timeout_ms, feature_flag_query_aware_harness, harness_config, + feature_flag_spaces, feature_flag_trackers, feature_flag_knowledge, + feature_flag_signals, feature_flag_opportunities, feature_flag_feedback, + feature_flag_discovery, feature_flag_incidents, feature_flag_outcomes, + feature_flag_docs_first, feature_flag_autopilot, feature_flag_factory, messaging_enabled, messaging_gateway_url, messaging_device_id, messaging_gateway_client_id, messaging_debounce_ms, messaging_chat_buffer_ms, messaging_telegram_enabled, messaging_telegram_paired, messaging_telegram_chat_id, @@ -141,7 +144,6 @@ export class SQLiteSettingsRepository implements ISettingsRepository { @feature_flag_whatsapp_dispatch, @feature_flag_aspm, @feature_flag_clusters, - @feature_flag_supply_chain_security, @workflow_enable_evidence, @workflow_commit_evidence, @hide_ci_status, @default_mode, @interactive_agent_enabled, @interactive_agent_auto_timeout_minutes, @@ -158,6 +160,10 @@ export class SQLiteSettingsRepository implements ISettingsRepository { @security_mode, @security_last_evaluation_at, @security_policy_source, @worktree_create_command, @worktree_post_create_command, @worktree_command_timeout_ms, @feature_flag_query_aware_harness, @harness_config, + @feature_flag_spaces, @feature_flag_trackers, @feature_flag_knowledge, + @feature_flag_signals, @feature_flag_opportunities, @feature_flag_feedback, + @feature_flag_discovery, @feature_flag_incidents, @feature_flag_outcomes, + @feature_flag_docs_first, @feature_flag_autopilot, @feature_flag_factory, @messaging_enabled, @messaging_gateway_url, @messaging_device_id, @messaging_gateway_client_id, @messaging_debounce_ms, @messaging_chat_buffer_ms, @messaging_telegram_enabled, @messaging_telegram_paired, @messaging_telegram_chat_id, @@ -289,7 +295,6 @@ export class SQLiteSettingsRepository implements ISettingsRepository { feature_flag_whatsapp_dispatch = @feature_flag_whatsapp_dispatch, feature_flag_aspm = @feature_flag_aspm, feature_flag_clusters = @feature_flag_clusters, - feature_flag_supply_chain_security = @feature_flag_supply_chain_security, workflow_enable_evidence = @workflow_enable_evidence, workflow_commit_evidence = @workflow_commit_evidence, hide_ci_status = @hide_ci_status, @@ -319,6 +324,18 @@ export class SQLiteSettingsRepository implements ISettingsRepository { worktree_post_create_command = @worktree_post_create_command, worktree_command_timeout_ms = @worktree_command_timeout_ms, feature_flag_query_aware_harness = @feature_flag_query_aware_harness, + feature_flag_spaces = @feature_flag_spaces, + feature_flag_trackers = @feature_flag_trackers, + feature_flag_knowledge = @feature_flag_knowledge, + feature_flag_signals = @feature_flag_signals, + feature_flag_opportunities = @feature_flag_opportunities, + feature_flag_feedback = @feature_flag_feedback, + feature_flag_discovery = @feature_flag_discovery, + feature_flag_incidents = @feature_flag_incidents, + feature_flag_outcomes = @feature_flag_outcomes, + feature_flag_docs_first = @feature_flag_docs_first, + feature_flag_autopilot = @feature_flag_autopilot, + feature_flag_factory = @feature_flag_factory, harness_config = @harness_config, messaging_enabled = @messaging_enabled, messaging_gateway_url = @messaging_gateway_url, diff --git a/packages/core/src/infrastructure/services/agent-auth-detector/platform-agent-auth-detector.service.ts b/packages/core/src/infrastructure/services/agent-auth-detector/platform-agent-auth-detector.service.ts index bc0dfb8a2..2c1ca89e2 100644 --- a/packages/core/src/infrastructure/services/agent-auth-detector/platform-agent-auth-detector.service.ts +++ b/packages/core/src/infrastructure/services/agent-auth-detector/platform-agent-auth-detector.service.ts @@ -112,7 +112,7 @@ export class PlatformAgentAuthDetectorService implements IAgentAuthDetectorServi case AgentType.Ollama: // falls through default: - // dev, aider, continue, codex-cli — assume no auth needed + // dev, codex-cli — assume no auth needed return true; } } diff --git a/packages/core/src/infrastructure/services/agents/feature-agent/nodes/node-helpers.ts b/packages/core/src/infrastructure/services/agents/feature-agent/nodes/node-helpers.ts index f64cab377..e908fe47d 100644 --- a/packages/core/src/infrastructure/services/agents/feature-agent/nodes/node-helpers.ts +++ b/packages/core/src/infrastructure/services/agents/feature-agent/nodes/node-helpers.ts @@ -17,6 +17,7 @@ import type { import type { ApprovalGates, Evidence } from '@/domain/generated/output.js'; import { hasSettings, getSettings } from '@/infrastructure/services/settings.service.js'; import { SecurityViolationError } from '@/domain/errors/security-violation.error.js'; +import { isSupplyChainSecurityEnabled } from '@/domain/shared/supply-chain-security.js'; import { checkSecurityDisposition, resolveEffectiveSecurityMode } from './security-pre-check.js'; import type { FeatureAgentState } from '../state.js'; import { reportNodeStart } from '../heartbeat.js'; @@ -620,12 +621,11 @@ export function executeNode( } // Security pre-check: evaluate policy before executing the agent. - // Master kill switch — when the supplyChainSecurity feature flag is off, - // resolveEffectiveSecurityMode forces the mode to Disabled so + // Supply-chain security is part of ASPM — when the aspm feature flag is + // off, resolveEffectiveSecurityMode forces the mode to Disabled so // checkSecurityDisposition returns a skip and no security logic runs. - const supplyChainSecurityEnabled = hasSettings() - ? (getSettings().featureFlags?.supplyChainSecurity ?? true) - : true; + const supplyChainSecurityEnabled = + hasSettings() && isSupplyChainSecurityEnabled(getSettings().featureFlags); const effectiveSecurityMode = resolveEffectiveSecurityMode( state.securityMode, supplyChainSecurityEnabled diff --git a/packages/core/src/infrastructure/services/agents/feature-agent/nodes/security-pre-check.ts b/packages/core/src/infrastructure/services/agents/feature-agent/nodes/security-pre-check.ts index ec14d94f8..43e7980a5 100644 --- a/packages/core/src/infrastructure/services/agents/feature-agent/nodes/security-pre-check.ts +++ b/packages/core/src/infrastructure/services/agents/feature-agent/nodes/security-pre-check.ts @@ -35,14 +35,14 @@ export function classifyNodeAction(nodeName: string): SecurityActionCategory | n /** * Resolve the effective SecurityMode for a node execution, honoring the - * supplyChainSecurity master kill switch. When the feature flag is false, + * supply-chain kill switch (the aspm feature flag, spec 135). When it is off, * the mode is forced to Disabled regardless of the mode stored in state. * * Extracted as a pure function so the gate can be unit-tested without * spinning up the full node-helpers infrastructure. * * @param stateMode - The security mode carried in FeatureAgentState (or undefined) - * @param supplyChainSecurityEnabled - Value of the supplyChainSecurity feature flag + * @param supplyChainSecurityEnabled - Whether supply-chain security is on (isSupplyChainSecurityEnabled) * @returns SecurityMode.Disabled when the flag is off, otherwise the state mode * (or Disabled when state has no mode set). */ diff --git a/packages/core/src/infrastructure/services/cloud-deploy/aws-amplify.provider.stub.ts b/packages/core/src/infrastructure/services/cloud-deploy/aws-amplify.provider.stub.ts deleted file mode 100644 index f499ecd41..000000000 --- a/packages/core/src/infrastructure/services/cloud-deploy/aws-amplify.provider.stub.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { injectable } from 'tsyringe'; - -import { CloudDeploymentProvider } from '../../../domain/generated/output.js'; -import { BaseProviderStub } from './base-provider-stub.js'; - -@injectable() -export class AwsAmplifyProviderStub extends BaseProviderStub { - readonly providerId = CloudDeploymentProvider.AwsAmplify; - readonly displayName = 'AWS Amplify'; -} diff --git a/packages/core/src/infrastructure/services/cloud-deploy/base-provider-stub.ts b/packages/core/src/infrastructure/services/cloud-deploy/base-provider-stub.ts deleted file mode 100644 index f807f8e2b..000000000 --- a/packages/core/src/infrastructure/services/cloud-deploy/base-provider-stub.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Shared base class for disabled cloud deployment providers. - * v1 ships four of these (Vercel, Netlify, AwsAmplify, GcpCloudRun) so the - * UI dropdown and registry can list them as "Coming soon". - */ - -import type { - CloudDeployInput, - CloudDeployLogEmitter, - CloudDeployProgressHandler, - CloudDeployResult, - ICloudDeploymentProvider, -} from '../../../application/ports/output/services/cloud-deployment-provider.interface.js'; -import { ProviderNotImplementedError } from '../../../domain/errors/provider-not-implemented.error.js'; -import { - CloudDeploymentStatus, - type CloudDeploymentProvider, -} from '../../../domain/generated/output.js'; - -export abstract class BaseProviderStub implements ICloudDeploymentProvider { - abstract readonly providerId: CloudDeploymentProvider; - abstract readonly displayName: string; - readonly enabled: boolean = false; - - async isConnected(): Promise { - return false; - } - - async validateToken(_token: string): Promise { - throw new ProviderNotImplementedError(this.providerId); - } - - async deploy( - _input: CloudDeployInput, - _onProgress: CloudDeployProgressHandler, - _onLog?: CloudDeployLogEmitter - ): Promise { - throw new ProviderNotImplementedError(this.providerId); - } - - async getStatus( - _deploymentId: string - ): Promise<{ status: CloudDeploymentStatus; url?: string; error?: string }> { - return { status: CloudDeploymentStatus.NotDeployed }; - } -} diff --git a/packages/core/src/infrastructure/services/cloud-deploy/cloud-deployment-provider.registry.ts b/packages/core/src/infrastructure/services/cloud-deploy/cloud-deployment-provider.registry.ts index c8dab1c81..32f436a75 100644 --- a/packages/core/src/infrastructure/services/cloud-deploy/cloud-deployment-provider.registry.ts +++ b/packages/core/src/infrastructure/services/cloud-deploy/cloud-deployment-provider.registry.ts @@ -2,14 +2,13 @@ * CloudDeploymentProviderRegistry * * Resolves ICloudDeploymentProvider instances from the tsyringe container - * using a per-provider string token. Mirrors the - * AgentSessionRepositoryRegistry pattern already in container.ts. + * it was registered in, using a per-provider string token. Only ids with a + * registered adapter are listed. * * String-token format: `ICloudDeploymentProvider:${providerId}`. */ -import { injectable } from 'tsyringe'; -import { container } from 'tsyringe'; +import type { DependencyContainer } from 'tsyringe'; import type { CloudDeploymentProviderDescriptor, @@ -21,16 +20,15 @@ import { CloudDeploymentProvider } from '../../../domain/generated/output.js'; export const CLOUD_DEPLOYMENT_PROVIDER_TOKEN = (provider: CloudDeploymentProvider): string => `ICloudDeploymentProvider:${provider}`; -@injectable() export class CloudDeploymentProviderRegistry implements ICloudDeploymentProviderRegistry { + /** The container holding the per-provider registrations (a child container in tests). */ + constructor(private readonly container: DependencyContainer) {} + listAll(): CloudDeploymentProviderDescriptor[] { - return Object.values(CloudDeploymentProvider).map((id) => { + return Object.values(CloudDeploymentProvider).flatMap((id) => { const instance = this.tryGet(id); - return { - id, - displayName: instance?.displayName ?? id, - enabled: instance?.enabled ?? false, - }; + // An id with no registered adapter cannot deploy, so it is not listed. + return instance ? [{ id, displayName: instance.displayName }] : []; }); } @@ -46,7 +44,7 @@ export class CloudDeploymentProviderRegistry implements ICloudDeploymentProvider private tryGet(id: CloudDeploymentProvider): ICloudDeploymentProvider | null { const token = CLOUD_DEPLOYMENT_PROVIDER_TOKEN(id); - if (!container.isRegistered(token)) return null; - return container.resolve(token); + if (!this.container.isRegistered(token, true)) return null; + return this.container.resolve(token); } } diff --git a/packages/core/src/infrastructure/services/cloud-deploy/cloudflare-pages.provider.ts b/packages/core/src/infrastructure/services/cloud-deploy/cloudflare-pages.provider.ts index 640646226..ad19c5b1a 100644 --- a/packages/core/src/infrastructure/services/cloud-deploy/cloudflare-pages.provider.ts +++ b/packages/core/src/infrastructure/services/cloud-deploy/cloudflare-pages.provider.ts @@ -78,7 +78,6 @@ interface CloudflarePagesDeployment { export class CloudflarePagesProvider implements ICloudDeploymentProvider { readonly providerId = CloudDeploymentProvider.CloudflarePages; readonly displayName = 'Cloudflare Pages'; - readonly enabled = true; constructor( @inject('ICloudProviderTokensRepository') diff --git a/packages/core/src/infrastructure/services/cloud-deploy/gcp-cloud-run.provider.stub.ts b/packages/core/src/infrastructure/services/cloud-deploy/gcp-cloud-run.provider.stub.ts deleted file mode 100644 index 654fc4cd7..000000000 --- a/packages/core/src/infrastructure/services/cloud-deploy/gcp-cloud-run.provider.stub.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { injectable } from 'tsyringe'; - -import { CloudDeploymentProvider } from '../../../domain/generated/output.js'; -import { BaseProviderStub } from './base-provider-stub.js'; - -@injectable() -export class GcpCloudRunProviderStub extends BaseProviderStub { - readonly providerId = CloudDeploymentProvider.GcpCloudRun; - readonly displayName = 'Google Cloud Run'; -} diff --git a/packages/core/src/infrastructure/services/cloud-deploy/netlify.provider.stub.ts b/packages/core/src/infrastructure/services/cloud-deploy/netlify.provider.stub.ts deleted file mode 100644 index bc0e5d8b4..000000000 --- a/packages/core/src/infrastructure/services/cloud-deploy/netlify.provider.stub.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { injectable } from 'tsyringe'; - -import { CloudDeploymentProvider } from '../../../domain/generated/output.js'; -import { BaseProviderStub } from './base-provider-stub.js'; - -@injectable() -export class NetlifyProviderStub extends BaseProviderStub { - readonly providerId = CloudDeploymentProvider.Netlify; - readonly displayName = 'Netlify'; -} diff --git a/packages/core/src/infrastructure/services/cloud-deploy/vercel.provider.stub.ts b/packages/core/src/infrastructure/services/cloud-deploy/vercel.provider.stub.ts deleted file mode 100644 index a524110fa..000000000 --- a/packages/core/src/infrastructure/services/cloud-deploy/vercel.provider.stub.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { injectable } from 'tsyringe'; - -import { CloudDeploymentProvider } from '../../../domain/generated/output.js'; -import { BaseProviderStub } from './base-provider-stub.js'; - -@injectable() -export class VercelProviderStub extends BaseProviderStub { - readonly providerId = CloudDeploymentProvider.Vercel; - readonly displayName = 'Vercel'; -} diff --git a/packages/core/src/infrastructure/services/contributors/monthly-recap-watcher.service.ts b/packages/core/src/infrastructure/services/contributors/monthly-recap-watcher.service.ts deleted file mode 100644 index 681464184..000000000 --- a/packages/core/src/infrastructure/services/contributors/monthly-recap-watcher.service.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * Monthly Contributor Recap Watcher Service - * - * Time-driven companion to the contributor pipeline (spec 097, FR-31 / - * FR-32). Once per poll interval (default daily), checks whether the - * previous calendar month's recap has already been generated; if not, - * generates it via `GenerateMonthlyRecapUseCase` and fans the artifact - * out across the configured publish targets via - * `PublishMonthlyRecapUseCase`. Each channel publish is gated through - * `IContributorActionGate` inside the use case; the watcher trusts the - * gate to decide what actually goes out. - */ - -import { RecapChannel } from '../../../domain/generated/output.js'; -import type { GenerateMonthlyRecapUseCase } from '../../../application/use-cases/contributors/generate-monthly-recap.use-case.js'; -import type { - PublishMonthlyRecapUseCase, - PublishMonthlyRecapResult, -} from '../../../application/use-cases/contributors/publish-monthly-recap.use-case.js'; -import type { RecapTarget } from '../../../application/ports/output/services/recap-publisher.interface.js'; - -export const DEFAULT_RECAP_POLL_INTERVAL_MS = 24 * 60 * 60 * 1000; -const DEFAULT_TARGETS: readonly RecapTarget[] = [{ channel: RecapChannel.File }]; - -export interface MonthlyRecapWatcherDeps { - generate: GenerateMonthlyRecapUseCase; - publish: PublishMonthlyRecapUseCase; - /** Optional override of "now" — useful for deterministic tests. */ - now?: () => Date; - /** Optional overrideable storage of which months have already shipped. */ - recapAlreadyPublished?: (yearMonth: string) => Promise; - /** Optional override of publish targets; defaults to file-only. */ - targets?: readonly RecapTarget[]; - /** Optional poll interval (ms). */ - pollIntervalMs?: number; -} - -export class MonthlyRecapWatcherService { - private readonly deps: Required< - Pick - > & { recapAlreadyPublished: (ym: string) => Promise }; - private readonly publishedThisProcess = new Set(); - private intervalId: ReturnType | null = null; - - constructor(deps: MonthlyRecapWatcherDeps) { - this.deps = { - generate: deps.generate, - publish: deps.publish, - now: deps.now ?? (() => new Date()), - targets: deps.targets ?? DEFAULT_TARGETS, - pollIntervalMs: deps.pollIntervalMs ?? DEFAULT_RECAP_POLL_INTERVAL_MS, - recapAlreadyPublished: deps.recapAlreadyPublished ?? (async () => false), - }; - } - - isRunning(): boolean { - return this.intervalId !== null; - } - - start(): void { - if (this.intervalId !== null) return; - void this.poll(); - this.intervalId = setInterval(() => { - void this.poll(); - }, this.deps.pollIntervalMs); - } - - stop(): void { - if (this.intervalId !== null) { - clearInterval(this.intervalId); - this.intervalId = null; - } - } - - /** Exposed for tests: returns the publish result if a recap shipped. */ - async poll(): Promise { - const yearMonth = previousYearMonth(this.deps.now()); - if (this.publishedThisProcess.has(yearMonth)) return null; - try { - if (await this.deps.recapAlreadyPublished(yearMonth)) { - this.publishedThisProcess.add(yearMonth); - return null; - } - const { artifact } = await this.deps.generate.execute({ yearMonth }); - const result = await this.deps.publish.execute({ - artifact, - targets: this.deps.targets, - }); - this.publishedThisProcess.add(yearMonth); - return result; - } catch { - return null; - } - } -} - -/** - * Compute the UTC `YYYY-MM` of the calendar month immediately before - * the given `now`. The recap is always for the *previous* month so we - * are not running mid-month on partial data. - */ -export function previousYearMonth(now: Date): string { - const year = now.getUTCFullYear(); - const month = now.getUTCMonth(); - const prev = new Date(Date.UTC(year, month - 1, 1)); - const yyyy = prev.getUTCFullYear(); - const mm = String(prev.getUTCMonth() + 1).padStart(2, '0'); - return `${yyyy}-${mm}`; -} - -let watcherInstance: MonthlyRecapWatcherService | null = null; - -export function initializeMonthlyRecapWatcher(deps: MonthlyRecapWatcherDeps): void { - if (watcherInstance !== null) { - throw new Error('Monthly recap watcher already initialized. Cannot re-initialize.'); - } - watcherInstance = new MonthlyRecapWatcherService(deps); -} - -export function getMonthlyRecapWatcher(): MonthlyRecapWatcherService { - if (watcherInstance === null) { - throw new Error( - 'Monthly recap watcher not initialized. Call initializeMonthlyRecapWatcher() during web server startup.' - ); - } - return watcherInstance; -} - -export function hasMonthlyRecapWatcher(): boolean { - return watcherInstance !== null; -} - -export function resetMonthlyRecapWatcher(): void { - if (watcherInstance !== null) { - watcherInstance.stop(); - } - watcherInstance = null; -} diff --git a/packages/core/src/infrastructure/services/contributors/stale-good-first-issue-watcher.service.ts b/packages/core/src/infrastructure/services/contributors/stale-good-first-issue-watcher.service.ts deleted file mode 100644 index 58cbfc90e..000000000 --- a/packages/core/src/infrastructure/services/contributors/stale-good-first-issue-watcher.service.ts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * Stale Good-First-Issue Watcher Service - * - * Periodically polls every connected repository (with a `remoteUrl`) - * and dispatches `DetectStaleGoodFirstIssueUseCase`. The use case is - * pure — it returns the structured stale list — and the watcher's only - * job is to feed it the right `(owner, repo)` tuples and surface - * results via the desktop notifier so a maintainer notices. - * - * Time-driven side of spec 097, FR-42 (research decision 9). The split - * with GitHub Actions is deliberate: webhook-driven workflows (welcome, - * label-by-lane) live in `.github/workflows/`; cadence-driven workloads - * run inside the Shep daemon so adopting projects without an always-on - * Shep instance still get the inbound automation. - */ - -import type { IRepositoryRepository } from '../../../application/ports/output/repositories/repository-repository.interface.js'; -import type { IGitHubRepositoryService } from '../../../application/ports/output/services/github-repository-service.interface.js'; -import type { IDesktopNotifier } from '../../../application/ports/output/services/i-desktop-notifier.js'; -import type { DetectStaleGoodFirstIssueUseCase } from '../../../application/use-cases/contributors/detect-stale-good-first-issue.use-case.js'; - -export const DEFAULT_STALE_POLL_INTERVAL_MS = 24 * 60 * 60 * 1000; -const DEFAULT_STALE_DAYS = 30; - -export class StaleGoodFirstIssueWatcherService { - private readonly useCase: DetectStaleGoodFirstIssueUseCase; - private readonly repositoryRepo: IRepositoryRepository; - private readonly githubService: IGitHubRepositoryService; - private readonly notifier: IDesktopNotifier; - private readonly pollIntervalMs: number; - private readonly staleDays: number; - private intervalId: ReturnType | null = null; - - constructor( - useCase: DetectStaleGoodFirstIssueUseCase, - repositoryRepo: IRepositoryRepository, - githubService: IGitHubRepositoryService, - notifier: IDesktopNotifier, - pollIntervalMs: number = DEFAULT_STALE_POLL_INTERVAL_MS, - staleDays: number = DEFAULT_STALE_DAYS - ) { - this.useCase = useCase; - this.repositoryRepo = repositoryRepo; - this.githubService = githubService; - this.notifier = notifier; - this.pollIntervalMs = pollIntervalMs; - this.staleDays = staleDays; - } - - isRunning(): boolean { - return this.intervalId !== null; - } - - start(): void { - if (this.intervalId !== null) return; - void this.poll(); - this.intervalId = setInterval(() => { - void this.poll(); - }, this.pollIntervalMs); - } - - stop(): void { - if (this.intervalId !== null) { - clearInterval(this.intervalId); - this.intervalId = null; - } - } - - private async poll(): Promise { - let repositories; - try { - repositories = await this.repositoryRepo.list(); - } catch { - return; - } - - for (const repo of repositories) { - if (!repo.remoteUrl) continue; - let parsed; - try { - parsed = this.githubService.parseGitHubUrl(repo.remoteUrl); - } catch { - continue; - } - try { - const result = await this.useCase.execute({ - owner: parsed.owner, - repo: parsed.repo, - staleDays: this.staleDays, - }); - if (result.stale.length > 0) { - this.notifier.send( - 'Stale good-first-issues detected', - `${result.stale.length} good-first-issue(s) in ${parsed.owner}/${parsed.repo} are over ${result.thresholdDays} days old.` - ); - } - } catch { - // Per-repo failures are isolated; continue with other repos. - } - } - } -} - -let watcherInstance: StaleGoodFirstIssueWatcherService | null = null; - -export function initializeStaleGoodFirstIssueWatcher( - useCase: DetectStaleGoodFirstIssueUseCase, - repositoryRepo: IRepositoryRepository, - githubService: IGitHubRepositoryService, - notifier: IDesktopNotifier, - pollIntervalMs?: number, - staleDays?: number -): void { - if (watcherInstance !== null) { - throw new Error('Stale good-first-issue watcher already initialized. Cannot re-initialize.'); - } - watcherInstance = new StaleGoodFirstIssueWatcherService( - useCase, - repositoryRepo, - githubService, - notifier, - pollIntervalMs, - staleDays - ); -} - -export function getStaleGoodFirstIssueWatcher(): StaleGoodFirstIssueWatcherService { - if (watcherInstance === null) { - throw new Error( - 'Stale good-first-issue watcher not initialized. Call initializeStaleGoodFirstIssueWatcher() during web server startup.' - ); - } - return watcherInstance; -} - -export function hasStaleGoodFirstIssueWatcher(): boolean { - return watcherInstance !== null; -} - -export function resetStaleGoodFirstIssueWatcher(): void { - if (watcherInstance !== null) { - watcherInstance.stop(); - } - watcherInstance = null; -} diff --git a/scripts/check-stories.mjs b/scripts/check-stories.mjs index 414daddfa..266d8cdb1 100644 --- a/scripts/check-stories.mjs +++ b/scripts/check-stories.mjs @@ -37,14 +37,12 @@ const GRANDFATHERED = new Set([ 'src/presentation/web/components/common/editor-type-icons.tsx', 'src/presentation/web/components/common/error-boundary.tsx', 'src/presentation/web/components/features/application-page/app-overflow-menu.tsx', - 'src/presentation/web/components/features/application-page/cloud-provider-icons.tsx', 'src/presentation/web/components/features/application-page/delete-application-menu-item.tsx', 'src/presentation/web/components/features/application-page/ide-tab/editor-pane.tsx', 'src/presentation/web/components/features/application-page/ide-tab/file-tree-panel.tsx', 'src/presentation/web/components/features/application-page/ide-tab/image-viewer.tsx', 'src/presentation/web/components/features/application-page/operation-logs-drawer.tsx', 'src/presentation/web/components/features/application-page/operation-logs-icon-button.tsx', - 'src/presentation/web/components/features/application-page/provider-list.tsx', 'src/presentation/web/components/features/application-page/smart-deploy-cluster.tsx', 'src/presentation/web/components/features/application-page/smart-deploy-logs-drawer.tsx', 'src/presentation/web/components/features/application-page/terminal-tab-inner.tsx', diff --git a/specs/135-review-cleanup-cuts/feature.yaml b/specs/135-review-cleanup-cuts/feature.yaml new file mode 100644 index 000000000..66993e84c --- /dev/null +++ b/specs/135-review-cleanup-cuts/feature.yaml @@ -0,0 +1,45 @@ +feature: + id: '135-review-cleanup-cuts' + name: 'review-cleanup-cuts' + number: 135 + branch: 'feat/133-review-cleanup-cuts' + lifecycle: 'review' + createdAt: '2026-10-09T09:35:49Z' + +status: + phase: 'implementation-complete' + progress: + completed: 9 + total: 9 + percentage: 100 + currentTask: null + lastUpdated: '2026-10-09T10:00:00Z' + lastUpdatedBy: 'shep-kit:implement' + +validation: + lastRun: null + gatesPassed: [] + autoFixesApplied: [] + +tasks: + current: null + blocked: [] + failed: [] + +checkpoints: + - phase: 'feature-created' + completedAt: '2026-10-09T09:35:49Z' + completedBy: 'shep-kit:new-feature-fast' + - phase: 'research-complete' + completedAt: '2026-10-09T10:00:00Z' + completedBy: 'shep-kit:new-feature-fast' + - phase: 'plan-complete' + completedAt: '2026-10-09T10:00:00Z' + completedBy: 'shep-kit:new-feature-fast' + - phase: 'implementation-complete' + completedAt: '2026-10-09T12:30:00Z' + completedBy: 'shep-kit:implement' + +errors: + current: null + history: [] diff --git a/specs/135-review-cleanup-cuts/plan.md b/specs/135-review-cleanup-cuts/plan.md new file mode 100644 index 000000000..e14fe251d --- /dev/null +++ b/specs/135-review-cleanup-cuts/plan.md @@ -0,0 +1,45 @@ +## Architecture Overview + +``` +domain/shared/feature-flag-catalog.ts (Record) + │ +ListFeatureFlagsUseCase / SetFeatureFlagUseCase (ISettingsRepository) + │ │ +shep settings flags /settings/feature-flags page ─ FeatureFlagsList ─ settings page section +``` + +Gating reads `featureFlags.`: sidebar links carry `flag`, pages call `notFound()`, +API routes call `requireFeatureFlag`, CLI groups go through `gateByFeatureFlag`, and the +daemon's background-sync loops skip disabled areas. + +## Implementation Strategy + +Items are independent, so each lands as one commit in the order C1, C3, C4, C5, C7, C2. C3 +precedes C4 because the fold makes supply chain follow the ASPM default. C2 goes last since +it touches every settings fixture that C3/C4/C7 also touch. + +Clean Architecture holds throughout: the catalog lives in `domain/`, the list/set logic in +`application/` use cases, and CLI and web stay thin presentation over them. + +## Testing Strategy (TDD: Tests FIRST) + +Every task runs RED-GREEN-REFACTOR: the failing test lands first, then the minimal change, +then cleanup. + + +- Unit: defaults factory (aspm false, new flags true, no autoUpdate/supplyChainSecurity), + catalog completeness, list/set use cases, supply-chain helper, flag gate helper, sidebar + link visibility, CLI commands (contributors stale-issues/recap, settings flags, gated group). +- Integration: settings repository round-trip with non-default values for every new column; + migration 169 is idempotent and defaults to 1; legacy `aider` agent type reads back as the + default; DI bootstrap resolves the new use cases. +- Storybook: FeatureFlagsList stories; existing stories updated for removed props. + +## Risk Mitigation + +| Risk | Mitigation | +| ---- | ---------- | +| Shep's own CI security gate goes inert when ASPM defaults off | CI enables the aspm flag via `shep settings flags enable aspm` before enforcing | +| Users with persisted removed enum values crash on read | Mapper falls back to the default agent type | +| Migration collisions with parallel workstreams | Merge main and renumber before merge | +| Missing a settings column in repository SQL | Round-trip test with non-default values for every new flag | diff --git a/specs/135-review-cleanup-cuts/plan.yaml b/specs/135-review-cleanup-cuts/plan.yaml new file mode 100644 index 000000000..a55254140 --- /dev/null +++ b/specs/135-review-cleanup-cuts/plan.yaml @@ -0,0 +1,123 @@ +# Implementation Plan (YAML) +# This is the source of truth. Markdown is auto-generated from this file. + +name: review-cleanup-cuts +summary: Implementation plan for 135 — six cleanup items, one commit each. + +relatedFeatures: + - 097-contributor-pipeline + - 098-aspm +technologies: + - TypeSpec + - SQLite + - tsyringe + - Commander + - Next.js + - GitHub Actions +relatedLinks: [] + +phases: + - id: phase-1 + name: 'C1 — maintainer automation out of the daemon' + parallel: false + taskIds: [task-1] + - id: phase-2 + name: 'C3 + C4 — ASPM default off, supply chain folded in' + parallel: false + taskIds: [task-2, task-3] + - id: phase-3 + name: 'C5 + C7 — remove stubs and dead code' + parallel: false + taskIds: [task-4, task-5] + - id: phase-4 + name: 'C2 — per-area flags and the flags view' + parallel: false + taskIds: [task-6, task-7, task-8] + - id: phase-5 + name: 'Docs and verification' + parallel: false + taskIds: [task-9] + +filesToCreate: + - .github/workflows/contributor-maintenance.yml + - src/presentation/cli/commands/contributors/stale-issues.command.ts + - src/presentation/cli/commands/contributors/recap.command.ts + - src/presentation/cli/commands/aspm/supply-chain.command.ts + - packages/core/src/domain/shared/feature-flag-catalog.ts + - packages/core/src/domain/shared/supply-chain-security.ts + - packages/core/src/application/use-cases/settings/list-feature-flags.use-case.ts + - packages/core/src/application/use-cases/settings/set-feature-flag.use-case.ts + - packages/core/src/infrastructure/persistence/sqlite/migrations/169-add-software-factory-feature-flags.ts + - src/presentation/cli/commands/settings/flags.command.ts + - src/presentation/cli/commands/feature-flag-gate.ts + - src/presentation/web/app/settings/feature-flags/page.tsx + - src/presentation/web/components/features/settings/feature-flags-list.tsx + - src/presentation/web/components/features/settings/feature-flags-list.stories.tsx + - src/presentation/web/app/actions/set-feature-flag.ts + +filesToModify: + - src/presentation/cli/commands/_serve.command.ts + - src/presentation/web/components/layouts/app-sidebar/app-sidebar.tsx + - src/presentation/web/components/layouts/app-sidebar/sidebar-links.ts + - tsp/domain/entities/settings.tsp + - tsp/common/enums/agent-config.tsp + - tsp/common/enums/cloud-deployment.tsp + - packages/core/src/domain/factories/settings-defaults.factory.ts + - packages/core/src/infrastructure/persistence/sqlite/mappers/settings.mapper.ts + - packages/core/src/infrastructure/repositories/sqlite-settings.repository.ts + - packages/core/src/infrastructure/di/modules/register-cloud-deploy.ts + - packages/core/src/domain/shared/agent-catalog.ts + - src/presentation/web/lib/feature-flags.ts + - src/presentation/web/components/features/settings/settings-page-client.tsx + - src/presentation/cli/index.ts + - src/presentation/cli/commands/background-sync.ts + - .github/workflows/ci.yml + - ROADMAP.md + - translations/*/web.json + +content: | + ## Architecture Overview + + ``` + domain/shared/feature-flag-catalog.ts (Record) + │ + ListFeatureFlagsUseCase / SetFeatureFlagUseCase (ISettingsRepository) + │ │ + shep settings flags /settings/feature-flags page ─ FeatureFlagsList ─ settings page section + ``` + + Gating reads `featureFlags.`: sidebar links carry `flag`, pages call `notFound()`, + API routes call `requireFeatureFlag`, CLI groups go through `gateByFeatureFlag`, and the + daemon's background-sync loops skip disabled areas. + + ## Implementation Strategy + + Items are independent, so each lands as one commit in the order C1, C3, C4, C5, C7, C2. C3 + precedes C4 because the fold makes supply chain follow the ASPM default. C2 goes last since + it touches every settings fixture that C3/C4/C7 also touch. + + Clean Architecture holds throughout: the catalog lives in `domain/`, the list/set logic in + `application/` use cases, and CLI and web stay thin presentation over them. + + ## Testing Strategy (TDD: Tests FIRST) + + Every task runs RED-GREEN-REFACTOR: the failing test lands first, then the minimal change, + then cleanup. + + + - Unit: defaults factory (aspm false, new flags true, no autoUpdate/supplyChainSecurity), + catalog completeness, list/set use cases, supply-chain helper, flag gate helper, sidebar + link visibility, CLI commands (contributors stale-issues/recap, settings flags, gated group). + - Integration: settings repository round-trip with non-default values for every new column; + migration 169 is idempotent and defaults to 1; legacy `aider` agent type reads back as the + default; DI bootstrap resolves the new use cases. + - Storybook: FeatureFlagsList stories; existing stories updated for removed props. + + ## Risk Mitigation + + | Risk | Mitigation | + | ---- | ---------- | + | Shep's own CI security gate goes inert when ASPM defaults off | CI enables the aspm flag via `shep settings flags enable aspm` before enforcing | + | Users with persisted removed enum values crash on read | Mapper falls back to the default agent type | + | Migration collisions with parallel workstreams | Merge main and renumber before merge | + | Missing a settings column in repository SQL | Round-trip test with non-default values for every new flag | diff --git a/specs/135-review-cleanup-cuts/research.md b/specs/135-review-cleanup-cuts/research.md new file mode 100644 index 000000000..a4a9d7be3 --- /dev/null +++ b/specs/135-review-cleanup-cuts/research.md @@ -0,0 +1,14 @@ +## Technology Decisions + +See `decisions` above. Everything uses the existing stack; no new libraries. + +## Security Considerations + +- The contributor workflow uses only `GITHUB_TOKEN` with `issues: read`/`contents: write` + scoped to the job; it never runs on pull_request events. +- Supply-chain enforcement in Shep's own CI enables the ASPM flag explicitly before running, + so the release gate does not go inert when ASPM defaults off. + +## Performance Implications + +Removing two daily daemon watchers and gating the factory loops slightly reduces daemon work. diff --git a/specs/135-review-cleanup-cuts/research.yaml b/specs/135-review-cleanup-cuts/research.yaml new file mode 100644 index 000000000..9585b33bc --- /dev/null +++ b/specs/135-review-cleanup-cuts/research.yaml @@ -0,0 +1,82 @@ +# Research (YAML) +# This is the source of truth. Markdown is auto-generated from this file. + +name: review-cleanup-cuts +summary: Decisions for 135 — where maintainer automation runs, how supply chain folds into ASPM, and how flags are catalogued. + +relatedFeatures: + - 097-contributor-pipeline + - 098-aspm +technologies: + - TypeSpec + - SQLite + - GitHub Actions +relatedLinks: [] + +decisions: + - title: Maintainer automation runs as CLI subcommands driven by a scheduled workflow + chosen: >- + `shep contributors stale-issues` and `shep contributors recap` call the existing + DetectStaleGoodFirstIssueUseCase / GenerateMonthlyRecapUseCase / PublishMonthlyRecapUseCase; + `.github/workflows/contributor-maintenance.yml` runs them on a cron + rejected: + - 'A standalone Node script under scripts/: would need its own DI bootstrap, duplicating the CLI entry' + - 'Keep the watchers but behind a flag: still ships maintainer code paths into every daemon' + rationale: > + welcome-first-time-contributor.yml already runs `pnpm dev:cli contributors welcome-pr`, so + the same pattern needs no new bootstrap and the use cases stay the single implementation. + + - title: Fold supply-chain security into ASPM rather than remove it + chosen: >- + Drop the separate `supplyChainSecurity` flag; a domain helper derives "supply chain on" + from `featureFlags.aspm`; the CLI moves to `shep aspm supply-chain enforce` + rejected: + - 'Remove it: the security mode threads through executors, the feature-agent pre-check, spawn and CI (≈40 files), far from cheap' + - 'Keep both flags: leaves two overlapping security products' + rationale: > + Folding is a handful of call sites; the subsystem keeps working for users who use ASPM, and + users who never opted into ASPM stop paying for it. + + - title: Removed settings keep their DB columns + chosen: Stop reading and writing `sys_auto_update` and `feature_flag_supply_chain_security`; leave the columns (NOT NULL with defaults) + rejected: + - 'DROP COLUMN: breaks older branches and rollbacks (LESSONS: migrations must be backward compatible)' + rationale: Columns with defaults let INSERTs omit them and older builds still read them. + + - title: Persisted removed enum values fall back at the mapper + chosen: The settings mapper maps an unknown persisted agent type (`aider`, `continue`) to the default agent + rejected: + - 'A data migration rewriting the values: mutates user data for agents nobody could select' + rationale: Fallbacks belong at the lowest level (LESSONS) and leave the DB untouched. + + - title: One domain catalog drives the flags view and CLI + chosen: '`FEATURE_FLAG_CATALOG: Record` in domain/shared with group and one-line description' + rejected: + - 'Hand-written SwitchRows per flag: the settings page already has 13 and is 2.3k lines' + rationale: > + A total Record makes a missing catalog entry a compile error, and the web view, the settings + section and `shep settings flags` all render the same list through ListFeatureFlagsUseCase. + + - title: CLI groups gated by hiding plus a pre-action guard + chosen: A shared `gateByFeatureFlag(command, flag)` hides the group from help and refuses to run it with an "enable it with shep settings flags enable" hint + rejected: + - 'Not registering the command: users get "unknown command" with no hint' + rationale: Same visibility pattern as the harness group, with an actionable error. + +openQuestions: [] + +content: | + ## Technology Decisions + + See `decisions` above. Everything uses the existing stack; no new libraries. + + ## Security Considerations + + - The contributor workflow uses only `GITHUB_TOKEN` with `issues: read`/`contents: write` + scoped to the job; it never runs on pull_request events. + - Supply-chain enforcement in Shep's own CI enables the ASPM flag explicitly before running, + so the release gate does not go inert when ASPM defaults off. + + ## Performance Implications + + Removing two daily daemon watchers and gating the factory loops slightly reduces daemon work. diff --git a/specs/135-review-cleanup-cuts/spec.md b/specs/135-review-cleanup-cuts/spec.md new file mode 100644 index 000000000..dac6f22ab --- /dev/null +++ b/specs/135-review-cleanup-cuts/spec.md @@ -0,0 +1,48 @@ +## Problem Statement + +The product review found Shep shipping things users did not ask for and cannot see or turn +off: maintainer dogfooding (stale good-first-issue and monthly recap watchers) runs inside +every user's daemon, ASPM is "behind a flag" that defaults on, supply-chain security +duplicates ASPM, four of five cloud-deploy providers are "coming soon" stubs, dead settings +and placeholder agents linger, and the twelve software-factory areas from specs 120–132 +landed with no flag at all. There is also no single place to see which flags exist. + +## Success Criteria + +- [ ] `shep _serve` no longer starts the stale good-first-issue or monthly recap watcher; a + scheduled workflow in `.github/workflows/` runs both through `shep contributors` + subcommands that reuse the existing use cases. `/onboarding` leaves the end-user sidebar. +- [ ] A fresh install has `featureFlags.aspm === false`; persisted values are not rewritten; + ROADMAP.md says ASPM is off by default. +- [ ] Supply-chain security has no flag of its own: every surface follows `featureFlags.aspm`, + the CLI lives under `shep aspm supply-chain` (with `shep security` kept as a hidden + alias), and no migration drops data. +- [ ] The Vercel, Netlify, AWS Amplify and GCP Cloud Run stubs, enum members and + "coming soon" UI are gone; Cloudflare Pages still deploys. +- [ ] `system.autoUpdate` and the Aider/Continue agent types are gone from TypeSpec and code; + their DB columns stay; persisted `aider`/`continue` values read back as the default agent. +- [ ] Twelve new flags (spaces, trackers, knowledge, signals, opportunities, feedback, + discovery, incidents, outcomes, docsFirst, autopilot, factory), default on, gate their + nav entries, pages, API routes, CLI groups and daemon loops; `/settings/feature-flags` + and `shep settings flags` list every flag with a one-line description and toggle it. + +## Affected Areas + +| Area | Impact | Reasoning | +| ---- | ------ | --------- | +| `tsp/domain/entities/settings.tsp`, `tsp/common/enums/*` | High | Flags added and removed, enum members removed | +| Settings persistence (mapper, repository, migrations) | High | New flag columns; removed fields stay as read-ignored columns | +| `src/presentation/cli` | Medium | `_serve`, contributors, aspm, settings flags, factory group gating | +| `src/presentation/web` | Medium | Sidebar, factory pages, settings page, new flags view, deploy UI | +| `packages/core` cloud-deploy, agent catalog, feature agent | Medium | Stub removal, catalog entries, supply-chain gate | +| `.github/workflows` | Low | New contributor maintenance workflow; CI security gate path | + +## Dependencies + +None blocking. Two parallel workstreams (telemetry, unified decisions) may add migrations; +migration numbers are re-checked against `main` before merge. + +## Size Estimate + +**L** — six independent items touching TypeSpec, persistence, CLI, web and CI, each small to +medium, with the flag work fanning out across every settings fixture. diff --git a/specs/135-review-cleanup-cuts/spec.yaml b/specs/135-review-cleanup-cuts/spec.yaml new file mode 100644 index 000000000..f58c0715e --- /dev/null +++ b/specs/135-review-cleanup-cuts/spec.yaml @@ -0,0 +1,97 @@ +# Feature Specification (YAML) +# This is the source of truth. Markdown is auto-generated from this file. + +name: review-cleanup-cuts +number: 135 +branch: feat/133-review-cleanup-cuts +oneLiner: >- + Cut what the T3 Code product review flagged: maintainer automation leaves users' daemons, + ASPM defaults off and absorbs supply-chain security, cloud-deploy stubs and dead settings go, + and every software-factory area gets its own flag in a dedicated feature-flags view +summary: > + One cleanup PR with the items the product owner agreed to after the T3 Code review + (docs/competitors/t3code.md, "Where Shep Is Today and What to Cut"). Each item is one commit: + C1 contributor watchers move to a scheduled GitHub Action, C3 the ASPM flag defaults off, + C4 supply-chain security folds into ASPM, C5 the four cloud-deploy stub providers are removed, + C7 dead settings and placeholder agents are removed, and C2 adds a flag per software-factory + area plus a web view and CLI that list and toggle every flag. +phase: Requirements +sizeEstimate: L + +relatedFeatures: + - 097-contributor-pipeline + - 098-aspm + - 120-spaces-and-product-lines + - 122-tracker-sync + - 125-knowledge-sources + - 126-opportunities + - 127-feedback-themes + - 128-discovery + - 129-incidents + - 130-outcomes + - 131-docs-first + - 132-autopilot + +technologies: + - TypeSpec + - SQLite + - tsyringe + - Commander + - Next.js + - GitHub Actions + +relatedLinks: + - title: T3 Code vs Shep review + url: https://github.com/shep-ai/shep/blob/claude/gifted-bardeen-os90j0/docs/competitors/t3code.md + +openQuestions: [] + +content: | + ## Problem Statement + + The product review found Shep shipping things users did not ask for and cannot see or turn + off: maintainer dogfooding (stale good-first-issue and monthly recap watchers) runs inside + every user's daemon, ASPM is "behind a flag" that defaults on, supply-chain security + duplicates ASPM, four of five cloud-deploy providers are "coming soon" stubs, dead settings + and placeholder agents linger, and the twelve software-factory areas from specs 120–132 + landed with no flag at all. There is also no single place to see which flags exist. + + ## Success Criteria + + - [ ] `shep _serve` no longer starts the stale good-first-issue or monthly recap watcher; a + scheduled workflow in `.github/workflows/` runs both through `shep contributors` + subcommands that reuse the existing use cases. `/onboarding` leaves the end-user sidebar. + - [ ] A fresh install has `featureFlags.aspm === false`; persisted values are not rewritten; + ROADMAP.md says ASPM is off by default. + - [ ] Supply-chain security has no flag of its own: every surface follows `featureFlags.aspm`, + the CLI lives under `shep aspm supply-chain` (with `shep security` kept as a hidden + alias), and no migration drops data. + - [ ] The Vercel, Netlify, AWS Amplify and GCP Cloud Run stubs, enum members and + "coming soon" UI are gone; Cloudflare Pages still deploys. + - [ ] `system.autoUpdate` and the Aider/Continue agent types are gone from TypeSpec and code; + their DB columns stay; persisted `aider`/`continue` values read back as the default agent. + - [ ] Twelve new flags (spaces, trackers, knowledge, signals, opportunities, feedback, + discovery, incidents, outcomes, docsFirst, autopilot, factory), default on, gate their + nav entries, pages, API routes, CLI groups and daemon loops; `/settings/feature-flags` + and `shep settings flags` list every flag with a one-line description and toggle it. + + ## Affected Areas + + | Area | Impact | Reasoning | + | ---- | ------ | --------- | + | `tsp/domain/entities/settings.tsp`, `tsp/common/enums/*` | High | Flags added and removed, enum members removed | + | Settings persistence (mapper, repository, migrations) | High | New flag columns; removed fields stay as read-ignored columns | + | `src/presentation/cli` | Medium | `_serve`, contributors, aspm, settings flags, factory group gating | + | `src/presentation/web` | Medium | Sidebar, factory pages, settings page, new flags view, deploy UI | + | `packages/core` cloud-deploy, agent catalog, feature agent | Medium | Stub removal, catalog entries, supply-chain gate | + | `.github/workflows` | Low | New contributor maintenance workflow; CI security gate path | + + ## Dependencies + + None blocking. Two parallel workstreams (telemetry, unified decisions) may add migrations; + migration numbers are re-checked against `main` before merge. + + ## Size Estimate + + **L** — six independent items touching TypeSpec, persistence, CLI, web and CI, each small to + medium, with the flag work fanning out across every settings fixture. diff --git a/specs/135-review-cleanup-cuts/tasks.md b/specs/135-review-cleanup-cuts/tasks.md new file mode 100644 index 000000000..76e6533bd --- /dev/null +++ b/specs/135-review-cleanup-cuts/tasks.md @@ -0,0 +1,8 @@ +## Summary + +Nine tasks; tasks 6–8 form the C2 commit, every other item is its own commit. + +## Acceptance Checklist + +- [ ] C1, C3, C4, C5, C7, C2 each land as one commit +- [ ] pnpm validate, unit, int, build and storybook build pass diff --git a/specs/135-review-cleanup-cuts/tasks.yaml b/specs/135-review-cleanup-cuts/tasks.yaml new file mode 100644 index 000000000..a8fad7250 --- /dev/null +++ b/specs/135-review-cleanup-cuts/tasks.yaml @@ -0,0 +1,201 @@ +# Task Breakdown (YAML) +# This is the source of truth. Markdown is auto-generated from this file. + +name: review-cleanup-cuts +summary: "Task breakdown for 135 \u2014 six cleanup items from the T3 Code review." +relatedFeatures: +- 097-contributor-pipeline +- 098-aspm +technologies: +- TypeSpec +- SQLite +- GitHub Actions +relatedLinks: [] +tasks: +- id: task-1 + title: Move contributor watchers to a scheduled GitHub Action + description: Remove the stale good-first-issue and monthly recap watchers from _serve and delete the + watcher services; add `shep contributors stale-issues` and `shep contributors recap` subcommands and + a scheduled workflow; remove /onboarding from the sidebar. + state: Done + dependencies: [] + acceptanceCriteria: + - _serve starts neither watcher + - contributor-maintenance.yml runs both subcommands on a cron + - Sidebar has no Get started entry + tdd: + red: + - Test that stale-issues prints stale issues for --repo and exits 0 + - Test that recap generates the previous month and publishes to the file channel + - Sidebar test asserts no /onboarding link + green: + - Subcommands calling the existing use cases + - Workflow file + - Remove watcher wiring and sidebar item + refactor: + - Remove duplication and dead imports + estimatedEffort: M +- id: task-2 + title: Default the ASPM flag to off + description: Set aspm to false in TypeSpec, the defaults factory and the web env fallback; leave persisted + values alone; update ROADMAP.md. + state: Done + dependencies: [] + acceptanceCriteria: + - createDefaultSettings().featureFlags.aspm is false + - A persisted aspm=1 row still reads true + - ROADMAP states ASPM is off by default + tdd: + red: + - Defaults factory test expects aspm false + - Repository round-trip keeps persisted true + green: + - Flip the defaults + refactor: + - Remove duplication and dead imports + estimatedEffort: S +- id: task-3 + title: Fold supply-chain security into ASPM + description: Remove the supplyChainSecurity flag; derive supply-chain enablement from aspm via a domain + helper; move the CLI to `shep aspm supply-chain enforce` with a hidden `shep security` alias; CI enables + aspm before enforcing. + state: Done + dependencies: + - task-2 + acceptanceCriteria: + - No supplyChainSecurity in TypeSpec or code outside the legacy migration + - Agent pre-check, canvas badge, settings section and CLI follow aspm + - Column feature_flag_supply_chain_security still exists + tdd: + red: + - 'Helper test: aspm on/off' + - security pre-check test uses the helper + - 'CLI test: enforce under aspm supply-chain is a no-op when aspm is off' + green: + - Helper + call-site swap + - CLI move and alias + refactor: + - Remove duplication and dead imports + estimatedEffort: M +- id: task-4 + title: Remove cloud-deploy stub providers + description: Delete the four *.provider.stub.ts files, the base stub, their DI registrations, enum members, + icons and coming-soon UI. + state: Done + dependencies: [] + acceptanceCriteria: + - Only CloudflarePages remains in CloudDeploymentProvider + - No coming-soon provider UI + - Cloudflare deploy tests still pass + tdd: + red: + - Provider list tests expect only Cloudflare + green: + - Delete stubs and UI + refactor: + - Remove duplication and dead imports + estimatedEffort: S +- id: task-5 + title: Remove dead settings and placeholder agents + description: Remove system.autoUpdate and the Aider/Continue agent types; keep sys_auto_update; map + unknown persisted agent types to the default. + state: Done + dependencies: [] + acceptanceCriteria: + - No autoUpdate in TypeSpec/defaults/mapper + - No Aider/Continue in AgentType or the catalog + - A settings row with agent_type 'aider' reads back as the default agent + tdd: + red: + - Mapper test for legacy agent type fallback + - Defaults test has no autoUpdate + green: + - Remove fields and members + refactor: + - Remove duplication and dead imports + estimatedEffort: S +- id: task-6 + title: Add per-area software-factory flags + description: Twelve flags in TypeSpec, defaults (true), migration 169 (DEFAULT 1), mapper, repository + SQL and web flags state. + state: Done + dependencies: [] + acceptanceCriteria: + - Round-trip persists false for every new flag + - Fresh install has every new flag true + tdd: + red: + - Repository round-trip with all twelve false + - Migration 169 idempotency test + green: + - Wire the flags + refactor: + - Remove duplication and dead imports + estimatedEffort: S +- id: task-7 + title: Gate software-factory surfaces + description: Sidebar links, pages, API routes, CLI groups and background-sync loops check their flag. + state: Done + dependencies: + - task-6 + acceptanceCriteria: + - Each area's nav link hides when its flag is off + - Each page 404s when its flag is off + - Each CLI group is hidden and refuses to run when its flag is off + - Daemon loops skip disabled areas + tdd: + red: + - Sidebar visibility test per flag + - gateByFeatureFlag test + - background-sync gating test + green: + - Add flag to links, pages, commands and loops + refactor: + - Remove duplication and dead imports + estimatedEffort: M +- id: task-8 + title: Feature-flags view and CLI + description: Domain catalog, ListFeatureFlagsUseCase/SetFeatureFlagUseCase, /settings/feature-flags + page, FeatureFlagsList component (also used in the settings page), `shep settings flags [enable|disable + ]`. + state: Done + dependencies: + - task-6 + acceptanceCriteria: + - Every FeatureFlags key has a catalog entry (compile-time) + - The page lists every flag with a description and toggle and is linked from Settings + - shep settings flags lists and toggles flags + tdd: + red: + - Use case tests + - CLI command test + - Component test for toggling + green: + - Implement view, component, action, CLI + refactor: + - Remove duplication and dead imports + estimatedEffort: M +- id: task-9 + title: Docs and full verification + description: Update docs that mention removed surfaces; run validate, unit, int, build, storybook and + check:stories. + state: Done + dependencies: + - task-1 + - task-2 + - task-3 + - task-4 + - task-5 + - task-6 + - task-7 + - task-8 + acceptanceCriteria: + - All local CI checks pass + - Docs no longer reference removed providers, flags or settings + tdd: null + estimatedEffort: S +totalEstimate: L +openQuestions: [] +content: "## Summary\n\nNine tasks; tasks 6\u20138 form the C2 commit, every other item is its own commit.\n\ + \n## Acceptance Checklist\n\n- [ ] C1, C3, C4, C5, C7, C2 each land as one commit\n- [ ] pnpm validate,\ + \ unit, int, build and storybook build pass\n" diff --git a/src/presentation/cli/commands/_serve.command.ts b/src/presentation/cli/commands/_serve.command.ts index d7417d804..8d54b58b7 100644 --- a/src/presentation/cli/commands/_serve.command.ts +++ b/src/presentation/cli/commands/_serve.command.ts @@ -37,26 +37,12 @@ import { initializeAutoArchiveWatcher, getAutoArchiveWatcher, } from '@/infrastructure/services/auto-archive/auto-archive-watcher.service.js'; -import { - initializeStaleGoodFirstIssueWatcher, - getStaleGoodFirstIssueWatcher, -} from '@/infrastructure/services/contributors/stale-good-first-issue-watcher.service.js'; -import { - initializeMonthlyRecapWatcher, - getMonthlyRecapWatcher, -} from '@/infrastructure/services/contributors/monthly-recap-watcher.service.js'; -import { DetectStaleGoodFirstIssueUseCase } from '@/application/use-cases/contributors/detect-stale-good-first-issue.use-case.js'; -import { GenerateMonthlyRecapUseCase } from '@/application/use-cases/contributors/generate-monthly-recap.use-case.js'; -import { PublishMonthlyRecapUseCase } from '@/application/use-cases/contributors/publish-monthly-recap.use-case.js'; import type { IVersionService } from '@/application/ports/output/services/version-service.interface.js'; import type { IWebServerService } from '@/application/ports/output/services/web-server-service.interface.js'; import type { IAgentRunRepository } from '@/application/ports/output/agents/agent-run-repository.interface.js'; import type { IPhaseTimingRepository } from '@/application/ports/output/agents/phase-timing-repository.interface.js'; import type { INotificationService } from '@/application/ports/output/services/notification-service.interface.js'; import type { IFeatureRepository } from '@/application/ports/output/repositories/feature-repository.interface.js'; -import type { IRepositoryRepository } from '@/application/ports/output/repositories/repository-repository.interface.js'; -import type { IGitHubRepositoryService } from '@/application/ports/output/services/github-repository-service.interface.js'; -import type { IDesktopNotifier } from '@/application/ports/output/services/i-desktop-notifier.js'; import type { IDeploymentService } from '@/application/ports/output/services/deployment-service.interface.js'; import { DaemonLogRotator } from '@/infrastructure/services/logging/daemon-log-rotator.js'; import { getDaemonLogPath } from '@/infrastructure/services/filesystem/shep-directory.service.js'; @@ -131,25 +117,6 @@ export function createServeCommand(): Command { initializeAutoArchiveWatcher(featureRepo); getAutoArchiveWatcher().start(); - // Start contributor pipeline watchers (spec 097, FR-42) - const repositoryRepo = container.resolve('IRepositoryRepository'); - const githubRepoService = container.resolve( - 'IGitHubRepositoryService' - ); - const desktopNotifier = container.resolve('IDesktopNotifier'); - initializeStaleGoodFirstIssueWatcher( - container.resolve(DetectStaleGoodFirstIssueUseCase), - repositoryRepo, - githubRepoService, - desktopNotifier - ); - getStaleGoodFirstIssueWatcher().start(); - initializeMonthlyRecapWatcher({ - generate: container.resolve(GenerateMonthlyRecapUseCase), - publish: container.resolve(PublishMonthlyRecapUseCase), - }); - getMonthlyRecapWatcher().start(); - // Start WhatsApp connection service (spec 101) — no-op unless the // whatsappDispatch flag is on AND the integration is enabled. const whatsappService = container.resolve<{ @@ -194,8 +161,6 @@ export function createServeCommand(): Command { backgroundSync.stop(); getNotificationWatcher().stop(); getAutoArchiveWatcher().stop(); - getStaleGoodFirstIssueWatcher().stop(); - getMonthlyRecapWatcher().stop(); void whatsappService.stop(); await messagingService.stop(); const deploymentService = container.resolve('IDeploymentService'); diff --git a/src/presentation/cli/commands/app/cloud-providers/connect.command.ts b/src/presentation/cli/commands/app/cloud-providers/connect.command.ts index 03a45dde9..cc0782932 100644 --- a/src/presentation/cli/commands/app/cloud-providers/connect.command.ts +++ b/src/presentation/cli/commands/app/cloud-providers/connect.command.ts @@ -10,22 +10,9 @@ import { Command } from 'commander'; import { password } from '@inquirer/prompts'; import { container } from '@/infrastructure/di/container.js'; import { ConnectCloudProviderUseCase } from '@/application/use-cases/cloud-deploy/connect-cloud-provider.use-case.js'; -import { - CloudDeploymentProvider, - type CloudDeploymentProvider as CloudDeploymentProviderType, -} from '@/domain/generated/output.js'; -import { ProviderNotImplementedError } from '@/domain/errors/provider-not-implemented.error.js'; +import { parseCloudDeploymentProvider } from '@/domain/shared/cloud-deployment-provider.js'; import { messages, colors } from '../../../ui/index.js'; -function parseProvider(raw: string): CloudDeploymentProviderType | null { - const lower = raw.toLowerCase(); - const allowed = Object.values(CloudDeploymentProvider); - for (const id of allowed) { - if (id.toLowerCase() === lower) return id; - } - return null; -} - export function createCloudProvidersConnectCommand(): Command { return new Command('connect') .description('Connect a cloud deployment provider with an API token') @@ -33,7 +20,7 @@ export function createCloudProvidersConnectCommand(): Command { .option('--token ', 'API token (if omitted, prompts securely)') .action(async (providerArg: string, options: { token?: string }) => { try { - const provider = parseProvider(providerArg); + const provider = parseCloudDeploymentProvider(providerArg, { ignoreCase: true }); if (!provider) { messages.error( `Unknown provider: ${providerArg}. Run \`shep app cloud-providers ls\` to see the list.` @@ -59,11 +46,6 @@ export function createCloudProvidersConnectCommand(): Command { await useCase.execute({ provider, token: token.trim() }); process.stdout.write(colors.success(`✓ ${provider} connected\n`)); } catch (error) { - if (error instanceof ProviderNotImplementedError) { - messages.error(`${error.provider} is not yet implemented — coming soon`); - process.exitCode = 2; - return; - } const err = error instanceof Error ? error : new Error(String(error)); messages.error('Failed to connect provider', err); process.exitCode = 1; diff --git a/src/presentation/cli/commands/app/cloud-providers/ls.command.ts b/src/presentation/cli/commands/app/cloud-providers/ls.command.ts index 82f3641d4..22aee2a18 100644 --- a/src/presentation/cli/commands/app/cloud-providers/ls.command.ts +++ b/src/presentation/cli/commands/app/cloud-providers/ls.command.ts @@ -25,13 +25,10 @@ export function createCloudProvidersLsCommand(): Command { process.stdout.write('\nCloud deployment providers:\n\n'); for (const provider of providers) { - const status = !provider.enabled - ? colors.muted('coming soon') - : provider.connected - ? colors.success('connected') - : colors.warning('not connected'); - const marker = provider.enabled ? '●' : '○'; - process.stdout.write(` ${marker} ${provider.displayName.padEnd(20)} ${status}\n`); + const status = provider.connected + ? colors.success('connected') + : colors.warning('not connected'); + process.stdout.write(` ● ${provider.displayName.padEnd(20)} ${status}\n`); } process.stdout.write('\n'); } catch (error) { diff --git a/src/presentation/cli/commands/app/deploy/initiate.command.ts b/src/presentation/cli/commands/app/deploy/initiate.command.ts index e9f358100..3cc14f97d 100644 --- a/src/presentation/cli/commands/app/deploy/initiate.command.ts +++ b/src/presentation/cli/commands/app/deploy/initiate.command.ts @@ -11,22 +11,11 @@ import { container } from '@/infrastructure/di/container.js'; import { InitiateCloudDeploymentUseCase } from '@/application/use-cases/cloud-deploy/initiate-cloud-deployment.use-case.js'; import { SelectCloudProviderUseCase } from '@/application/use-cases/cloud-deploy/select-cloud-provider.use-case.js'; import type { ICloudDeploymentEventBus } from '@/application/ports/output/services/cloud-deployment-event-bus.interface.js'; -import { - CloudDeploymentProvider, - CloudDeploymentStatus, - type CloudDeploymentProvider as CloudDeploymentProviderType, -} from '@/domain/generated/output.js'; +import { CloudDeploymentStatus } from '@/domain/generated/output.js'; +import { parseCloudDeploymentProvider } from '@/domain/shared/cloud-deployment-provider.js'; import { messages, colors } from '../../../ui/index.js'; import { resolveApplication } from '../resolve-application.js'; -function parseProvider(raw: string): CloudDeploymentProviderType | null { - const lower = raw.toLowerCase(); - for (const id of Object.values(CloudDeploymentProvider)) { - if (id.toLowerCase() === lower) return id; - } - return null; -} - function labelForStatus(status: CloudDeploymentStatus): string { switch (status) { case CloudDeploymentStatus.Uploading: @@ -60,7 +49,7 @@ export function createDeployInitiateCommand(): Command { // Optional provider override — select first so the use case picks // the right adapter, matching the web flow. if (options.provider) { - const provider = parseProvider(options.provider); + const provider = parseCloudDeploymentProvider(options.provider, { ignoreCase: true }); if (!provider) { messages.error(`Unknown provider: ${options.provider}`); process.exitCode = 1; diff --git a/src/presentation/cli/commands/aspm/index.ts b/src/presentation/cli/commands/aspm/index.ts index 1d1e45955..9622580df 100644 --- a/src/presentation/cli/commands/aspm/index.ts +++ b/src/presentation/cli/commands/aspm/index.ts @@ -7,16 +7,13 @@ * container — the command modules themselves stay thin (parse → * use-case-call → formatted output). * - * The entire surface is gated behind the `aspm` feature flag. When the - * flag is off the command is registered but hidden from `--help` and - * any invocation prints a one-liner pointing to `shep settings`. Mirrors - * the supervisor command's collaboration-flag pattern so byte-identical - * default CLI output is preserved unless the user opts in. + * The entire surface is gated behind the `aspm` feature flag (off by + * default): while it is off the command is hidden from `--help` and any + * invocation says how to turn it on (see feature-flag-gate.ts). */ import { Command } from 'commander'; -import { getSettings, hasSettings } from '@/infrastructure/services/settings.service.js'; -import { messages } from '../../ui/index.js'; +import { gateByFeatureFlag } from '../feature-flag-gate.js'; import { createAspmIngestCommand } from './aspm-ingest-command.js'; import { createAspmFindingsCommand } from './aspm-findings-command.js'; import { createAspmCampaignsCommand } from './aspm-campaigns-command.js'; @@ -25,20 +22,6 @@ import { createAspmExceptionsCommand } from './aspm-exceptions-command.js'; import { createAspmAiReviewCommand } from './aspm-ai-review-command.js'; import { createAspmScanCommand, createAspmRescanCommand } from './aspm-scan-command.js'; -function isAspmEnabled(): boolean { - if (!hasSettings()) return false; - return getSettings().featureFlags?.aspm === true; -} - -/** Printed for any `shep aspm` invocation while the feature flag is off. */ -const ASPM_DISABLED_MESSAGE = - 'The ASPM module is disabled. Enable the "aspm" feature flag in settings to use `shep aspm`.'; - -function blockAspm(): never { - messages.error(ASPM_DISABLED_MESSAGE); - process.exit(1); -} - export function createAspmCommand(): Command { const cmd = new Command('aspm').description( 'Application Security Posture Management — findings, campaigns, posture, exceptions, AI-review' @@ -53,18 +36,5 @@ export function createAspmCommand(): Command { cmd.addCommand(createAspmExceptionsCommand()); cmd.addCommand(createAspmAiReviewCommand()); - if (!isAspmEnabled()) { - // Hide the surface from --help and short-circuit any invocation so - // the default CLI output is unchanged for users who haven't opted in. - (cmd as unknown as { _hidden: boolean })._hidden = true; - - // `preAction` only fires once a subcommand's own action runs, so a bare - // `shep aspm` used to fall through to Commander's default help — which - // lists every subcommand and defeats the flag entirely. Giving the parent - // its own action puts the no-subcommand invocation on the same blocked path. - cmd.hook('preAction', blockAspm); - cmd.action(blockAspm); - } - - return cmd; + return gateByFeatureFlag(cmd, 'aspm'); } diff --git a/src/presentation/cli/commands/background-sync.ts b/src/presentation/cli/commands/background-sync.ts index b401427ba..0162c1e84 100644 --- a/src/presentation/cli/commands/background-sync.ts +++ b/src/presentation/cli/commands/background-sync.ts @@ -3,6 +3,8 @@ * the `shep start` daemon (`_serve`) and `shep ui` alike: * * - data retention (spec 116) + * Software-factory passes skip themselves while their feature flag is off. + * * - Linear and Jira sync rules (spec 122) * - Notion knowledge sources (spec 125) * - scheduled discovery runs (spec 128) @@ -45,6 +47,19 @@ import type { IGitPrService } from '@/application/ports/output/services/git-pr-s import type { IGitForkService } from '@/application/ports/output/services/git-fork-service.interface.js'; import type { ILogger } from '@/application/ports/output/services/logger.interface.js'; import type { ITelemetry } from '@/application/ports/output/services/telemetry.interface.js'; +import type { FeatureFlagKey } from '@/domain/shared/feature-flag-catalog.js'; +import { isFeatureFlagOn } from './feature-flag-gate.js'; + +/** + * Runs `job` only while `flag` is on. Checked on every pass, so turning an + * area off in the web UI stops its pass without a restart (spec 135). + */ +function whenOn( + flag: FeatureFlagKey, + job: (...args: Args) => Promise +): (...args: Args) => Promise { + return (...args) => (isFeatureFlagOn(flag) ? job(...args) : Promise.resolve()); +} export interface BackgroundSync { stop(): void; @@ -63,16 +78,21 @@ export function startBackgroundSync(label: string): BackgroundSync { report('data retention prune') ); const trackers = createDueWorkWatcher( - (now) => container.resolve('SyncTrackerRulesUseCase').runDue(now), + whenOn('trackers', (now: Date) => + container.resolve('SyncTrackerRulesUseCase').runDue(now) + ), report('tracker sync') ); const knowledge = createDueWorkWatcher( - (now) => - container.resolve('SyncKnowledgeSourcesUseCase').runDue(now), + whenOn('knowledge', (now: Date) => + container.resolve('SyncKnowledgeSourcesUseCase').runDue(now) + ), report('knowledge sync') ); const discovery = createDueWorkWatcher( - (now) => container.resolve('SyncDiscoveryUseCase').runDue(now), + whenOn('discovery', (now: Date) => + container.resolve('SyncDiscoveryUseCase').runDue(now) + ), report('discovery') ); const prComments = createPrCommentWatcher( @@ -80,11 +100,13 @@ export function startBackgroundSync(label: string): BackgroundSync { report('PR comment sync') ); const outcomes = createHourlyWatcher( - () => container.resolve('TrackOutcomesUseCase').run(), + whenOn('outcomes', () => container.resolve('TrackOutcomesUseCase').run()), report('outcome tracking') ); const autopilot = createHourlyWatcher( - () => container.resolve('RunAutopilotUseCase').runAll(), + whenOn('autopilot', () => + container.resolve('RunAutopilotUseCase').runAll() + ), report('autopilot') ); const telemetry = createTelemetryFlushWatcher( diff --git a/src/presentation/cli/commands/contributors/index.ts b/src/presentation/cli/commands/contributors/index.ts index 90365633b..539879154 100644 --- a/src/presentation/cli/commands/contributors/index.ts +++ b/src/presentation/cli/commands/contributors/index.ts @@ -1,10 +1,14 @@ import { Command } from 'commander'; import { createWelcomePrCommand } from './welcome-pr.command.js'; import { createGroomIssueCommand } from './groom-issue.command.js'; +import { createStaleIssuesCommand } from './stale-issues.command.js'; +import { createRecapCommand } from './recap.command.js'; export function createContributorsCommand(): Command { return new Command('contributors') .description('Contributor pipeline subcommands (entry points for GitHub Actions workflows).') .addCommand(createWelcomePrCommand()) - .addCommand(createGroomIssueCommand()); + .addCommand(createGroomIssueCommand()) + .addCommand(createStaleIssuesCommand()) + .addCommand(createRecapCommand()); } diff --git a/src/presentation/cli/commands/contributors/recap.command.ts b/src/presentation/cli/commands/contributors/recap.command.ts new file mode 100644 index 000000000..5f14a89e9 --- /dev/null +++ b/src/presentation/cli/commands/contributors/recap.command.ts @@ -0,0 +1,67 @@ +/** + * `shep contributors recap` — generates a month's contributor recap and + * publishes it to `recaps/.md`. Run by + * `.github/workflows/contributor-maintenance.yml` on the first of each month; + * it used to run as a watcher inside every user's daemon (spec 097, FR-31). + */ + +import { Command } from 'commander'; +import { container } from '@/infrastructure/di/container.js'; +import { GenerateMonthlyRecapUseCase } from '@/application/use-cases/contributors/generate-monthly-recap.use-case.js'; +import { PublishMonthlyRecapUseCase } from '@/application/use-cases/contributors/publish-monthly-recap.use-case.js'; +import { RecapChannel } from '@/domain/generated/output.js'; +import { previousYearMonth } from '@/domain/shared/previous-year-month.js'; +import { messages } from '../../ui/index.js'; + +const YEAR_MONTH = /^\d{4}-(0[1-9]|1[0-2])$/; + +export function createRecapCommand(): Command { + return new Command('recap') + .description('Generate and publish a monthly contributor recap (maintainer workflow entry).') + .option('-m, --month ', 'Month to recap (default: the previous calendar month)') + .addHelpText( + 'after', + ` +Examples: + $ shep contributors recap Recap the previous calendar month + $ shep contributors recap --month 2026-09 Recap September 2026` + ) + .action(async (options: { month?: string }) => { + try { + const yearMonth = options.month ?? previousYearMonth(new Date()); + if (!YEAR_MONTH.test(yearMonth)) { + throw new Error(`--month must look like 2026-09, got "${yearMonth}".`); + } + + const { artifact } = await container + .resolve(GenerateMonthlyRecapUseCase) + .execute({ yearMonth }); + const { outcomes } = await container.resolve(PublishMonthlyRecapUseCase).execute({ + artifact, + targets: [{ channel: RecapChannel.File }], + }); + + for (const outcome of outcomes) { + switch (outcome.status) { + case 'published': + messages.success(`Published ${yearMonth} recap: ${outcome.reference}`); + break; + case 'denied': + messages.info(`${outcome.channel} publish denied: ${outcome.rationale}`); + break; + case 'skipped': + messages.info(`${outcome.channel} skipped: ${outcome.reason}`); + break; + case 'failed': + messages.error(`${outcome.channel} publish failed: ${outcome.error}`); + process.exitCode = 1; + break; + } + } + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + messages.error('Failed to publish the contributor recap', err); + process.exitCode = 1; + } + }); +} diff --git a/src/presentation/cli/commands/contributors/repository-slug.ts b/src/presentation/cli/commands/contributors/repository-slug.ts new file mode 100644 index 000000000..3eded0c26 --- /dev/null +++ b/src/presentation/cli/commands/contributors/repository-slug.ts @@ -0,0 +1,6 @@ +/** Parse an `owner/repo` slug; `null` when either half is missing. */ +export function parseRepositorySlug(slug: string): { owner: string; repo: string } | null { + const [owner, repo, ...rest] = slug.split('/'); + if (!owner || !repo || rest.length > 0) return null; + return { owner, repo }; +} diff --git a/src/presentation/cli/commands/contributors/stale-issues.command.ts b/src/presentation/cli/commands/contributors/stale-issues.command.ts new file mode 100644 index 000000000..62882ef51 --- /dev/null +++ b/src/presentation/cli/commands/contributors/stale-issues.command.ts @@ -0,0 +1,70 @@ +/** + * `shep contributors stale-issues` — lists good-first-issues with no activity + * for longer than the threshold. Run by + * `.github/workflows/contributor-maintenance.yml` on a schedule; it used to + * run as a watcher inside every user's daemon (spec 097, FR-42). + * + * Environment: + * - GITHUB_REPOSITORY: "owner/repo" slug, used when --repo is omitted + */ + +import { Command } from 'commander'; +import { container } from '@/infrastructure/di/container.js'; +import { DetectStaleGoodFirstIssueUseCase } from '@/application/use-cases/contributors/detect-stale-good-first-issue.use-case.js'; +import { messages } from '../../ui/index.js'; +import { readGitHubRepositoryEnv } from './load-github-event.js'; +import { parseRepositorySlug } from './repository-slug.js'; + +interface StaleIssuesOptions { + repo?: string; + days?: string; +} + +export function createStaleIssuesCommand(): Command { + return new Command('stale-issues') + .description('List good-first-issues with no recent activity (maintainer workflow entry).') + .option('-r, --repo ', 'Repository to check (default: $GITHUB_REPOSITORY)') + .option('-d, --days ', 'Days without activity before an issue counts as stale') + .addHelpText( + 'after', + ` +Examples: + $ shep contributors stale-issues --repo shep-ai/shep + $ shep contributors stale-issues --repo shep-ai/shep --days 45 + $ GITHUB_REPOSITORY=shep-ai/shep shep contributors stale-issues` + ) + .action(async (options: StaleIssuesOptions) => { + try { + const target = options.repo ? parseRepositorySlug(options.repo) : readGitHubRepositoryEnv(); + if (!target) { + throw new Error('Pass --repo owner/repo or set GITHUB_REPOSITORY.'); + } + const staleDays = options.days === undefined ? undefined : Number(options.days); + if (staleDays !== undefined && (!Number.isInteger(staleDays) || staleDays <= 0)) { + throw new Error(`--days must be a positive whole number, got "${options.days}".`); + } + + const useCase = container.resolve(DetectStaleGoodFirstIssueUseCase); + const result = await useCase.execute({ owner: target.owner, repo: target.repo, staleDays }); + + if (result.stale.length === 0) { + messages.success( + `No good-first-issues in ${target.owner}/${target.repo} are older than ${result.thresholdDays} days.` + ); + return; + } + messages.info( + `${result.stale.length} good-first-issue(s) in ${target.owner}/${target.repo} have had no activity for over ${result.thresholdDays} days:` + ); + for (const issue of result.stale) { + console.log( + `- #${issue.issueNumber} ${issue.title} (${issue.staleForDays} days) ${issue.url}` + ); + } + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + messages.error('Failed to check for stale good-first-issues', err); + process.exitCode = 1; + } + }); +} diff --git a/src/presentation/cli/commands/feature-flag-gate.ts b/src/presentation/cli/commands/feature-flag-gate.ts new file mode 100644 index 000000000..f751c99ce --- /dev/null +++ b/src/presentation/cli/commands/feature-flag-gate.ts @@ -0,0 +1,40 @@ +/** + * Gates a command group behind feature flags (spec 135). + * + * While none of the flags is on, the group is hidden from `--help` and any + * invocation — a subcommand or the bare group — prints how to turn the flag + * on and exits 1. A flag the stored settings do not carry counts as its + * default value. + */ + +import type { Command } from 'commander'; +import { getSettings, hasSettings } from '@/infrastructure/services/settings.service.js'; +import { createDefaultSettings } from '@/domain/factories/settings-defaults.factory.js'; +import type { FeatureFlagKey } from '@/domain/shared/feature-flag-catalog.js'; +import { messages } from '../ui/index.js'; + +/** A flag's value, or its default when the stored settings do not carry it. */ +export function isFeatureFlagOn(flag: FeatureFlagKey): boolean { + const stored = hasSettings() ? getSettings().featureFlags?.[flag] : undefined; + return stored ?? createDefaultSettings().featureFlags![flag]; +} + +export function gateByFeatureFlag( + cmd: Command, + flags: FeatureFlagKey | readonly FeatureFlagKey[] +): Command { + const required: readonly FeatureFlagKey[] = typeof flags === 'string' ? [flags] : flags; + if (required.some(isFeatureFlagOn)) return cmd; + + const block = (): never => { + const enable = required.map((flag) => `shep settings flags enable ${flag}`).join(' or '); + messages.error(`\`shep ${cmd.name()}\` is turned off. Turn it on with \`${enable}\`.`); + process.exit(1); + }; + (cmd as unknown as { _hidden: boolean })._hidden = true; + // `preAction` only fires once a subcommand's own action runs, so the bare + // group gets its own action too — otherwise Commander prints its help. + cmd.hook('preAction', block); + cmd.action(block); + return cmd; +} diff --git a/src/presentation/cli/commands/security.command.ts b/src/presentation/cli/commands/security.command.ts index 5aefcabc6..efc4c830a 100644 --- a/src/presentation/cli/commands/security.command.ts +++ b/src/presentation/cli/commands/security.command.ts @@ -1,8 +1,9 @@ /** * Security Command Group * - * Top-level security command with subcommands for supply-chain security - * policy management and enforcement. + * Supply-chain security enforcement, part of ASPM (spec 135): it runs only + * while the `aspm` feature flag is on, or when SHEP_SUPPLY_CHAIN_SECURITY=true + * opts in explicitly (Shep's own CI does this). * * Usage: * shep security enforce Evaluate and enforce security posture @@ -14,6 +15,7 @@ import { container } from '@/infrastructure/di/container.js'; import { EnforceSecurityUseCase } from '@/application/use-cases/security/enforce-security.use-case.js'; import { SecurityMode } from '@/domain/generated/output.js'; import { getSettings } from '@/infrastructure/services/settings.service.js'; +import { isSupplyChainSecurityEnabled } from '@/domain/shared/supply-chain-security.js'; import { colors, fmt, messages } from '../ui/index.js'; import { OutputFormatter, type OutputFormat } from '../ui/output.js'; import { getCliI18n } from '../i18n.js'; @@ -51,17 +53,16 @@ Examples: .option('-o, --output ', t('cli:commands.security.enforce.outputOption'), 'table') .action(async (options: { repo: string; output: string }) => { try { - // Master kill switch — if the supplyChainSecurity feature flag is off, - // the command becomes a no-op and exits 0. Prevents accidental enforcement - // after the flag has been used as a rollback. - // - // Two ways to disable: - // 1. SHEP_SUPPLY_CHAIN_SECURITY=false environment variable (intended for CI) - // 2. featureFlags.supplyChainSecurity=false in the Shep settings DB (local user) - const envOverride = process.env.SHEP_SUPPLY_CHAIN_SECURITY; - const envDisabled = envOverride === 'false' || envOverride === '0'; - const settingsEnabled = getSettings().featureFlags?.supplyChainSecurity ?? true; - if (envDisabled || !settingsEnabled) { + // Supply-chain security is part of ASPM: with the aspm feature flag off + // the command is a no-op that exits 0, so a CI step never fails just + // because the feature is off. SHEP_SUPPLY_CHAIN_SECURITY overrides the + // flag either way ("false"/"0" off, "true"/"1" on) for CI. + if ( + !isSupplyChainSecurityEnabled( + getSettings().featureFlags, + process.env.SHEP_SUPPLY_CHAIN_SECURITY + ) + ) { messages.info(t('cli:commands.security.enforce.flagDisabledNote')); return; } diff --git a/src/presentation/cli/commands/settings/flags.command.ts b/src/presentation/cli/commands/settings/flags.command.ts new file mode 100644 index 000000000..a919d6525 --- /dev/null +++ b/src/presentation/cli/commands/settings/flags.command.ts @@ -0,0 +1,96 @@ +/** + * Feature Flags Command (spec 135) + * + * Lists every feature flag with its state, default and a one-line + * description, and turns one on or off — the CLI side of the web + * feature-flags view. Both go through the List/SetFeatureFlag use cases. + * + * Usage: + * shep settings flags # List every flag + * shep settings flags enable # Turn a flag on + * shep settings flags disable # Turn a flag off + */ + +import { Command } from 'commander'; +import { container } from '@/infrastructure/di/container.js'; +import { + ListFeatureFlagsUseCase, + type FeatureFlagState, +} from '@/application/use-cases/settings/list-feature-flags.use-case.js'; +import { SetFeatureFlagUseCase } from '@/application/use-cases/settings/set-feature-flag.use-case.js'; +import { FeatureFlagGroup } from '@/domain/generated/output.js'; +import { initializeSettings, resetSettings } from '@/infrastructure/services/settings.service.js'; +import { colors, fmt, messages } from '../../ui/index.js'; + +const GROUP_TITLES: Record = { + [FeatureFlagGroup.Platform]: 'Platform', + [FeatureFlagGroup.SoftwareFactory]: 'Software factory', + [FeatureFlagGroup.Experimental]: 'Experimental', +}; + +function onOff(enabled: boolean): string { + return enabled ? 'on' : 'off'; +} + +function printFlags(flags: readonly FeatureFlagState[]): void { + const keyWidth = Math.max(...flags.map((flag) => flag.key.length)); + for (const group of Object.values(FeatureFlagGroup)) { + const inGroup = flags.filter((flag) => flag.group === group); + if (inGroup.length === 0) continue; + messages.newline(); + console.log(fmt.heading(GROUP_TITLES[group])); + for (const flag of inGroup) { + const state = flag.enabled ? colors.success('on ') : colors.muted('off'); + const note = colors.muted(`(default ${onOff(flag.defaultEnabled)})`); + console.log(` ${state} ${flag.key.padEnd(keyWidth)} ${flag.description} ${note}`); + } + } + messages.newline(); +} + +function createToggleCommand(verb: 'enable' | 'disable'): Command { + const enabled = verb === 'enable'; + return new Command(verb) + .description(`Turn a feature flag ${onOff(enabled)}`) + .argument('', 'Flag name, as listed by `shep settings flags`') + .action(async (flag: string) => { + try { + const updated = await container + .resolve(SetFeatureFlagUseCase) + .execute({ key: flag, enabled }); + resetSettings(); + initializeSettings(updated); + messages.success(`Feature flag "${flag}" is ${onOff(enabled)}.`); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + messages.error( + `Failed to turn "${flag}" ${onOff(enabled)}: ${reason}. \`shep settings flags\` lists every flag.` + ); + process.exitCode = 1; + } + }); +} + +export function createFlagsCommand(): Command { + return new Command('flags') + .description('List feature flags and turn them on or off') + .addHelpText( + 'after', + ` +Examples: + $ shep settings flags List every flag, its state and default + $ shep settings flags enable aspm Turn ASPM on + $ shep settings flags disable factory Hide the software-factory status page` + ) + .addCommand(createToggleCommand('enable')) + .addCommand(createToggleCommand('disable')) + .action(async () => { + try { + printFlags(await container.resolve(ListFeatureFlagsUseCase).execute()); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + messages.error('Failed to list feature flags', err); + process.exitCode = 1; + } + }); +} diff --git a/src/presentation/cli/commands/settings/index.ts b/src/presentation/cli/commands/settings/index.ts index b35af27c9..708db8625 100644 --- a/src/presentation/cli/commands/settings/index.ts +++ b/src/presentation/cli/commands/settings/index.ts @@ -16,6 +16,7 @@ * shep settings adaptive-models # Configure per-task adaptive model tiers * shep settings language # Configure display language * shep settings worktree # Configure custom worktree provisioning commands + * shep settings flags # List feature flags and turn them on or off */ import { Command } from 'commander'; @@ -30,6 +31,7 @@ import { createEffortCommand } from './effort.command.js'; import { createLanguageCommand } from './language.command.js'; import { createMessagingCommand } from './messaging.command.js'; import { createWorktreeCommand } from './worktree.command.js'; +import { createFlagsCommand } from './flags.command.js'; import { onboardingWizard } from '../../../tui/wizards/onboarding/onboarding.wizard.js'; import { messages } from '../../ui/index.js'; import { getCliI18n } from '../../i18n.js'; @@ -50,7 +52,8 @@ export function createSettingsCommand(): Command { .addCommand(createAdaptiveModelsCommand()) .addCommand(createLanguageCommand()) .addCommand(createMessagingCommand()) - .addCommand(createWorktreeCommand()); + .addCommand(createWorktreeCommand()) + .addCommand(createFlagsCommand()); // Default action: launch the full setup wizard when no subcommand is given cmd.action(async () => { diff --git a/src/presentation/cli/commands/ui.command.ts b/src/presentation/cli/commands/ui.command.ts index 93b52804d..379207574 100644 --- a/src/presentation/cli/commands/ui.command.ts +++ b/src/presentation/cli/commands/ui.command.ts @@ -36,21 +36,7 @@ import { initializeAutoArchiveWatcher, getAutoArchiveWatcher, } from '@/infrastructure/services/auto-archive/auto-archive-watcher.service.js'; -import { - initializeStaleGoodFirstIssueWatcher, - getStaleGoodFirstIssueWatcher, -} from '@/infrastructure/services/contributors/stale-good-first-issue-watcher.service.js'; -import { - initializeMonthlyRecapWatcher, - getMonthlyRecapWatcher, -} from '@/infrastructure/services/contributors/monthly-recap-watcher.service.js'; -import { DetectStaleGoodFirstIssueUseCase } from '@/application/use-cases/contributors/detect-stale-good-first-issue.use-case.js'; -import { GenerateMonthlyRecapUseCase } from '@/application/use-cases/contributors/generate-monthly-recap.use-case.js'; -import { PublishMonthlyRecapUseCase } from '@/application/use-cases/contributors/publish-monthly-recap.use-case.js'; import type { IBrowserOpener } from '@/application/ports/output/services/i-browser-opener.js'; -import type { IRepositoryRepository } from '@/application/ports/output/repositories/repository-repository.interface.js'; -import type { IGitHubRepositoryService } from '@/application/ports/output/services/github-repository-service.interface.js'; -import type { IDesktopNotifier } from '@/application/ports/output/services/i-desktop-notifier.js'; import type { ITunnelService } from '@/application/ports/output/services/tunnel-service.interface.js'; import type { IWebhookService as IGitHubWebhookServiceType } from '@/application/ports/output/services/webhook-service.interface.js'; import { @@ -125,25 +111,6 @@ Examples: initializeAutoArchiveWatcher(featureRepo); getAutoArchiveWatcher().start(); - // Start contributor pipeline watchers (spec 097, FR-42) - const repositoryRepo = container.resolve('IRepositoryRepository'); - const githubRepoService = container.resolve( - 'IGitHubRepositoryService' - ); - const desktopNotifier = container.resolve('IDesktopNotifier'); - initializeStaleGoodFirstIssueWatcher( - container.resolve(DetectStaleGoodFirstIssueUseCase), - repositoryRepo, - githubRepoService, - desktopNotifier - ); - getStaleGoodFirstIssueWatcher().start(); - initializeMonthlyRecapWatcher({ - generate: container.resolve(GenerateMonthlyRecapUseCase), - publish: container.resolve(PublishMonthlyRecapUseCase), - }); - getMonthlyRecapWatcher().start(); - // Start WhatsApp connection service (spec 101) — no-op unless the // whatsappDispatch flag is on AND the integration is enabled. const whatsappService = container.resolve<{ @@ -198,8 +165,6 @@ Examples: backgroundSync.stop(); getNotificationWatcher().stop(); getAutoArchiveWatcher().stop(); - getStaleGoodFirstIssueWatcher().stop(); - getMonthlyRecapWatcher().stop(); void whatsappService.stop(); await service.stop(); process.exit(0); diff --git a/src/presentation/cli/index.ts b/src/presentation/cli/index.ts index e1830cef7..ca4b4bb42 100644 --- a/src/presentation/cli/index.ts +++ b/src/presentation/cli/index.ts @@ -68,6 +68,7 @@ import { createBedrockCommand } from './commands/bedrock/bedrock.command.js'; import { createContributorsCommand } from './commands/contributors/index.js'; import { createWhatsappCommand } from './commands/whatsapp/whatsapp.command.js'; import { createAspmCommand } from './commands/aspm/index.js'; +import { gateByFeatureFlag } from './commands/feature-flag-gate.js'; import { createSecurityCommand } from './commands/security.command.js'; import { createWorkflowCommand } from './commands/workflow/index.js'; import { createPluginCommand } from './commands/plugin/index.js'; @@ -212,18 +213,18 @@ async function bootstrap() { program.addCommand(createAspmCommand()); program.addCommand(createSecurityCommand()); program.addCommand(createPluginCommand()); - program.addCommand(createSpaceCommand()); - program.addCommand(createConnectionCommand()); - program.addCommand(createSyncCommand()); - program.addCommand(createKnowledgeCommand()); - program.addCommand(createSignalCommand()); - program.addCommand(createOpportunityCommand()); - program.addCommand(createFeedbackCommand()); - program.addCommand(createDiscoveryCommand()); - program.addCommand(createIncidentCommand()); - program.addCommand(createOutcomeCommand()); - program.addCommand(createAutopilotCommand()); - program.addCommand(createFactoryCommand()); + program.addCommand(gateByFeatureFlag(createSpaceCommand(), 'spaces')); + program.addCommand(gateByFeatureFlag(createConnectionCommand(), ['trackers', 'knowledge'])); + program.addCommand(gateByFeatureFlag(createSyncCommand(), 'trackers')); + program.addCommand(gateByFeatureFlag(createKnowledgeCommand(), 'knowledge')); + program.addCommand(gateByFeatureFlag(createSignalCommand(), 'signals')); + program.addCommand(gateByFeatureFlag(createOpportunityCommand(), 'opportunities')); + program.addCommand(gateByFeatureFlag(createFeedbackCommand(), 'feedback')); + program.addCommand(gateByFeatureFlag(createDiscoveryCommand(), 'discovery')); + program.addCommand(gateByFeatureFlag(createIncidentCommand(), 'incidents')); + program.addCommand(gateByFeatureFlag(createOutcomeCommand(), 'outcomes')); + program.addCommand(gateByFeatureFlag(createAutopilotCommand(), 'autopilot')); + program.addCommand(gateByFeatureFlag(createFactoryCommand(), 'factory')); program.addCommand(createUpgradeCommand()); program.addCommand(createWorkflowCommand()); program.addCommand(createMcpCommand()); diff --git a/src/presentation/web/app/(dashboard)/get-graph-data.ts b/src/presentation/web/app/(dashboard)/get-graph-data.ts index cfd7adcad..c0cbc5b24 100644 --- a/src/presentation/web/app/(dashboard)/get-graph-data.ts +++ b/src/presentation/web/app/(dashboard)/get-graph-data.ts @@ -24,6 +24,7 @@ import { getLanguagePreference } from '@/lib/language'; import { buildGraphNodes } from '@/app/build-graph-nodes'; import type { CanvasNodeType } from '@/components/features/features-canvas'; import type { Edge } from '@xyflow/react'; +import { isSupplyChainSecurityEnabled } from '@shepai/core/domain/shared/supply-chain-security'; const execFileAsync = promisify(execFileCb); @@ -272,9 +273,9 @@ export async function getGraphData(): Promise<{ } const { workflow, security, featureFlags } = getSettings(); - // Master kill switch: when the supplyChainSecurity feature flag is off, skip + // Supply-chain security is part of ASPM: with the aspm flag off, skip // passing securityMode so no feature card renders the SecurityBadge. - const supplyChainSecurityEnabled = featureFlags?.supplyChainSecurity ?? true; + const supplyChainSecurityEnabled = isSupplyChainSecurityEnabled(featureFlags); const { nodes, edges } = buildGraphNodes(repositories, featuresWithRuns, { enableEvidence: workflow.enableEvidence, commitEvidence: workflow.commitEvidence, diff --git a/src/presentation/web/app/actions/set-feature-flag.ts b/src/presentation/web/app/actions/set-feature-flag.ts new file mode 100644 index 000000000..6088c914d --- /dev/null +++ b/src/presentation/web/app/actions/set-feature-flag.ts @@ -0,0 +1,36 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { resolve } from '@/lib/server-container'; +import { + resetSettings, + initializeSettings, +} from '@shepai/core/infrastructure/services/settings.service'; +import type { SetFeatureFlagUseCase } from '@shepai/core/application/use-cases/settings/set-feature-flag.use-case'; + +/** + * Turns one feature flag on or off (spec 135). + * + * Validation lives in SetFeatureFlagUseCase; this action refreshes the + * in-memory settings singleton and the layout so the sidebar follows. + */ +export async function setFeatureFlag( + key: string, + enabled: boolean +): Promise<{ ok: boolean; error?: string }> { + try { + const updated = await resolve('SetFeatureFlagUseCase').execute({ + key, + enabled, + }); + resetSettings(); + initializeSettings(updated); + revalidatePath('/', 'layout'); + return { ok: true }; + } catch (error: unknown) { + return { + ok: false, + error: error instanceof Error ? error.message : 'Failed to update the feature flag', + }; + } +} diff --git a/src/presentation/web/app/api/alerts/route.ts b/src/presentation/web/app/api/alerts/route.ts index 45f3a856e..448410d7b 100644 --- a/src/presentation/web/app/api/alerts/route.ts +++ b/src/presentation/web/app/api/alerts/route.ts @@ -9,12 +9,14 @@ */ import { resolve } from '@/lib/server-container'; -import { handleIntake } from '@/lib/intake-route'; +import { handleIntake, intakeDisabled } from '@/lib/intake-route'; +import { getFeatureFlags } from '@/lib/feature-flags'; import type { IngestAlertUseCase } from '@shepai/core/application/use-cases/incidents/ingest-alert.use-case'; export const dynamic = 'force-dynamic'; -export function POST(request: Request): Promise { +export async function POST(request: Request): Promise { + if (!getFeatureFlags().incidents) return intakeDisabled(); return handleIntake(request, async (secret, payload) => { const result = await resolve('IngestAlertUseCase').execute(secret, payload); return result.ok ? { ok: true, id: result.incident.id, duplicate: result.duplicate } : result; diff --git a/src/presentation/web/app/api/applications/[id]/cloud-deploy/initiate/route.ts b/src/presentation/web/app/api/applications/[id]/cloud-deploy/initiate/route.ts index be9a5e5a7..d3a786e5e 100644 --- a/src/presentation/web/app/api/applications/[id]/cloud-deploy/initiate/route.ts +++ b/src/presentation/web/app/api/applications/[id]/cloud-deploy/initiate/route.ts @@ -41,9 +41,6 @@ export async function POST(_request: NextRequest, { params }: RouteParams): Prom ) { return NextResponse.json({ error: message, code }, { status: 409 }); } - if (code === 'PROVIDER_NOT_IMPLEMENTED') { - return NextResponse.json({ error: message, code }, { status: 501 }); - } return NextResponse.json({ error: message }, { status: 500 }); } } diff --git a/src/presentation/web/app/api/applications/[id]/cloud-deploy/select-provider/route.ts b/src/presentation/web/app/api/applications/[id]/cloud-deploy/select-provider/route.ts index 881566811..7b92d20da 100644 --- a/src/presentation/web/app/api/applications/[id]/cloud-deploy/select-provider/route.ts +++ b/src/presentation/web/app/api/applications/[id]/cloud-deploy/select-provider/route.ts @@ -9,10 +9,7 @@ import { NextResponse } from 'next/server'; import { resolve } from '@/lib/server-container'; import { errorCode, errorMessage } from '@/lib/error-code'; import type { SelectCloudProviderUseCase } from '@shepai/core/application/use-cases/cloud-deploy/select-cloud-provider.use-case'; -import { - CloudDeploymentProvider, - type CloudDeploymentProvider as CloudDeploymentProviderType, -} from '@shepai/core/domain/generated/output'; +import { parseCloudDeploymentProvider } from '@shepai/core/domain/shared/cloud-deployment-provider'; export const dynamic = 'force-dynamic'; @@ -20,17 +17,11 @@ interface RouteParams { params: Promise<{ id: string }>; } -function parseProvider(raw: unknown): CloudDeploymentProviderType | null { - if (typeof raw !== 'string') return null; - const allowed = Object.values(CloudDeploymentProvider) as string[]; - return allowed.includes(raw) ? (raw as CloudDeploymentProviderType) : null; -} - export async function POST(request: NextRequest, { params }: RouteParams): Promise { try { const { id } = await params; const body = (await request.json()) as { provider?: unknown }; - const provider = parseProvider(body.provider); + const provider = parseCloudDeploymentProvider(body.provider); if (!provider) { return NextResponse.json({ error: 'Invalid provider' }, { status: 400 }); } diff --git a/src/presentation/web/app/api/cloud-providers/[provider]/connect/route.ts b/src/presentation/web/app/api/cloud-providers/[provider]/connect/route.ts index 8cc1ee618..c7b434caa 100644 --- a/src/presentation/web/app/api/cloud-providers/[provider]/connect/route.ts +++ b/src/presentation/web/app/api/cloud-providers/[provider]/connect/route.ts @@ -9,12 +9,9 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { resolve } from '@/lib/server-container'; -import { errorCode, errorMessage } from '@/lib/error-code'; +import { errorMessage } from '@/lib/error-code'; import type { ConnectCloudProviderUseCase } from '@shepai/core/application/use-cases/cloud-deploy/connect-cloud-provider.use-case'; -import { - CloudDeploymentProvider, - type CloudDeploymentProvider as CloudDeploymentProviderType, -} from '@shepai/core/domain/generated/output'; +import { parseCloudDeploymentProvider } from '@shepai/core/domain/shared/cloud-deployment-provider'; export const dynamic = 'force-dynamic'; @@ -22,15 +19,10 @@ interface RouteParams { params: Promise<{ provider: string }>; } -function parseProvider(raw: string): CloudDeploymentProviderType | null { - const allowed = Object.values(CloudDeploymentProvider) as string[]; - return allowed.includes(raw) ? (raw as CloudDeploymentProviderType) : null; -} - export async function POST(request: NextRequest, { params }: RouteParams): Promise { try { const { provider: raw } = await params; - const provider = parseProvider(raw); + const provider = parseCloudDeploymentProvider(raw); if (!provider) { return NextResponse.json({ error: `Unknown provider: ${raw}` }, { status: 400 }); } @@ -42,11 +34,6 @@ export async function POST(request: NextRequest, { params }: RouteParams): Promi await useCase.execute({ provider, token: body.token.trim() }); return NextResponse.json({ ok: true }); } catch (error) { - const code = errorCode(error); - const message = errorMessage(error); - if (code === 'PROVIDER_NOT_IMPLEMENTED') { - return NextResponse.json({ error: message, code }, { status: 409 }); - } - return NextResponse.json({ error: message }, { status: 500 }); + return NextResponse.json({ error: errorMessage(error) }, { status: 500 }); } } diff --git a/src/presentation/web/app/api/cloud-providers/route.ts b/src/presentation/web/app/api/cloud-providers/route.ts index d1f56ff1e..b2272bcff 100644 --- a/src/presentation/web/app/api/cloud-providers/route.ts +++ b/src/presentation/web/app/api/cloud-providers/route.ts @@ -1,8 +1,7 @@ /** * GET /api/cloud-providers * - * List every known cloud deployment provider with its enabled + connected - * flags. Powers the Deploy dropdown on the application page. + * List every cloud deployment provider with its connected flag. Powers the Deploy dropdown on the application page. */ import { NextResponse } from 'next/server'; diff --git a/src/presentation/web/app/api/feedback/route.ts b/src/presentation/web/app/api/feedback/route.ts index f625457c8..f6924d07c 100644 --- a/src/presentation/web/app/api/feedback/route.ts +++ b/src/presentation/web/app/api/feedback/route.ts @@ -9,12 +9,14 @@ */ import { resolve } from '@/lib/server-container'; -import { handleIntake } from '@/lib/intake-route'; +import { handleIntake, intakeDisabled } from '@/lib/intake-route'; +import { getFeatureFlags } from '@/lib/feature-flags'; import type { IngestFeedbackUseCase } from '@shepai/core/application/use-cases/feedback/ingest-feedback.use-case'; export const dynamic = 'force-dynamic'; -export function POST(request: Request): Promise { +export async function POST(request: Request): Promise { + if (!getFeatureFlags().feedback) return intakeDisabled(); return handleIntake(request, async (secret, payload) => { const result = await resolve('IngestFeedbackUseCase').execute( secret, diff --git a/src/presentation/web/app/connections/page.tsx b/src/presentation/web/app/connections/page.tsx index 149ffc44b..359dc8175 100644 --- a/src/presentation/web/app/connections/page.tsx +++ b/src/presentation/web/app/connections/page.tsx @@ -1,9 +1,11 @@ import { getTrackerOverview } from '@/app/actions/manage-trackers'; +import { requireFeaturePage } from '@/lib/require-feature-page'; import { TrackersPageClient } from '@/components/features/trackers/trackers-page-client'; export const dynamic = 'force-dynamic'; export default async function ConnectionsPage() { + requireFeaturePage('trackers', 'knowledge'); const { overview, error } = await getTrackerOverview(); return ( diff --git a/src/presentation/web/app/contributors/page.tsx b/src/presentation/web/app/contributors/page.tsx new file mode 100644 index 000000000..6012d84cd --- /dev/null +++ b/src/presentation/web/app/contributors/page.tsx @@ -0,0 +1,98 @@ +import { notFound } from 'next/navigation'; +import { getFeatureFlags } from '@/lib/feature-flags'; +import { resolve } from '@/lib/server-container'; +import { ContributorOnboardingView } from '@/components/contributors/ContributorOnboardingView'; +import type { + GetContributorLeaderboardUseCase, + ContributorLeaderboardEntry, +} from '@shepai/core/application/use-cases/contributors/get-contributor-leaderboard.use-case'; +import type { RunDoctorUseCase } from '@shepai/core/application/use-cases/doctor/run-doctor.use-case'; +import type { LeaderboardEntry } from '@/components/contributors/ContributorLeaderboard'; +import type { DoctorSummaryReport } from '@/components/contributors/DoctorSummary'; + +export const dynamic = 'force-dynamic'; + +const LEADERBOARD_LIMIT = 7; + +interface LeaderboardLoadResult { + entries: readonly LeaderboardEntry[]; + error?: string; +} + +interface DoctorLoadResult { + report?: DoctorSummaryReport; + error?: string; +} + +async function loadInitialLeaderboard(): Promise { + try { + const useCase = resolve('GetContributorLeaderboardUseCase'); + const result = await useCase.execute({ scope: 'month', limit: LEADERBOARD_LIMIT }); + const entries: LeaderboardEntry[] = result.entries.map((e: ContributorLeaderboardEntry) => ({ + login: e.login, + displayName: e.displayName, + avatarUrl: e.avatarUrl, + prCount: e.prCount, + level: e.level, + lane: e.lane, + })); + return { entries }; + } catch (error: unknown) { + return { + entries: [], + error: error instanceof Error ? error.message : 'Failed to load leaderboard', + }; + } +} + +async function loadInitialDoctorReport(): Promise { + try { + const useCase = resolve('RunDoctorUseCase'); + const report = await useCase.execute(); + return { + report: { + results: report.results.map((r) => ({ + name: r.name, + status: r.status, + detail: r.detail, + fixHint: r.fixHint, + })), + overallStatus: report.overallStatus, + summary: report.summary, + }, + }; + } catch (error: unknown) { + return { error: error instanceof Error ? error.message : 'Doctor unavailable' }; + } +} + +/** + * Contributor onboarding for people working on Shep itself: lane chooser, + * leaderboard and contributor doctor (spec 097). Linked from CONTRIBUTING.md, + * deliberately absent from the end-user sidebar. + */ +export default async function ContributorsRoute() { + const flags = getFeatureFlags(); + if (!flags.collaboration) { + notFound(); + } + + const [leaderboard, doctor] = await Promise.all([ + loadInitialLeaderboard(), + loadInitialDoctorReport(), + ]); + + return ( +
+ +
+ ); +} diff --git a/src/presentation/web/app/factory/page.tsx b/src/presentation/web/app/factory/page.tsx index df87d7a9a..4cb6c9d8d 100644 --- a/src/presentation/web/app/factory/page.tsx +++ b/src/presentation/web/app/factory/page.tsx @@ -1,4 +1,5 @@ import { resolve } from '@/lib/server-container'; +import { requireFeaturePage } from '@/lib/require-feature-page'; import { errorMessage } from '@/lib/action-outcome'; import type { FactoryStatus, @@ -21,6 +22,7 @@ interface FactoryPageProps { } export default async function FactoryPage({ searchParams }: FactoryPageProps) { + requireFeaturePage('factory'); const { space } = await searchParams; let spaces: FactorySpaceOption[] = []; let projects: FactoryProject[] = []; diff --git a/src/presentation/web/app/incidents/page.tsx b/src/presentation/web/app/incidents/page.tsx index b16cf22b3..3cffe6512 100644 --- a/src/presentation/web/app/incidents/page.tsx +++ b/src/presentation/web/app/incidents/page.tsx @@ -1,4 +1,5 @@ import { resolve } from '@/lib/server-container'; +import { requireFeaturePage } from '@/lib/require-feature-page'; import { errorMessage } from '@/lib/action-outcome'; import type { GetIncidentBoardUseCase, @@ -20,6 +21,7 @@ async function loadSpaces(): Promise { } export default async function IncidentsPage({ searchParams }: IncidentsPageProps) { + requireFeaturePage('incidents'); const { space, incident } = await searchParams; let spaces: IncidentSpaceOption[] = []; let board: IncidentBoard | undefined; diff --git a/src/presentation/web/app/onboarding/page.tsx b/src/presentation/web/app/onboarding/page.tsx index f96931983..46b5749a5 100644 --- a/src/presentation/web/app/onboarding/page.tsx +++ b/src/presentation/web/app/onboarding/page.tsx @@ -1,95 +1,21 @@ import { notFound } from 'next/navigation'; import { getFeatureFlags } from '@/lib/feature-flags'; -import { resolve } from '@/lib/server-container'; import { OnboardingTutorial } from '@/components/onboarding/onboarding-tutorial'; -import { ContributorOnboardingView } from '@/components/contributors/ContributorOnboardingView'; -import type { - GetContributorLeaderboardUseCase, - ContributorLeaderboardEntry, -} from '@shepai/core/application/use-cases/contributors/get-contributor-leaderboard.use-case'; -import type { RunDoctorUseCase } from '@shepai/core/application/use-cases/doctor/run-doctor.use-case'; -import type { LeaderboardEntry } from '@/components/contributors/ContributorLeaderboard'; -import type { DoctorSummaryReport } from '@/components/contributors/DoctorSummary'; -export const dynamic = 'force-dynamic'; - -const LEADERBOARD_LIMIT = 7; - -interface LeaderboardLoadResult { - entries: readonly LeaderboardEntry[]; - error?: string; -} - -interface DoctorLoadResult { - report?: DoctorSummaryReport; - error?: string; -} - -async function loadInitialLeaderboard(): Promise { - try { - const useCase = resolve('GetContributorLeaderboardUseCase'); - const result = await useCase.execute({ scope: 'month', limit: LEADERBOARD_LIMIT }); - const entries: LeaderboardEntry[] = result.entries.map((e: ContributorLeaderboardEntry) => ({ - login: e.login, - displayName: e.displayName, - avatarUrl: e.avatarUrl, - prCount: e.prCount, - level: e.level, - lane: e.lane, - })); - return { entries }; - } catch (error: unknown) { - return { - entries: [], - error: error instanceof Error ? error.message : 'Failed to load leaderboard', - }; - } -} - -async function loadInitialDoctorReport(): Promise { - try { - const useCase = resolve('RunDoctorUseCase'); - const report = await useCase.execute(); - return { - report: { - results: report.results.map((r) => ({ - name: r.name, - status: r.status, - detail: r.detail, - fixHint: r.fixHint, - })), - overallStatus: report.overallStatus, - summary: report.summary, - }, - }; - } catch (error: unknown) { - return { error: error instanceof Error ? error.message : 'Doctor unavailable' }; - } -} - -export default async function OnboardingRoute() { +/** + * The collaboration tutorial (supervisors, agents, questions), linked from + * the empty states on /supervisor and /agents. Shep's own contributor + * tooling lives at /contributors. + */ +export default function OnboardingRoute() { const flags = getFeatureFlags(); if (!flags.collaboration) { notFound(); } - const [leaderboard, doctor] = await Promise.all([ - loadInitialLeaderboard(), - loadInitialDoctorReport(), - ]); - return (
- - +
); } diff --git a/src/presentation/web/app/opportunities/page.tsx b/src/presentation/web/app/opportunities/page.tsx index 7b82aa203..262d9de3c 100644 --- a/src/presentation/web/app/opportunities/page.tsx +++ b/src/presentation/web/app/opportunities/page.tsx @@ -1,4 +1,5 @@ import { resolve } from '@/lib/server-container'; +import { requireFeaturePage } from '@/lib/require-feature-page'; import type { GetOpportunityBoardUseCase, OpportunityBoard, @@ -71,6 +72,7 @@ async function loadOptions(spaceId: string): Promise { } export default async function OpportunitiesPage({ searchParams }: OpportunitiesPageProps) { + requireFeaturePage('opportunities'); const { space } = await searchParams; let board: OpportunityBoard | undefined; let options: OpportunityPageOptions = { spaces: [], productLines: [], projects: [] }; diff --git a/src/presentation/web/app/settings/feature-flags/page.tsx b/src/presentation/web/app/settings/feature-flags/page.tsx new file mode 100644 index 000000000..b9e3cefde --- /dev/null +++ b/src/presentation/web/app/settings/feature-flags/page.tsx @@ -0,0 +1,21 @@ +import { resolve } from '@/lib/server-container'; +import type { ListFeatureFlagsUseCase } from '@shepai/core/application/use-cases/settings/list-feature-flags.use-case'; +import { FeatureFlagsPageClient } from '@/components/features/feature-flags/feature-flags-page-client'; + +/** Skip static pre-rendering since we need runtime DI container and server context. */ +export const dynamic = 'force-dynamic'; + +/** Every feature flag with a description, its default and a switch (spec 135). */ +export default async function FeatureFlagsPage() { + try { + const flags = await resolve('ListFeatureFlagsUseCase').execute(); + return ; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + return ( +
+

Failed to load feature flags: {message}

+
+ ); + } +} diff --git a/src/presentation/web/app/spaces/page.tsx b/src/presentation/web/app/spaces/page.tsx index 92cf2287a..af0761843 100644 --- a/src/presentation/web/app/spaces/page.tsx +++ b/src/presentation/web/app/spaces/page.tsx @@ -1,9 +1,11 @@ import { getSpacesOverview } from '@/app/actions/manage-spaces'; +import { requireFeaturePage } from '@/lib/require-feature-page'; import { SpacesPageClient } from '@/components/features/spaces/spaces-page-client'; export const dynamic = 'force-dynamic'; export default async function SpacesPage() { + requireFeaturePage('spaces'); const { overview, error } = await getSpacesOverview(); return ( diff --git a/src/presentation/web/components/common/feature-node/agent-type-icons.tsx b/src/presentation/web/components/common/feature-node/agent-type-icons.tsx index 6bc60a74b..992076c76 100644 --- a/src/presentation/web/components/common/feature-node/agent-type-icons.tsx +++ b/src/presentation/web/components/common/feature-node/agent-type-icons.tsx @@ -116,8 +116,6 @@ const agentTypeIconMap: Record> = { cursor: createBrandIcon('/icons/agents/cursor.jpeg', 'Cursor'), cline: createBrandIcon('/icons/agents/cline.svg', 'Cline', true), 'gemini-cli': createBrandIcon('/icons/agents/gemini.svg', 'Gemini CLI', true), - aider: createBrandIcon('/icons/agents/aider.png', 'Aider'), - continue: createBrandIcon('/icons/agents/continue.jpeg', 'Continue'), openrouter: createBrandIcon('/icons/agents/openrouter.svg', 'OpenRouter', true), 'together-ai': createBrandIcon('/icons/agents/together-ai.svg', 'Together AI'), ollama: createBrandIcon('/icons/agents/ollama.svg', 'Ollama', true), @@ -135,8 +133,6 @@ export const agentTypeLabels: Record = { cursor: 'Cursor', cline: 'Cline', 'gemini-cli': 'Gemini CLI', - aider: 'Aider', - continue: 'Continue', openrouter: 'OpenRouter', 'together-ai': 'Together AI', ollama: 'Ollama', diff --git a/src/presentation/web/components/common/feature-node/feature-node.stories.tsx b/src/presentation/web/components/common/feature-node/feature-node.stories.tsx index 4e514d7da..cfe5a5bf0 100644 --- a/src/presentation/web/components/common/feature-node/feature-node.stories.tsx +++ b/src/presentation/web/components/common/feature-node/feature-node.stories.tsx @@ -212,7 +212,7 @@ const allStatesData: FeatureNodeData[] = [ state: 'error', progress: 30, errorMessage: 'Build failed: Cannot find module @sendgrid/mail', - agentType: 'aider' as AgentTypeValue, + agentType: 'cline' as AgentTypeValue, modelId: 'claude-sonnet-4-6', repositoryPath: '/home/user/my-repo', repositoryName: 'my-repo', @@ -347,30 +347,6 @@ const allAgentTypesData: FeatureNodeData[] = [ repositoryPath: '/home/user/my-repo', branch: 'feat/gemini-cli', }, - { - name: 'Aider Agent', - description: 'Running with Aider executor', - featureId: '#a4', - lifecycle: 'implementation' as FeatureLifecyclePhase, - state: 'running', - progress: 50, - agentType: 'aider' as AgentTypeValue, - modelId: 'claude-sonnet-4-6', - repositoryPath: '/home/user/my-repo', - branch: 'feat/aider', - }, - { - name: 'Continue Agent', - description: 'Running with Continue executor', - featureId: '#a5', - lifecycle: 'implementation' as FeatureLifecyclePhase, - state: 'running', - progress: 50, - agentType: 'continue' as AgentTypeValue, - modelId: 'claude-sonnet-4-6', - repositoryPath: '/home/user/my-repo', - branch: 'feat/continue', - }, { name: 'Default (No Agent)', description: 'Running with no agent type set', diff --git a/src/presentation/web/components/common/repository-node/repository-drawer.stories.tsx b/src/presentation/web/components/common/repository-node/repository-drawer.stories.tsx index a70cdd74a..386e834f7 100644 --- a/src/presentation/web/components/common/repository-node/repository-drawer.stories.tsx +++ b/src/presentation/web/components/common/repository-node/repository-drawer.stories.tsx @@ -104,6 +104,18 @@ function WithGitOpsTemplate({ data }: { data: RepositoryNodeData }) { scheduledWorkflows: true, githubImport: true, queryAwareHarness: false, + spaces: true, + trackers: true, + knowledge: true, + signals: true, + opportunities: true, + feedback: true, + discovery: true, + incidents: true, + outcomes: true, + docsFirst: true, + autopilot: true, + factory: true, }; return ( diff --git a/src/presentation/web/components/features/application-page/cloud-provider-icons.stories.tsx b/src/presentation/web/components/features/application-page/cloud-provider-icons.stories.tsx new file mode 100644 index 000000000..53ce88633 --- /dev/null +++ b/src/presentation/web/components/features/application-page/cloud-provider-icons.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { CloudDeploymentProvider } from '@shepai/core/domain/generated/output'; +import { + CLOUD_PROVIDER_BRAND_HEX, + CLOUD_PROVIDER_ICONS, + CloudflareIcon, + GitHubIcon, +} from './cloud-provider-icons'; + +const meta: Meta = { + title: 'ApplicationPage/CloudProviderIcons', + component: CloudflareIcon, + parameters: { layout: 'centered' }, +}; + +export default meta; +type Story = StoryObj; + +export const Cloudflare: Story = { + args: { + className: 'size-8', + style: { color: CLOUD_PROVIDER_BRAND_HEX[CloudDeploymentProvider.CloudflarePages] }, + }, +}; + +export const GitHub: Story = { + render: () => , +}; + +/** Every provider icon in its brand color, as the provider list shows it. */ +export const AllProviders: Story = { + render: () => ( +
+ {Object.values(CloudDeploymentProvider).map((id) => { + const Icon = CLOUD_PROVIDER_ICONS[id]; + return ; + })} +
+ ), +}; diff --git a/src/presentation/web/components/features/application-page/cloud-provider-icons.tsx b/src/presentation/web/components/features/application-page/cloud-provider-icons.tsx index 1b27ac449..db77d63bc 100644 --- a/src/presentation/web/components/features/application-page/cloud-provider-icons.tsx +++ b/src/presentation/web/components/features/application-page/cloud-provider-icons.tsx @@ -8,7 +8,7 @@ * `className` + standard SVG props so the provider list can size them * uniformly. Brand hex colors are exposed via `CLOUD_PROVIDER_BRAND_HEX` * so the list can colorize the icon per provider without baking a `fill` - * into the SVG (lets callers force a neutral tint in disabled states). + * into the SVG (lets callers keep a neutral tint where they need one). * * Adding a new provider: drop a new component + map it in * `CLOUD_PROVIDER_ICONS` below and add its hex to `CLOUD_PROVIDER_BRAND_HEX`. @@ -45,45 +45,6 @@ export function CloudflareIcon(props: IconProps) { ); } -/** Vercel — simple-icons `vercel`. Solid triangle. */ -export function VercelIcon(props: IconProps) { - return ; -} - -/** Netlify — simple-icons `netlify`. */ -export function NetlifyIcon(props: IconProps) { - return ( - - ); -} - -/** AWS Amplify — simple-icons does NOT ship an AWS Amplify brand mark - * (AWS trademark restrictions), so we fall back to a stylised "A" chevron - * that reads as "AWS-ish" without infringing. Users never deploy here in - * v1 — it's a "Coming soon" row — so a perfect brand match isn't worth - * the legal headache. */ -export function AwsAmplifyIcon(props: IconProps) { - return ( - - ); -} - -/** Google Cloud — simple-icons `googlecloud`. */ -export function GcpCloudRunIcon(props: IconProps) { - return ( - - ); -} - /** GitHub — simple-icons `github`. */ export function GitHubIcon(props: IconProps) { return ( @@ -99,19 +60,11 @@ export const CLOUD_PROVIDER_ICONS: Record< (props: IconProps) => ReactElement > = { [CloudDeploymentProvider.CloudflarePages]: CloudflareIcon, - [CloudDeploymentProvider.Vercel]: VercelIcon, - [CloudDeploymentProvider.Netlify]: NetlifyIcon, - [CloudDeploymentProvider.AwsAmplify]: AwsAmplifyIcon, - [CloudDeploymentProvider.GcpCloudRun]: GcpCloudRunIcon, }; -/** Brand hex colors from the simple-icons metadata (minus the leading `#`). - * Used by ProviderList so each row's icon renders in the real brand color - * when enabled, and falls back to the muted token when disabled. */ +/** Brand hex colors from the simple-icons metadata. + * Used by ProviderList and DeployPanel so each provider icon renders in + * its real brand color. */ export const CLOUD_PROVIDER_BRAND_HEX: Record = { [CloudDeploymentProvider.CloudflarePages]: '#F38020', - [CloudDeploymentProvider.Vercel]: '#000000', - [CloudDeploymentProvider.Netlify]: '#00C7B7', - [CloudDeploymentProvider.AwsAmplify]: '#FF9900', - [CloudDeploymentProvider.GcpCloudRun]: '#4285F4', }; diff --git a/src/presentation/web/components/features/application-page/cloud-providers.ts b/src/presentation/web/components/features/application-page/cloud-providers.ts new file mode 100644 index 000000000..ef82acca3 --- /dev/null +++ b/src/presentation/web/components/features/application-page/cloud-providers.ts @@ -0,0 +1,21 @@ +/** + * Shared cloud-provider presentation data for the Deploy surfaces + * (DeployButton, DeployPanel, SmartDeployCluster, ConnectProviderModal). + * + * The records are total over CloudDeploymentProvider, so adding a provider + * to the TypeSpec enum is a compile error here until it has a label. + */ + +import { CloudDeploymentProvider } from '@shepai/core/domain/generated/output'; + +/** One row of GET /api/cloud-providers. */ +export interface CloudProviderListEntry { + id: CloudDeploymentProvider; + displayName: string; + connected: boolean; +} + +/** Full provider names, used before /api/cloud-providers has answered. */ +export const CLOUD_PROVIDER_DISPLAY_NAMES: Record = { + [CloudDeploymentProvider.CloudflarePages]: 'Cloudflare Pages', +}; diff --git a/src/presentation/web/components/features/application-page/connect-provider-modal.stories.tsx b/src/presentation/web/components/features/application-page/connect-provider-modal.stories.tsx index db0b93165..b06951ff2 100644 --- a/src/presentation/web/components/features/application-page/connect-provider-modal.stories.tsx +++ b/src/presentation/web/components/features/application-page/connect-provider-modal.stories.tsx @@ -22,9 +22,10 @@ export const Cloudflare: Story = { }, }; -export const Vercel: Story = { +export const UpdateToken: Story = { args: { - provider: CloudDeploymentProvider.Vercel, + provider: CloudDeploymentProvider.CloudflarePages, + mode: 'update', onClose: noopClose, onSubmit: noopSubmit, }, @@ -40,7 +41,7 @@ export const Closed: Story = { export const SubmitError: Story = { args: { - provider: CloudDeploymentProvider.Netlify, + provider: CloudDeploymentProvider.CloudflarePages, onClose: noopClose, onSubmit: async () => { throw new Error('Invalid API token'); diff --git a/src/presentation/web/components/features/application-page/connect-provider-modal.tsx b/src/presentation/web/components/features/application-page/connect-provider-modal.tsx index 205917d5a..c6beaa181 100644 --- a/src/presentation/web/components/features/application-page/connect-provider-modal.tsx +++ b/src/presentation/web/components/features/application-page/connect-provider-modal.tsx @@ -3,8 +3,8 @@ /** * ConnectProviderModal — token paste dialog for cloud deploy providers. * - * Opens when the user selects an enabled-but-not-connected provider from - * the Deploy dropdown. Shows the provider icon, a "Get a token" external + * Opens when the user selects a not-yet-connected provider from the + * Deploy dropdown. Shows the provider icon, a "Get a token" external * link, a textarea for the token, and a Connect button that submits to * POST /api/cloud-providers/:provider/connect via the useCloudDeployAction * hook. @@ -24,23 +24,10 @@ import { import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; import { CLOUD_PROVIDER_ICONS } from './cloud-provider-icons'; +import { CLOUD_PROVIDER_DISPLAY_NAMES } from './cloud-providers'; const PROVIDER_TOKEN_PAGES: Record = { [CloudDeploymentProvider.CloudflarePages]: 'https://dash.cloudflare.com/profile/api-tokens', - [CloudDeploymentProvider.Vercel]: 'https://vercel.com/account/tokens', - [CloudDeploymentProvider.Netlify]: - 'https://app.netlify.com/user/applications#personal-access-tokens', - [CloudDeploymentProvider.AwsAmplify]: - 'https://us-east-1.console.aws.amazon.com/iam/home#/security_credentials', - [CloudDeploymentProvider.GcpCloudRun]: 'https://console.cloud.google.com/apis/credentials', -}; - -const PROVIDER_DISPLAY_NAMES: Record = { - [CloudDeploymentProvider.CloudflarePages]: 'Cloudflare Pages', - [CloudDeploymentProvider.Vercel]: 'Vercel', - [CloudDeploymentProvider.Netlify]: 'Netlify', - [CloudDeploymentProvider.AwsAmplify]: 'AWS Amplify', - [CloudDeploymentProvider.GcpCloudRun]: 'Google Cloud Run', }; export type ConnectProviderModalMode = 'connect' | 'update'; @@ -64,7 +51,7 @@ export function ConnectProviderModal({ const Icon = provider ? CLOUD_PROVIDER_ICONS[provider] : null; const tokenUrl = provider ? PROVIDER_TOKEN_PAGES[provider] : ''; - const displayName = provider ? PROVIDER_DISPLAY_NAMES[provider] : ''; + const displayName = provider ? CLOUD_PROVIDER_DISPLAY_NAMES[provider] : ''; async function handleSubmit() { if (!provider || token.trim().length === 0) return; diff --git a/src/presentation/web/components/features/application-page/deploy-button.tsx b/src/presentation/web/components/features/application-page/deploy-button.tsx index 3a9c53420..02813981b 100644 --- a/src/presentation/web/components/features/application-page/deploy-button.tsx +++ b/src/presentation/web/components/features/application-page/deploy-button.tsx @@ -8,9 +8,8 @@ * Uploading/Deploying → Deploying…, Deployed → live-URL chip, * Failed → Retry). * - Right half: chevron that opens the provider dropdown. The - * dropdown lists every known provider (enabled + disabled stubs). - * Selecting an enabled-but-not-connected provider opens the - * ConnectProviderModal before running Deploy. + * dropdown lists every provider. Selecting a not-yet-connected + * provider opens the ConnectProviderModal before running Deploy. * * This component is purely presentational over the `useCloudDeployAction` * hook and the `/api/cloud-providers` list — no business logic lives here. @@ -24,7 +23,8 @@ import { } from '@shepai/core/domain/generated/output'; import { cn } from '@/lib/utils'; import { CLOUD_PROVIDER_ICONS } from './cloud-provider-icons'; -import { ProviderDropdown, type CloudProviderListEntry } from './provider-dropdown'; +import { ProviderDropdown } from './provider-dropdown'; +import { CLOUD_PROVIDER_DISPLAY_NAMES, type CloudProviderListEntry } from './cloud-providers'; import { ConnectProviderModal } from './connect-provider-modal'; import { OperationLogsDrawer } from './operation-logs-drawer'; import { OperationLogsIconButton, type OperationLogsIconState } from './operation-logs-icon-button'; @@ -42,22 +42,10 @@ export interface DeployButtonProps { const DEFAULT_PROVIDER: CloudDeploymentProvider = CloudDeploymentProvider.CloudflarePages; -const PROVIDER_FALLBACK_NAMES: Record = { - [CloudDeploymentProvider.CloudflarePages]: 'Cloudflare Pages', - [CloudDeploymentProvider.Vercel]: 'Vercel', - [CloudDeploymentProvider.Netlify]: 'Netlify', - [CloudDeploymentProvider.AwsAmplify]: 'AWS Amplify', - [CloudDeploymentProvider.GcpCloudRun]: 'Google Cloud Run', -}; - /** Compact names used in the top-bar Deploy button so it doesn't blow out * the row. The dropdown still uses the full displayName. */ const PROVIDER_SHORT_NAMES: Record = { [CloudDeploymentProvider.CloudflarePages]: 'Cloudflare', - [CloudDeploymentProvider.Vercel]: 'Vercel', - [CloudDeploymentProvider.Netlify]: 'Netlify', - [CloudDeploymentProvider.AwsAmplify]: 'Amplify', - [CloudDeploymentProvider.GcpCloudRun]: 'Cloud Run', }; function statusLabel(status: CloudDeploymentStatus, providerShortName: string): string { @@ -128,7 +116,6 @@ export function DeployButton({ // If there's no provider selected yet, default to Cloudflare Pages. const providerToUse = deploy.state.provider ?? DEFAULT_PROVIDER; const info = providers.find((p) => p.id === providerToUse); - if (info && !info.enabled) return; if (info && !info.connected) { setConnectMode('connect'); setConnectingProvider(providerToUse); @@ -146,7 +133,7 @@ export function DeployButton({ await deploy.initiate(); } - async function handleSelectEnabled(provider: CloudDeploymentProvider) { + async function handleSelectConnected(provider: CloudDeploymentProvider) { // Just update the selection — the user clicks the main Deploy button to // actually run a deployment. Selecting a provider should never side-effect // a deploy, otherwise users can't change provider without committing to @@ -173,11 +160,11 @@ export function DeployButton({ // Tooltip uses the full display name for clarity; the visible label uses // the compact short name so the top-bar button stays narrow. const selectedProviderFullName = - selectedProviderInfo?.displayName ?? PROVIDER_FALLBACK_NAMES[selectedProvider]; + selectedProviderInfo?.displayName ?? CLOUD_PROVIDER_DISPLAY_NAMES[selectedProvider]; const selectedProviderShortName = PROVIDER_SHORT_NAMES[selectedProvider]; const label = statusLabel(status, selectedProviderShortName); const isDeployed = status === CloudDeploymentStatus.Deployed && url; - const hasConnectedProvider = providers.some((p) => p.enabled && p.connected); + const hasConnectedProvider = providers.some((p) => p.connected); // The action button is gated until at least one provider is connected. // The chevron stays interactive so users can connect a provider from the // dropdown — that's the only way out of the gated state. @@ -265,7 +252,7 @@ export function DeployButton({ selectedProvider={selectedProvider} loading={providersLoading} loadError={providersError} - onSelectEnabled={handleSelectEnabled} + onSelectConnected={handleSelectConnected} onSelectDisconnected={(p) => { setConnectMode('connect'); setConnectingProvider(p); diff --git a/src/presentation/web/components/features/application-page/deploy-panel.tsx b/src/presentation/web/components/features/application-page/deploy-panel.tsx index d889c9b61..b7524ab0a 100644 --- a/src/presentation/web/components/features/application-page/deploy-panel.tsx +++ b/src/presentation/web/components/features/application-page/deploy-panel.tsx @@ -35,7 +35,8 @@ import { cn } from '@/lib/utils'; import type { GitStatusDto } from '@/hooks/use-git-status'; import type { SmartDeployState } from '@/hooks/use-smart-deploy-state'; import type { CloudDeployActionApi } from '@/hooks/use-cloud-deploy-action'; -import { ProviderList, type ProviderListEntry } from './provider-list'; +import { ProviderList } from './provider-list'; +import type { CloudProviderListEntry } from './cloud-providers'; import { CLOUD_PROVIDER_BRAND_HEX, CLOUD_PROVIDER_ICONS, GitHubIcon } from './cloud-provider-icons'; import { PublishToGitHubForm, type PublishOwner } from './publish-to-github-modal'; @@ -47,11 +48,10 @@ export interface DeployPanelProps { cloudProviderName: string | null; /** Time-ago string for the last successful deploy ("2 minutes ago"). */ lastDeployedAgo: string | null; - /** Full list of providers for the inline switcher (all 5 including - * the "Coming soon" stubs). Drives the list inside the "Live - * website" section so the user can see + pick any provider without - * needing a nested popover. */ - providers: readonly ProviderListEntry[]; + /** Full list of providers for the inline switcher. Drives the list + * inside the "Live website" section so the user can pick any provider + * without needing a nested popover. */ + providers: readonly CloudProviderListEntry[]; providersLoading?: boolean; providersError?: string | null; /** GitHub owner list for the inline publish subpanel. Null when the @@ -436,8 +436,8 @@ export function DeployPanel({ icon={Cloud} label="Connect hosting" onClick={() => { - const firstEnabled = providers.find((p) => p.enabled); - if (firstEnabled) onConnectProvider(firstEnabled.id); + const firstProvider = providers[0]; + if (firstProvider) onConnectProvider(firstProvider.id); }} variant="primary" disabled={isWorking} @@ -456,8 +456,8 @@ export function DeployPanel({ // the provider list to hide it so we don't render two // "Cloudflare Pages" rows stacked on top of each other — // the user just sees a "Change provider" chevron that - // reveals the alternatives (and "Coming soon" stubs) - // when clicked. + // reveals the alternatives when clicked (and nothing at + // all while Cloudflare Pages is the only provider). hideSelected onSelectConnected={onSelectProvider} onSelectDisconnected={onConnectProvider} diff --git a/src/presentation/web/components/features/application-page/provider-dropdown.stories.tsx b/src/presentation/web/components/features/application-page/provider-dropdown.stories.tsx index e02cd443e..519fbb3d2 100644 --- a/src/presentation/web/components/features/application-page/provider-dropdown.stories.tsx +++ b/src/presentation/web/components/features/application-page/provider-dropdown.stories.tsx @@ -1,40 +1,19 @@ import type { Meta, StoryObj } from '@storybook/react'; import { CloudDeploymentProvider } from '@shepai/core/domain/generated/output'; import { Button } from '@/components/ui/button'; -import { ProviderDropdown, type CloudProviderListEntry } from './provider-dropdown'; +import { ProviderDropdown } from './provider-dropdown'; +import type { CloudProviderListEntry } from './cloud-providers'; -const ALL_PROVIDERS: CloudProviderListEntry[] = [ - { - id: CloudDeploymentProvider.CloudflarePages, - displayName: 'Cloudflare Pages', - enabled: true, - connected: true, - }, - { - id: CloudDeploymentProvider.Vercel, - displayName: 'Vercel', - enabled: true, - connected: false, - }, - { - id: CloudDeploymentProvider.Netlify, - displayName: 'Netlify', - enabled: true, - connected: false, - }, - { - id: CloudDeploymentProvider.AwsAmplify, - displayName: 'AWS Amplify', - enabled: false, - connected: false, - }, - { - id: CloudDeploymentProvider.GcpCloudRun, - displayName: 'Google Cloud Run', - enabled: false, - connected: false, - }, -]; +const CLOUDFLARE_CONNECTED: CloudProviderListEntry = { + id: CloudDeploymentProvider.CloudflarePages, + displayName: 'Cloudflare Pages', + connected: true, +}; + +const CLOUDFLARE_NOT_CONNECTED: CloudProviderListEntry = { + ...CLOUDFLARE_CONNECTED, + connected: false, +}; const meta: Meta = { title: 'ApplicationPage/ProviderDropdown', @@ -51,9 +30,9 @@ const defaultTrigger = ; export const Default: Story = { args: { trigger: defaultTrigger, - providers: ALL_PROVIDERS, + providers: [CLOUDFLARE_CONNECTED], selectedProvider: null, - onSelectEnabled: noopSelect, + onSelectConnected: noopSelect, onSelectDisconnected: noopSelect, }, }; @@ -61,44 +40,52 @@ export const Default: Story = { export const SelectedCloudflare: Story = { args: { trigger: defaultTrigger, - providers: ALL_PROVIDERS, + providers: [CLOUDFLARE_CONNECTED], selectedProvider: CloudDeploymentProvider.CloudflarePages, - onSelectEnabled: noopSelect, + onSelectConnected: noopSelect, onSelectDisconnected: noopSelect, }, }; -export const AllDisabled: Story = { +export const NoneConnected: Story = { args: { trigger: defaultTrigger, - providers: ALL_PROVIDERS.map((p) => ({ ...p, enabled: false, connected: false })), + providers: [CLOUDFLARE_NOT_CONNECTED], selectedProvider: null, - onSelectEnabled: noopSelect, + onSelectConnected: noopSelect, onSelectDisconnected: noopSelect, }, }; -export const NoneConnected: Story = { +export const WithEditTokenAffordance: Story = { args: { trigger: defaultTrigger, - providers: ALL_PROVIDERS.map((p) => ({ ...p, connected: false })), + providers: [CLOUDFLARE_CONNECTED], + selectedProvider: CloudDeploymentProvider.CloudflarePages, + onSelectConnected: noopSelect, + onSelectDisconnected: noopSelect, + onEditConnection: noopSelect, + }, +}; + +export const Loading: Story = { + args: { + trigger: defaultTrigger, + providers: [], selectedProvider: null, - onSelectEnabled: noopSelect, + loading: true, + onSelectConnected: noopSelect, onSelectDisconnected: noopSelect, }, }; -export const WithEditTokenAffordance: Story = { +export const LoadError: Story = { args: { trigger: defaultTrigger, - providers: ALL_PROVIDERS.map((p) => - p.id === CloudDeploymentProvider.CloudflarePages || p.id === CloudDeploymentProvider.Vercel - ? { ...p, connected: true } - : p - ), - selectedProvider: CloudDeploymentProvider.CloudflarePages, - onSelectEnabled: noopSelect, + providers: [], + selectedProvider: null, + loadError: 'Failed to load providers', + onSelectConnected: noopSelect, onSelectDisconnected: noopSelect, - onEditConnection: noopSelect, }, }; diff --git a/src/presentation/web/components/features/application-page/provider-dropdown.tsx b/src/presentation/web/components/features/application-page/provider-dropdown.tsx index 044ccb1b5..d588d7b84 100644 --- a/src/presentation/web/components/features/application-page/provider-dropdown.tsx +++ b/src/presentation/web/components/features/application-page/provider-dropdown.tsx @@ -5,9 +5,8 @@ * * Purely presentational: takes a `providers` list (fetched via * /api/cloud-providers) and renders each with its icon, display name, - * and state badge. Disabled providers show "Coming soon"; enabled-but- - * not-connected providers show "Not connected" and trigger the - * connect-provider modal when clicked; enabled-and-connected providers + * and state badge. Providers without a token show "Not connected" and + * trigger the connect-provider modal when clicked; connected providers * just emit onSelect for the parent button to run Deploy. */ @@ -22,13 +21,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { CLOUD_PROVIDER_ICONS } from './cloud-provider-icons'; - -export interface CloudProviderListEntry { - id: CloudDeploymentProvider; - displayName: string; - enabled: boolean; - connected: boolean; -} +import type { CloudProviderListEntry } from './cloud-providers'; export interface ProviderDropdownProps { trigger: React.ReactNode; @@ -36,7 +29,7 @@ export interface ProviderDropdownProps { selectedProvider: CloudDeploymentProvider | null; loading?: boolean; loadError?: string | null; - onSelectEnabled(provider: CloudDeploymentProvider): void; + onSelectConnected(provider: CloudDeploymentProvider): void; onSelectDisconnected(provider: CloudDeploymentProvider): void; onEditConnection?(provider: CloudDeploymentProvider): void; } @@ -47,7 +40,7 @@ export function ProviderDropdown({ selectedProvider, loading = false, loadError = null, - onSelectEnabled, + onSelectConnected, onSelectDisconnected, onEditConnection, }: ProviderDropdownProps) { @@ -68,42 +61,31 @@ export function ProviderDropdown({ ) : null} {providers.map((provider) => { const Icon = CLOUD_PROVIDER_ICONS[provider.id]; - const disabled = !provider.enabled; - const badge = !provider.enabled - ? 'Coming soon' - : provider.connected - ? 'Connected' - : 'Not connected'; + const badge = provider.connected ? 'Connected' : 'Not connected'; const selected = provider.id === selectedProvider; return ( { - if (disabled) return; if (provider.connected) { - onSelectEnabled(provider.id); + onSelectConnected(provider.id); } else { onSelectDisconnected(provider.id); } }} - className={`flex items-center gap-2 ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`} + className="flex cursor-pointer items-center gap-2" > {provider.displayName} {badge} {selected ? ● : null} - {provider.enabled && provider.connected && onEditConnection ? ( + {provider.connected && onEditConnection ? (