From bf04a2fcd6bd910077128e4c323900a2e806dbe6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 09:38:39 +0000 Subject: [PATCH 01/13] feat(specs): add 133-review-cleanup-cuts fast-track specification Claude-Session: https://claude.ai/code/session_01TxtN2VcWzg6NTCLj4DbmfL Co-Authored-By: Shep Bot --- specs/133-review-cleanup-cuts/feature.yaml | 42 ++++ specs/133-review-cleanup-cuts/plan.md | 45 +++++ specs/133-review-cleanup-cuts/plan.yaml | 123 ++++++++++++ specs/133-review-cleanup-cuts/research.md | 14 ++ specs/133-review-cleanup-cuts/research.yaml | 82 ++++++++ specs/133-review-cleanup-cuts/spec.md | 48 +++++ specs/133-review-cleanup-cuts/spec.yaml | 97 ++++++++++ specs/133-review-cleanup-cuts/tasks.md | 8 + specs/133-review-cleanup-cuts/tasks.yaml | 201 ++++++++++++++++++++ 9 files changed, 660 insertions(+) create mode 100644 specs/133-review-cleanup-cuts/feature.yaml create mode 100644 specs/133-review-cleanup-cuts/plan.md create mode 100644 specs/133-review-cleanup-cuts/plan.yaml create mode 100644 specs/133-review-cleanup-cuts/research.md create mode 100644 specs/133-review-cleanup-cuts/research.yaml create mode 100644 specs/133-review-cleanup-cuts/spec.md create mode 100644 specs/133-review-cleanup-cuts/spec.yaml create mode 100644 specs/133-review-cleanup-cuts/tasks.md create mode 100644 specs/133-review-cleanup-cuts/tasks.yaml diff --git a/specs/133-review-cleanup-cuts/feature.yaml b/specs/133-review-cleanup-cuts/feature.yaml new file mode 100644 index 000000000..1057422f1 --- /dev/null +++ b/specs/133-review-cleanup-cuts/feature.yaml @@ -0,0 +1,42 @@ +feature: + id: '133-review-cleanup-cuts' + name: 'review-cleanup-cuts' + number: 133 + branch: 'feat/133-review-cleanup-cuts' + lifecycle: 'implementation' + createdAt: '2026-10-09T09:35:49Z' + +status: + phase: 'ready-to-implement' + progress: + completed: 0 + total: 9 + percentage: 0 + currentTask: null + lastUpdated: '2026-10-09T10:00:00Z' + lastUpdatedBy: 'shep-kit:new-feature-fast' + +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' + +errors: + current: null + history: [] diff --git a/specs/133-review-cleanup-cuts/plan.md b/specs/133-review-cleanup-cuts/plan.md new file mode 100644 index 000000000..71c1193ec --- /dev/null +++ b/specs/133-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 166 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/133-review-cleanup-cuts/plan.yaml b/specs/133-review-cleanup-cuts/plan.yaml new file mode 100644 index 000000000..1c2647117 --- /dev/null +++ b/specs/133-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 133 — 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/166-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 166 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/133-review-cleanup-cuts/research.md b/specs/133-review-cleanup-cuts/research.md new file mode 100644 index 000000000..a4a9d7be3 --- /dev/null +++ b/specs/133-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/133-review-cleanup-cuts/research.yaml b/specs/133-review-cleanup-cuts/research.yaml new file mode 100644 index 000000000..f1fe3d292 --- /dev/null +++ b/specs/133-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 133 — 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/133-review-cleanup-cuts/spec.md b/specs/133-review-cleanup-cuts/spec.md new file mode 100644 index 000000000..dac6f22ab --- /dev/null +++ b/specs/133-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/133-review-cleanup-cuts/spec.yaml b/specs/133-review-cleanup-cuts/spec.yaml new file mode 100644 index 000000000..24c57cb47 --- /dev/null +++ b/specs/133-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: 133 +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/133-review-cleanup-cuts/tasks.md b/specs/133-review-cleanup-cuts/tasks.md new file mode 100644 index 000000000..76e6533bd --- /dev/null +++ b/specs/133-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/133-review-cleanup-cuts/tasks.yaml b/specs/133-review-cleanup-cuts/tasks.yaml new file mode 100644 index 000000000..5b49a1282 --- /dev/null +++ b/specs/133-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 133 \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: Todo + 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: Todo + 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: Todo + 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: Todo + 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: Todo + 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 166 (DEFAULT 1), mapper, repository + SQL and web flags state. + state: Todo + 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 166 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: Todo + 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: Todo + 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: Todo + 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" From bb81a67b61b0b66a9a0fe4c5f6f73ecb39b074e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 09:48:48 +0000 Subject: [PATCH 02/13] fix(cli): run contributor watchers from a GitHub Action, not the daemon The stale good-first-issue and monthly recap watchers (spec 097) ran inside every user's daemon. They are Shep-maintainer automation, so they now run from .github/workflows/contributor-maintenance.yml through two new subcommands, `shep contributors stale-issues` and `shep contributors recap`, which reuse the existing use cases. The watcher services are deleted. The contributor view (leaderboard, contributor doctor) moves from /onboarding to /contributors, linked from CONTRIBUTING.md and not from the sidebar. /onboarding keeps only the collaboration tutorial that the supervisor and agents pages link to, and leaves the sidebar. Claude-Session: https://claude.ai/code/session_01TxtN2VcWzg6NTCLj4DbmfL Co-Authored-By: Shep Bot --- .github/workflows/contributor-maintenance.yml | 106 ++++++++ CONTRIBUTING.md | 4 + .../src/domain/shared/previous-year-month.ts | 12 + .../monthly-recap-watcher.service.ts | 138 ---------- .../stale-good-first-issue-watcher.service.ts | 145 ----------- .../cli/commands/_serve.command.ts | 35 --- .../cli/commands/contributors/index.ts | 6 +- .../commands/contributors/recap.command.ts | 67 +++++ .../commands/contributors/repository-slug.ts | 6 + .../contributors/stale-issues.command.ts | 70 ++++++ src/presentation/cli/commands/ui.command.ts | 35 --- .../web/app/contributors/page.tsx | 98 ++++++++ src/presentation/web/app/onboarding/page.tsx | 88 +------ .../layouts/app-sidebar/app-sidebar.tsx | 7 - src/presentation/web/dev-server.ts | 43 ---- tests/e2e/web/contributor-onboarding.spec.ts | 4 +- tests/unit/commands/_serve.command.test.ts | 41 ++- .../contributors/recap.command.test.ts | 108 ++++++++ .../contributors/stale-issues.command.test.ts | 103 ++++++++ tests/unit/commands/index.integration.test.ts | 17 -- .../domain/shared/previous-year-month.test.ts | 12 + .../monthly-recap-watcher.service.test.ts | 178 ------------- ...e-good-first-issue-watcher.service.test.ts | 235 ------------------ .../cli/commands/ui.command.test.ts | 25 -- .../commands/workflow/serve-scheduler.test.ts | 16 -- .../web/layouts/app-sidebar.test.tsx | 12 + 26 files changed, 626 insertions(+), 985 deletions(-) create mode 100644 .github/workflows/contributor-maintenance.yml create mode 100644 packages/core/src/domain/shared/previous-year-month.ts delete mode 100644 packages/core/src/infrastructure/services/contributors/monthly-recap-watcher.service.ts delete mode 100644 packages/core/src/infrastructure/services/contributors/stale-good-first-issue-watcher.service.ts create mode 100644 src/presentation/cli/commands/contributors/recap.command.ts create mode 100644 src/presentation/cli/commands/contributors/repository-slug.ts create mode 100644 src/presentation/cli/commands/contributors/stale-issues.command.ts create mode 100644 src/presentation/web/app/contributors/page.tsx create mode 100644 tests/unit/commands/contributors/recap.command.test.ts create mode 100644 tests/unit/commands/contributors/stale-issues.command.test.ts create mode 100644 tests/unit/domain/shared/previous-year-month.test.ts delete mode 100644 tests/unit/infrastructure/services/contributors/monthly-recap-watcher.service.test.ts delete mode 100644 tests/unit/infrastructure/services/contributors/stale-good-first-issue-watcher.service.test.ts diff --git a/.github/workflows/contributor-maintenance.yml b/.github/workflows/contributor-maintenance.yml new file mode 100644 index 000000000..eaf171d27 --- /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 133). +# +# 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/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/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/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/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/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/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/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/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/components/layouts/app-sidebar/app-sidebar.tsx b/src/presentation/web/components/layouts/app-sidebar/app-sidebar.tsx index 49934e482..76a6a77f4 100644 --- a/src/presentation/web/components/layouts/app-sidebar/app-sidebar.tsx +++ b/src/presentation/web/components/layouts/app-sidebar/app-sidebar.tsx @@ -17,7 +17,6 @@ import { ShieldCheck, ShieldAlert, Bot, - GraduationCap, Users, Bug, Package, @@ -223,12 +222,6 @@ export function AppSidebar({ href: '/agents', active: pathname?.startsWith('/agents') ?? false, }, - { - icon: GraduationCap, - label: 'Get started', - href: '/onboarding', - active: pathname?.startsWith('/onboarding') ?? false, - }, ]} /> ) : null} diff --git a/src/presentation/web/dev-server.ts b/src/presentation/web/dev-server.ts index 4eafe9215..5827c6b0a 100644 --- a/src/presentation/web/dev-server.ts +++ b/src/presentation/web/dev-server.ts @@ -43,20 +43,6 @@ 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 { 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 { IMessagingService } from '@/application/ports/output/services/messaging-service.interface.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'; @@ -193,25 +179,6 @@ async function main() { 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(); - // Optionally start the messaging remote-control service. // Dev mode skips this by default because it opens a persistent // WebSocket tunnel to the gateway and is not something every @@ -337,16 +304,6 @@ async function main() { } catch { /* not initialized */ } - try { - getStaleGoodFirstIssueWatcher().stop(); - } catch { - /* not initialized */ - } - try { - getMonthlyRecapWatcher().stop(); - } catch { - /* not initialized */ - } try { const messagingService = container.resolve('IMessagingService'); await messagingService.stop(); diff --git a/tests/e2e/web/contributor-onboarding.spec.ts b/tests/e2e/web/contributor-onboarding.spec.ts index d03f505c0..080a9f6c1 100644 --- a/tests/e2e/web/contributor-onboarding.spec.ts +++ b/tests/e2e/web/contributor-onboarding.spec.ts @@ -1,7 +1,7 @@ /** * E2E: Contributor onboarding view smoke test (spec 097, task-48). * - * Verifies that GET /onboarding renders the contributor view when the + * Verifies that GET /contributors renders the contributor view when the * `collaboration` feature flag is on (flipped by global-setup): * - the four primary surfaces are present (lane chooser, leaderboard, * doctor summary, pick-issue prompt) @@ -17,7 +17,7 @@ import { test, expect } from '@playwright/test'; test.describe('Contributor onboarding view (spec 097)', () => { test('renders contributor view, lane selection updates curated issues', async ({ page }) => { - await page.goto('/onboarding'); + await page.goto('/contributors'); const isNotFound = await page .getByRole('heading', { name: 'Not Found' }) diff --git a/tests/unit/commands/_serve.command.test.ts b/tests/unit/commands/_serve.command.test.ts index 92b339717..b7b7a11fa 100644 --- a/tests/unit/commands/_serve.command.test.ts +++ b/tests/unit/commands/_serve.command.test.ts @@ -102,33 +102,6 @@ vi.mock('@/infrastructure/services/auto-archive/auto-archive-watcher.service.js' }), })); -// Mock contributor pipeline watchers (spec 097, FR-42) -vi.mock('@/infrastructure/services/contributors/stale-good-first-issue-watcher.service.js', () => ({ - initializeStaleGoodFirstIssueWatcher: vi.fn(), - getStaleGoodFirstIssueWatcher: vi.fn().mockReturnValue({ - start: vi.fn(), - stop: vi.fn(), - }), -})); -vi.mock('@/infrastructure/services/contributors/monthly-recap-watcher.service.js', () => ({ - initializeMonthlyRecapWatcher: vi.fn(), - getMonthlyRecapWatcher: vi.fn().mockReturnValue({ - start: vi.fn(), - stop: vi.fn(), - }), -})); - -// Mock contributor pipeline use cases (resolved via class tokens) -vi.mock('@/application/use-cases/contributors/detect-stale-good-first-issue.use-case.js', () => ({ - DetectStaleGoodFirstIssueUseCase: vi.fn(), -})); -vi.mock('@/application/use-cases/contributors/generate-monthly-recap.use-case.js', () => ({ - GenerateMonthlyRecapUseCase: vi.fn(), -})); -vi.mock('@/application/use-cases/contributors/publish-monthly-recap.use-case.js', () => ({ - PublishMonthlyRecapUseCase: vi.fn(), -})); - // Mock data-retention scheduling (spec 116) const retentionScheduler = vi.hoisted(() => ({ start: vi.fn(), stop: vi.fn() })); vi.mock('@/infrastructure/services/maintenance/retention-scheduler.js', () => ({ @@ -165,6 +138,7 @@ const mockMessagingService = { stop: vi.fn().mockResolvedValue(undefined), }; +import { container } from '@/infrastructure/di/container.js'; import { getNotificationWatcher } from '@/infrastructure/services/notifications/notification-watcher.service.js'; import { createServeCommand } from '../../../src/presentation/cli/commands/_serve.command.js'; @@ -227,6 +201,19 @@ describe('_serve command', () => { const watcher = getNotificationWatcher(); expect(watcher.start).toHaveBeenCalled(); }); + + it('does not run maintainer contributor automation in the user daemon', async () => { + const cmd = createServeCommand(); + await cmd.parseAsync(['--port', '4050'], { from: 'user' }); + const resolved = vi + .mocked(container.resolve) + .mock.calls.map(([token]) => + typeof token === 'function' ? (token as { name: string }).name : String(token) + ); + expect(resolved).not.toContain('DetectStaleGoodFirstIssueUseCase'); + expect(resolved).not.toContain('GenerateMonthlyRecapUseCase'); + expect(resolved).not.toContain('PublishMonthlyRecapUseCase'); + }); }); describe('data retention (spec 116)', () => { diff --git a/tests/unit/commands/contributors/recap.command.test.ts b/tests/unit/commands/contributors/recap.command.test.ts new file mode 100644 index 000000000..cdd3ab45c --- /dev/null +++ b/tests/unit/commands/contributors/recap.command.test.ts @@ -0,0 +1,108 @@ +/** + * `shep contributors recap` — the scheduled maintainer workflow's entry + * point that replaced the daemon's monthly recap watcher. + */ + +import 'reflect-metadata'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { RecapChannel } from '@/domain/generated/output.js'; + +const { mockGenerate, mockPublish, mockMessages, resolveMock } = vi.hoisted(() => { + const mockGenerate = { execute: vi.fn() }; + const mockPublish = { execute: vi.fn() }; + return { + mockGenerate, + mockPublish, + mockMessages: { success: vi.fn(), error: vi.fn(), info: vi.fn(), newline: vi.fn() }, + resolveMock: vi.fn(), + }; +}); + +vi.mock('@/infrastructure/di/container.js', () => ({ + container: { resolve: resolveMock }, +})); + +vi.mock('@/application/use-cases/contributors/generate-monthly-recap.use-case.js', () => ({ + GenerateMonthlyRecapUseCase: class GenerateMonthlyRecapUseCase {}, +})); + +vi.mock('@/application/use-cases/contributors/publish-monthly-recap.use-case.js', () => ({ + PublishMonthlyRecapUseCase: class PublishMonthlyRecapUseCase {}, +})); + +vi.mock('../../../../src/presentation/cli/ui/index.js', () => ({ + messages: mockMessages, +})); + +import { createRecapCommand } from '../../../../src/presentation/cli/commands/contributors/recap.command.js'; + +const artifact = { + recapId: '2026-09', + title: 'Shep — 2026-09 contributor recap', + body: '# recap', + periodStartIso: '2026-09-01T00:00:00.000Z', + periodEndIso: '2026-10-01T00:00:00.000Z', +}; + +describe('recap command', () => { + const originalExitCode = process.exitCode; + + beforeEach(() => { + vi.clearAllMocks(); + process.exitCode = 0; + resolveMock.mockImplementation((token: { name?: string }) => + token?.name === 'GenerateMonthlyRecapUseCase' ? mockGenerate : mockPublish + ); + mockGenerate.execute.mockResolvedValue({ artifact, stats: { totalEvents: 0 } }); + }); + + afterEach(() => { + process.exitCode = originalExitCode; + }); + + it('generates the requested month and publishes it to the file channel', async () => { + mockPublish.execute.mockResolvedValue({ + outcomes: [ + { channel: RecapChannel.File, status: 'published', reference: 'recaps/2026-09.md' }, + ], + }); + + await createRecapCommand().parseAsync(['node', 'recap', '--month', '2026-09']); + + expect(mockGenerate.execute).toHaveBeenCalledWith({ yearMonth: '2026-09' }); + expect(mockPublish.execute).toHaveBeenCalledWith({ + artifact, + targets: [{ channel: RecapChannel.File }], + }); + expect(mockMessages.success).toHaveBeenCalledWith(expect.stringContaining('recaps/2026-09.md')); + expect(process.exitCode).toBe(0); + }); + + it('defaults to the previous calendar month', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-10-01T03:00:00Z')); + mockPublish.execute.mockResolvedValue({ outcomes: [] }); + + await createRecapCommand().parseAsync(['node', 'recap']); + + vi.useRealTimers(); + expect(mockGenerate.execute).toHaveBeenCalledWith({ yearMonth: '2026-09' }); + }); + + it('rejects a malformed --month', async () => { + await createRecapCommand().parseAsync(['node', 'recap', '--month', 'September']); + + expect(mockGenerate.execute).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it('exits non-zero when a channel fails to publish', async () => { + mockPublish.execute.mockResolvedValue({ + outcomes: [{ channel: RecapChannel.File, status: 'failed', error: 'EACCES' }], + }); + + await createRecapCommand().parseAsync(['node', 'recap', '--month', '2026-09']); + + expect(process.exitCode).toBe(1); + }); +}); diff --git a/tests/unit/commands/contributors/stale-issues.command.test.ts b/tests/unit/commands/contributors/stale-issues.command.test.ts new file mode 100644 index 000000000..13ab8136a --- /dev/null +++ b/tests/unit/commands/contributors/stale-issues.command.test.ts @@ -0,0 +1,103 @@ +/** + * `shep contributors stale-issues` — the scheduled maintainer workflow's + * entry point that replaced the daemon's stale good-first-issue watcher. + */ + +import 'reflect-metadata'; +import { describe, it, expect, beforeEach, afterEach, vi, type MockInstance } from 'vitest'; + +const { mockUseCase, mockMessages } = vi.hoisted(() => ({ + mockUseCase: { execute: vi.fn() }, + mockMessages: { success: vi.fn(), error: vi.fn(), info: vi.fn(), newline: vi.fn() }, +})); + +vi.mock('@/infrastructure/di/container.js', () => ({ + container: { resolve: vi.fn().mockReturnValue(mockUseCase) }, +})); + +vi.mock('@/application/use-cases/contributors/detect-stale-good-first-issue.use-case.js', () => ({ + DetectStaleGoodFirstIssueUseCase: vi.fn(), +})); + +vi.mock('../../../../src/presentation/cli/ui/index.js', () => ({ + messages: mockMessages, +})); + +import { createStaleIssuesCommand } from '../../../../src/presentation/cli/commands/contributors/stale-issues.command.js'; + +describe('stale-issues command', () => { + const originalEnv = { ...process.env }; + const originalExitCode = process.exitCode; + let logSpy: MockInstance; + + beforeEach(() => { + vi.clearAllMocks(); + process.exitCode = 0; + logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + process.exitCode = originalExitCode; + logSpy.mockRestore(); + }); + + it('checks the --repo slug with the --days threshold and lists each stale issue', async () => { + mockUseCase.execute.mockResolvedValue({ + thresholdDays: 45, + stale: [ + { + owner: 'shep-ai', + repo: 'shep', + issueNumber: 7, + title: 'Add a flag', + url: 'https://github.com/shep-ai/shep/issues/7', + lastActivityAt: '2026-08-01T00:00:00Z', + staleForDays: 60, + }, + ], + }); + + await createStaleIssuesCommand().parseAsync([ + 'node', + 'stale-issues', + '--repo', + 'shep-ai/shep', + '--days', + '45', + ]); + + expect(mockUseCase.execute).toHaveBeenCalledWith({ + owner: 'shep-ai', + repo: 'shep', + staleDays: 45, + }); + const printed = logSpy.mock.calls.map((c) => String(c[0])).join('\n'); + expect(printed).toContain('#7'); + expect(printed).toContain('https://github.com/shep-ai/shep/issues/7'); + expect(process.exitCode).toBe(0); + }); + + it('falls back to GITHUB_REPOSITORY when --repo is omitted', async () => { + process.env.GITHUB_REPOSITORY = 'acme/widgets'; + mockUseCase.execute.mockResolvedValue({ thresholdDays: 30, stale: [] }); + + await createStaleIssuesCommand().parseAsync(['node', 'stale-issues']); + + expect(mockUseCase.execute).toHaveBeenCalledWith({ + owner: 'acme', + repo: 'widgets', + staleDays: undefined, + }); + expect(mockMessages.success).toHaveBeenCalled(); + }); + + it('fails with a non-zero exit code when no repository is given', async () => { + delete process.env.GITHUB_REPOSITORY; + + await createStaleIssuesCommand().parseAsync(['node', 'stale-issues']); + + expect(mockUseCase.execute).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/tests/unit/commands/index.integration.test.ts b/tests/unit/commands/index.integration.test.ts index d1ce759bb..1a870a9aa 100644 --- a/tests/unit/commands/index.integration.test.ts +++ b/tests/unit/commands/index.integration.test.ts @@ -75,23 +75,6 @@ vi.mock('@/infrastructure/services/auto-archive/auto-archive-watcher.service.js' initializeAutoArchiveWatcher: vi.fn(), getAutoArchiveWatcher: vi.fn().mockReturnValue({ start: vi.fn(), stop: vi.fn() }), })); -vi.mock('@/infrastructure/services/contributors/stale-good-first-issue-watcher.service.js', () => ({ - initializeStaleGoodFirstIssueWatcher: vi.fn(), - getStaleGoodFirstIssueWatcher: vi.fn().mockReturnValue({ start: vi.fn(), stop: vi.fn() }), -})); -vi.mock('@/infrastructure/services/contributors/monthly-recap-watcher.service.js', () => ({ - initializeMonthlyRecapWatcher: vi.fn(), - getMonthlyRecapWatcher: vi.fn().mockReturnValue({ start: vi.fn(), stop: vi.fn() }), -})); -vi.mock('@/application/use-cases/contributors/detect-stale-good-first-issue.use-case.js', () => ({ - DetectStaleGoodFirstIssueUseCase: vi.fn(), -})); -vi.mock('@/application/use-cases/contributors/generate-monthly-recap.use-case.js', () => ({ - GenerateMonthlyRecapUseCase: vi.fn(), -})); -vi.mock('@/application/use-cases/contributors/publish-monthly-recap.use-case.js', () => ({ - PublishMonthlyRecapUseCase: vi.fn(), -})); import { startDaemon } from '../../../src/presentation/cli/commands/daemon/start-daemon.js'; import { createStartCommand } from '../../../src/presentation/cli/commands/start.command.js'; diff --git a/tests/unit/domain/shared/previous-year-month.test.ts b/tests/unit/domain/shared/previous-year-month.test.ts new file mode 100644 index 000000000..48af0cd4d --- /dev/null +++ b/tests/unit/domain/shared/previous-year-month.test.ts @@ -0,0 +1,12 @@ +import { describe, it, expect } from 'vitest'; +import { previousYearMonth } from '@/domain/shared/previous-year-month.js'; + +describe('previousYearMonth', () => { + it('rolls back into the previous month', () => { + expect(previousYearMonth(new Date('2026-05-15T12:00:00Z'))).toBe('2026-04'); + }); + + it('rolls back across the year boundary', () => { + expect(previousYearMonth(new Date('2026-01-01T00:00:00Z'))).toBe('2025-12'); + }); +}); diff --git a/tests/unit/infrastructure/services/contributors/monthly-recap-watcher.service.test.ts b/tests/unit/infrastructure/services/contributors/monthly-recap-watcher.service.test.ts deleted file mode 100644 index 62aa9eb93..000000000 --- a/tests/unit/infrastructure/services/contributors/monthly-recap-watcher.service.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * Integration tests for the monthly contributor recap watcher. - * - * Uses a fake clock to assert that the watcher dispatches the - * generate + publish use cases exactly once for the previous calendar - * month per process, even when the poll interval fires multiple times. - */ - -import 'reflect-metadata'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -import { - MonthlyRecapWatcherService, - initializeMonthlyRecapWatcher, - getMonthlyRecapWatcher, - hasMonthlyRecapWatcher, - resetMonthlyRecapWatcher, - previousYearMonth, -} from '@/infrastructure/services/contributors/monthly-recap-watcher.service.js'; -import { RecapChannel } from '@/domain/generated/output.js'; - -function makeGenerate(yearMonth: string) { - return { - execute: vi.fn().mockResolvedValue({ - artifact: { - recapId: yearMonth, - title: `Shep — ${yearMonth} contributor recap`, - body: '# stub', - periodStartIso: `${yearMonth}-01T00:00:00.000Z`, - }, - stats: { totalEvents: 0, newContributors: 0, prsRecognized: 0 }, - }), - }; -} - -function makePublish() { - return { - execute: vi.fn().mockResolvedValue({ - outcomes: [{ channel: RecapChannel.File, status: 'published', reference: '/tmp/recap.md' }], - }), - }; -} - -describe('previousYearMonth helper', () => { - it('rolls back into the previous month', () => { - expect(previousYearMonth(new Date('2026-05-15T12:00:00Z'))).toBe('2026-04'); - }); - - it('rolls back across year boundary', () => { - expect(previousYearMonth(new Date('2026-01-01T00:00:00Z'))).toBe('2025-12'); - }); -}); - -describe('MonthlyRecapWatcherService', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - resetMonthlyRecapWatcher(); - }); - - it('dispatches generate + publish once per previous month', async () => { - const generate = makeGenerate('2026-04'); - const publish = makePublish(); - const watcher = new MonthlyRecapWatcherService({ - generate: generate as never, - publish: publish as never, - now: () => new Date('2026-05-06T00:00:00Z'), - pollIntervalMs: 1000, - }); - - watcher.start(); - await vi.advanceTimersByTimeAsync(0); - - expect(generate.execute).toHaveBeenCalledWith({ yearMonth: '2026-04' }); - expect(publish.execute).toHaveBeenCalledTimes(1); - - // A subsequent tick within the same calendar month must not re-publish. - await vi.advanceTimersByTimeAsync(1000); - expect(generate.execute).toHaveBeenCalledTimes(1); - expect(publish.execute).toHaveBeenCalledTimes(1); - watcher.stop(); - }); - - it('does not double-publish when storage reports recap already shipped', async () => { - const generate = makeGenerate('2026-04'); - const publish = makePublish(); - const watcher = new MonthlyRecapWatcherService({ - generate: generate as never, - publish: publish as never, - now: () => new Date('2026-05-06T00:00:00Z'), - pollIntervalMs: 1000, - recapAlreadyPublished: vi.fn().mockResolvedValue(true), - }); - - watcher.start(); - await vi.advanceTimersByTimeAsync(0); - - expect(generate.execute).not.toHaveBeenCalled(); - expect(publish.execute).not.toHaveBeenCalled(); - watcher.stop(); - }); - - it('isolates failures so subsequent polls still try', async () => { - const generate = { - execute: vi - .fn() - .mockRejectedValueOnce(new Error('transient')) - .mockResolvedValueOnce({ - artifact: { - recapId: '2026-04', - title: 't', - body: 'b', - periodStartIso: '2026-04-01T00:00:00.000Z', - }, - stats: { totalEvents: 0, newContributors: 0, prsRecognized: 0 }, - }), - }; - const publish = makePublish(); - const watcher = new MonthlyRecapWatcherService({ - generate: generate as never, - publish: publish as never, - now: () => new Date('2026-05-06T00:00:00Z'), - pollIntervalMs: 1000, - }); - - watcher.start(); - await vi.advanceTimersByTimeAsync(0); - expect(publish.execute).not.toHaveBeenCalled(); - - await vi.advanceTimersByTimeAsync(1000); - expect(publish.execute).toHaveBeenCalledTimes(1); - watcher.stop(); - }); - - it('stops cleanly', () => { - const watcher = new MonthlyRecapWatcherService({ - generate: makeGenerate('2026-04') as never, - publish: makePublish() as never, - now: () => new Date('2026-05-06T00:00:00Z'), - pollIntervalMs: 1000, - }); - watcher.start(); - expect(watcher.isRunning()).toBe(true); - watcher.stop(); - expect(watcher.isRunning()).toBe(false); - }); -}); - -describe('Monthly recap watcher singleton', () => { - afterEach(() => { - resetMonthlyRecapWatcher(); - }); - - it('initializes once', () => { - initializeMonthlyRecapWatcher({ - generate: makeGenerate('2026-04') as never, - publish: makePublish() as never, - }); - expect(hasMonthlyRecapWatcher()).toBe(true); - expect(getMonthlyRecapWatcher()).toBeInstanceOf(MonthlyRecapWatcherService); - }); - - it('throws on double init', () => { - initializeMonthlyRecapWatcher({ - generate: makeGenerate('2026-04') as never, - publish: makePublish() as never, - }); - expect(() => - initializeMonthlyRecapWatcher({ - generate: makeGenerate('2026-04') as never, - publish: makePublish() as never, - }) - ).toThrow(/already initialized/i); - }); -}); diff --git a/tests/unit/infrastructure/services/contributors/stale-good-first-issue-watcher.service.test.ts b/tests/unit/infrastructure/services/contributors/stale-good-first-issue-watcher.service.test.ts deleted file mode 100644 index 68aeb3b1e..000000000 --- a/tests/unit/infrastructure/services/contributors/stale-good-first-issue-watcher.service.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -/** - * Integration tests for the stale-good-first-issue watcher. - * - * Asserts that the watcher dispatches `DetectStaleGoodFirstIssueUseCase` - * once per connected repository on the configured interval, fans out - * via `Promise.allSettled`-style isolation (a single repo failure does - * not block siblings), and surfaces results via `IDesktopNotifier`. - */ - -import 'reflect-metadata'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -import { - StaleGoodFirstIssueWatcherService, - initializeStaleGoodFirstIssueWatcher, - getStaleGoodFirstIssueWatcher, - hasStaleGoodFirstIssueWatcher, - resetStaleGoodFirstIssueWatcher, -} from '@/infrastructure/services/contributors/stale-good-first-issue-watcher.service.js'; -import type { IDesktopNotifier } from '@/application/ports/output/services/i-desktop-notifier.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 { Repository } from '@/domain/generated/output.js'; -import type { DetectStaleGoodFirstIssueUseCase } from '@/application/use-cases/contributors/detect-stale-good-first-issue.use-case.js'; - -function makeRepoRow(overrides: Partial): Repository { - return { - id: overrides.id ?? 'repo-1', - name: overrides.name ?? 'shep', - path: overrides.path ?? '/tmp/shep', - remoteUrl: overrides.remoteUrl, - createdAt: new Date(), - updatedAt: new Date(), - ...overrides, - } as Repository; -} - -function makeMockRepositoryRepo(rows: Repository[]): IRepositoryRepository { - return { - create: vi.fn(), - findById: vi.fn(), - findByPath: vi.fn(), - findByPathIncludingDeleted: vi.fn(), - findByRemoteUrl: vi.fn(), - findByUpstreamUrl: vi.fn(), - list: vi.fn().mockResolvedValue(rows), - remove: vi.fn(), - softDelete: vi.fn(), - restore: vi.fn(), - update: vi.fn(), - }; -} - -function makeMockGitHubService(): IGitHubRepositoryService { - return { - parseGitHubUrl: vi.fn().mockImplementation((url: string) => { - const match = /github\.com\/([^/]+)\/([^/.]+)/.exec(url); - if (!match) throw new Error(`unparseable: ${url}`); - return { owner: match[1], repo: match[2], nameWithOwner: `${match[1]}/${match[2]}` }; - }), - } as unknown as IGitHubRepositoryService; -} - -function makeMockNotifier(): IDesktopNotifier { - return { send: vi.fn() } as unknown as IDesktopNotifier; -} - -function makeMockUseCase(): DetectStaleGoodFirstIssueUseCase { - return { execute: vi.fn() } as unknown as DetectStaleGoodFirstIssueUseCase; -} - -describe('StaleGoodFirstIssueWatcherService', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - resetStaleGoodFirstIssueWatcher(); - }); - - it('dispatches the use case for each connected repo with a remoteUrl', async () => { - const repos = [ - makeRepoRow({ id: 'r1', remoteUrl: 'https://github.com/shep-ai/shep' }), - makeRepoRow({ id: 'r2', remoteUrl: 'https://github.com/shep-ai/other' }), - makeRepoRow({ id: 'r3', remoteUrl: undefined }), - ]; - const repoRepo = makeMockRepositoryRepo(repos); - const ghService = makeMockGitHubService(); - const notifier = makeMockNotifier(); - const useCase = makeMockUseCase(); - vi.mocked(useCase.execute).mockResolvedValue({ thresholdDays: 30, stale: [] }); - - const watcher = new StaleGoodFirstIssueWatcherService( - useCase, - repoRepo, - ghService, - notifier, - 1000 - ); - watcher.start(); - await vi.advanceTimersByTimeAsync(0); - - expect(useCase.execute).toHaveBeenCalledTimes(2); - expect(useCase.execute).toHaveBeenCalledWith( - expect.objectContaining({ owner: 'shep-ai', repo: 'shep' }) - ); - expect(useCase.execute).toHaveBeenCalledWith( - expect.objectContaining({ owner: 'shep-ai', repo: 'other' }) - ); - watcher.stop(); - }); - - it('invokes the notifier when stale issues are found', async () => { - const repoRepo = makeMockRepositoryRepo([ - makeRepoRow({ remoteUrl: 'https://github.com/owner/repo' }), - ]); - const ghService = makeMockGitHubService(); - const notifier = makeMockNotifier(); - const useCase = makeMockUseCase(); - vi.mocked(useCase.execute).mockResolvedValue({ - thresholdDays: 30, - stale: [ - { - owner: 'owner', - repo: 'repo', - issueNumber: 1, - title: 'old', - url: 'u', - lastActivityAt: '2026-01-01T00:00:00Z', - staleForDays: 100, - }, - ], - }); - - const watcher = new StaleGoodFirstIssueWatcherService( - useCase, - repoRepo, - ghService, - notifier, - 1000 - ); - watcher.start(); - await vi.advanceTimersByTimeAsync(0); - - expect(notifier.send).toHaveBeenCalledTimes(1); - watcher.stop(); - }); - - it('continues after a per-repo failure', async () => { - const repoRepo = makeMockRepositoryRepo([ - makeRepoRow({ id: 'r1', remoteUrl: 'https://github.com/a/x' }), - makeRepoRow({ id: 'r2', remoteUrl: 'https://github.com/b/y' }), - ]); - const ghService = makeMockGitHubService(); - const notifier = makeMockNotifier(); - const useCase = makeMockUseCase(); - vi.mocked(useCase.execute) - .mockRejectedValueOnce(new Error('boom')) - .mockResolvedValueOnce({ thresholdDays: 30, stale: [] }); - - const watcher = new StaleGoodFirstIssueWatcherService( - useCase, - repoRepo, - ghService, - notifier, - 1000 - ); - watcher.start(); - await vi.advanceTimersByTimeAsync(0); - - expect(useCase.execute).toHaveBeenCalledTimes(2); - watcher.stop(); - }); - - it('polls again on the configured interval', async () => { - const repoRepo = makeMockRepositoryRepo([makeRepoRow({ remoteUrl: 'https://github.com/a/x' })]); - const ghService = makeMockGitHubService(); - const useCase = makeMockUseCase(); - vi.mocked(useCase.execute).mockResolvedValue({ thresholdDays: 30, stale: [] }); - - const watcher = new StaleGoodFirstIssueWatcherService( - useCase, - repoRepo, - ghService, - makeMockNotifier(), - 1000 - ); - watcher.start(); - await vi.advanceTimersByTimeAsync(0); - expect(useCase.execute).toHaveBeenCalledTimes(1); - - await vi.advanceTimersByTimeAsync(1000); - expect(useCase.execute).toHaveBeenCalledTimes(2); - watcher.stop(); - }); -}); - -describe('Stale good-first-issue watcher singleton', () => { - afterEach(() => { - resetStaleGoodFirstIssueWatcher(); - }); - - it('is uninitialized initially', () => { - expect(hasStaleGoodFirstIssueWatcher()).toBe(false); - }); - - it('initializes once', () => { - initializeStaleGoodFirstIssueWatcher( - makeMockUseCase(), - makeMockRepositoryRepo([]), - makeMockGitHubService(), - makeMockNotifier() - ); - expect(hasStaleGoodFirstIssueWatcher()).toBe(true); - expect(getStaleGoodFirstIssueWatcher()).toBeInstanceOf(StaleGoodFirstIssueWatcherService); - }); - - it('throws on double init', () => { - initializeStaleGoodFirstIssueWatcher( - makeMockUseCase(), - makeMockRepositoryRepo([]), - makeMockGitHubService(), - makeMockNotifier() - ); - expect(() => - initializeStaleGoodFirstIssueWatcher( - makeMockUseCase(), - makeMockRepositoryRepo([]), - makeMockGitHubService(), - makeMockNotifier() - ) - ).toThrow(/already initialized/i); - }); -}); diff --git a/tests/unit/presentation/cli/commands/ui.command.test.ts b/tests/unit/presentation/cli/commands/ui.command.test.ts index b08c9fbc5..3c83be366 100644 --- a/tests/unit/presentation/cli/commands/ui.command.test.ts +++ b/tests/unit/presentation/cli/commands/ui.command.test.ts @@ -114,31 +114,6 @@ vi.mock('@/infrastructure/services/auto-archive/auto-archive-watcher.service.js' }), })); -// Mock contributor pipeline watchers (spec 097, FR-42) -vi.mock('@/infrastructure/services/contributors/stale-good-first-issue-watcher.service.js', () => ({ - initializeStaleGoodFirstIssueWatcher: vi.fn(), - getStaleGoodFirstIssueWatcher: vi.fn().mockReturnValue({ - start: vi.fn(), - stop: vi.fn(), - }), -})); -vi.mock('@/infrastructure/services/contributors/monthly-recap-watcher.service.js', () => ({ - initializeMonthlyRecapWatcher: vi.fn(), - getMonthlyRecapWatcher: vi.fn().mockReturnValue({ - start: vi.fn(), - stop: vi.fn(), - }), -})); -vi.mock('@/application/use-cases/contributors/detect-stale-good-first-issue.use-case.js', () => ({ - DetectStaleGoodFirstIssueUseCase: vi.fn(), -})); -vi.mock('@/application/use-cases/contributors/generate-monthly-recap.use-case.js', () => ({ - GenerateMonthlyRecapUseCase: vi.fn(), -})); -vi.mock('@/application/use-cases/contributors/publish-monthly-recap.use-case.js', () => ({ - PublishMonthlyRecapUseCase: vi.fn(), -})); - // Mock data-retention scheduling (spec 116) const retentionScheduler = vi.hoisted(() => ({ start: vi.fn(), stop: vi.fn() })); vi.mock('@/infrastructure/services/maintenance/retention-scheduler.js', () => ({ diff --git a/tests/unit/presentation/cli/commands/workflow/serve-scheduler.test.ts b/tests/unit/presentation/cli/commands/workflow/serve-scheduler.test.ts index 189d8a8f6..151d1d218 100644 --- a/tests/unit/presentation/cli/commands/workflow/serve-scheduler.test.ts +++ b/tests/unit/presentation/cli/commands/workflow/serve-scheduler.test.ts @@ -108,22 +108,6 @@ vi.mock('@/infrastructure/services/auto-archive/auto-archive-watcher.service.js' }), })); -vi.mock('@/infrastructure/services/contributors/stale-good-first-issue-watcher.service.js', () => ({ - initializeStaleGoodFirstIssueWatcher: vi.fn(), - getStaleGoodFirstIssueWatcher: vi.fn().mockReturnValue({ - start: vi.fn(), - stop: vi.fn(), - }), -})); - -vi.mock('@/infrastructure/services/contributors/monthly-recap-watcher.service.js', () => ({ - initializeMonthlyRecapWatcher: vi.fn(), - getMonthlyRecapWatcher: vi.fn().mockReturnValue({ - start: vi.fn(), - stop: vi.fn(), - }), -})); - vi.mock('@/infrastructure/services/workflow-scheduler/workflow-scheduler.service.js', () => ({ initializeWorkflowScheduler: vi.fn(), getWorkflowScheduler: vi.fn().mockReturnValue({ diff --git a/tests/unit/presentation/web/layouts/app-sidebar.test.tsx b/tests/unit/presentation/web/layouts/app-sidebar.test.tsx index e205e4d7e..556d3244a 100644 --- a/tests/unit/presentation/web/layouts/app-sidebar.test.tsx +++ b/tests/unit/presentation/web/layouts/app-sidebar.test.tsx @@ -101,6 +101,18 @@ const mockFeatures = [ ]; describe('AppSidebar', () => { + it('keeps the contributor onboarding page out of the end-user sidebar', async () => { + const { container } = renderWithSidebar( + + ); + + await userEvent.click(screen.getByText('Collaboration')); + expect(container.querySelector('a[href="/agent-questions"]')).not.toBeNull(); + expect(container.querySelector('a[href="/onboarding"]')).toBeNull(); + expect(container.querySelector('a[href="/contributors"]')).toBeNull(); + expect(screen.queryByText('Get started')).toBeNull(); + }); + it('renders Control Center nav item in header', () => { renderWithSidebar(); From 2810b715b7840b633e025cd06c0b525a72343a4a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 10:04:03 +0000 Subject: [PATCH 03/13] fix(config): default the ASPM feature flag to off ROADMAP said ASPM ships behind a flag, but the flag defaulted to on. ASPM is a separate product category, so new installs now start with featureFlags.aspm = false (TypeSpec default, defaults factory and the web env fallback). Values users already persisted are left as they are: no migration touches feature_flag_aspm. The e2e suites that visit /aspm turn the flag on from Settings for the test and restore it afterwards. Claude-Session: https://claude.ai/code/session_01TxtN2VcWzg6NTCLj4DbmfL Co-Authored-By: Shep Bot --- ROADMAP.md | 2 +- apis/json-schema/FeatureFlags.yaml | 4 +- docs/FEATURES.md | 2 +- .../factories/settings-defaults.factory.ts | 2 +- packages/core/src/domain/generated/output.ts | 2 +- src/presentation/web/lib/feature-flags.ts | 5 +- .../e2e/web/aspm-dashboard-happy-path.spec.ts | 10 +++ tests/e2e/web/helpers/feature-flag-toggle.ts | 24 ++++++ tests/e2e/web/ui-experience.spec.ts | 26 +++--- tests/e2e/web/ui-populated-experience.spec.ts | 80 ++++++++++--------- .../sqlite-settings.repository.test.ts | 12 +++ .../settings-defaults.factory.test.ts | 5 +- .../web/lib/feature-flags.test.ts | 10 ++- tsp/domain/entities/settings.tsp | 4 +- 14 files changed, 121 insertions(+), 67 deletions(-) create mode 100644 tests/e2e/web/helpers/feature-flag-toggle.ts 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/apis/json-schema/FeatureFlags.yaml b/apis/json-schema/FeatureFlags.yaml index 9e1cb5cad..c3238d727 100644 --- a/apis/json-schema/FeatureFlags.yaml +++ b/apis/json-schema/FeatureFlags.yaml @@ -36,8 +36,8 @@ 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 133)." clusters: type: boolean default: false diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 33b037cc5..bdc533e9d 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -369,7 +369,7 @@ Toggle experimental features. Several of them gate whole command groups: | `collaboration` | Supervisor agent, agent questions and messages | | `bedrockIntegration` | `shep bedrock` | | `whatsappDispatch` | `shep whatsapp` | -| `aspm` | `shep aspm` | +| `aspm` | `shep aspm`, `/aspm` — off by default | | `clusters` | `shep cluster` | | `supplyChainSecurity` | `shep security enforce` | | `scheduledWorkflows` | `shep workflow` | diff --git a/packages/core/src/domain/factories/settings-defaults.factory.ts b/packages/core/src/domain/factories/settings-defaults.factory.ts index 671677143..d88a55bac 100644 --- a/packages/core/src/domain/factories/settings-defaults.factory.ts +++ b/packages/core/src/domain/factories/settings-defaults.factory.ts @@ -238,7 +238,7 @@ export function createDefaultSettings(): Settings { projects: true, codeReview: true, collaboration: true, - aspm: true, + aspm: false, bedrockIntegration: true, whatsappDispatch: false, clusters: false, diff --git a/packages/core/src/domain/generated/output.ts b/packages/core/src/domain/generated/output.ts index 52d64eab3..cc67a302a 100644 --- a/packages/core/src/domain/generated/output.ts +++ b/packages/core/src/domain/generated/output.ts @@ -756,7 +756,7 @@ 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 133). */ aspm: boolean; /** diff --git a/src/presentation/web/lib/feature-flags.ts b/src/presentation/web/lib/feature-flags.ts index 948fb8e43..3bbc167ca 100644 --- a/src/presentation/web/lib/feature-flags.ts +++ b/src/presentation/web/lib/feature-flags.ts @@ -73,10 +73,7 @@ export function getFeatureFlags(): FeatureFlagsState { process.env.NEXT_PUBLIC_FLAG_COLLABORATION !== undefined ? isEnabled(process.env.NEXT_PUBLIC_FLAG_COLLABORATION) : true, - aspm: - process.env.NEXT_PUBLIC_FLAG_ASPM !== undefined - ? isEnabled(process.env.NEXT_PUBLIC_FLAG_ASPM) - : true, + aspm: isEnabled(process.env.NEXT_PUBLIC_FLAG_ASPM), bedrockIntegration: isEnabled(process.env.NEXT_PUBLIC_FLAG_BEDROCK_INTEGRATION), whatsappDispatch: isEnabled(process.env.NEXT_PUBLIC_FLAG_WHATSAPP_DISPATCH), clusters: false, diff --git a/tests/e2e/web/aspm-dashboard-happy-path.spec.ts b/tests/e2e/web/aspm-dashboard-happy-path.spec.ts index d406d4a84..85ae583e1 100644 --- a/tests/e2e/web/aspm-dashboard-happy-path.spec.ts +++ b/tests/e2e/web/aspm-dashboard-happy-path.spec.ts @@ -21,6 +21,7 @@ import { test, expect, type Page } from '@playwright/test'; import type Database from 'better-sqlite3'; import { randomUUID } from 'node:crypto'; import { openShepDb } from './helpers/collaboration-flag'; +import { enableFeatureFlag } from './helpers/feature-flag-toggle'; const TEST_APP_ID = `aspm-e2e-app-${randomUUID().slice(0, 8)}`; const TEST_APP_SLUG = `aspm-e2e-${randomUUID().slice(0, 8)}`; @@ -105,8 +106,17 @@ test.describe('ASPM dashboard happy path', () => { } }); + let restoreAspm: (() => Promise) | null = null; + + test.afterEach(async () => { + await restoreAspm?.(); + restoreAspm = null; + }); + test('dashboard → findings → detail → declare exception', async ({ page }) => { await mockDeclareExceptionRoute(page); + // ASPM is off by default (spec 133); turn it on for this test. + restoreAspm = await enableFeatureFlag(page, 'aspm'); // Step 1: open the dashboard and see the posture cards. await page.goto('/aspm'); diff --git a/tests/e2e/web/helpers/feature-flag-toggle.ts b/tests/e2e/web/helpers/feature-flag-toggle.ts new file mode 100644 index 000000000..9049fca9b --- /dev/null +++ b/tests/e2e/web/helpers/feature-flag-toggle.ts @@ -0,0 +1,24 @@ +import { expect, type Page } from '@playwright/test'; + +/** + * Turns a feature flag on from the Settings page and returns a function that + * turns it back off (a no-op when the flag was already on). + * + * The dev server caches Settings in memory, so writing the SQLite row after + * boot does not reach it; the Settings toggle updates the live process. + */ +export async function enableFeatureFlag(page: Page, flag: string): Promise<() => Promise> { + await page.goto('/settings'); + const toggle = page.getByTestId(`switch-flag-${flag}`); + await expect(toggle).toBeEnabled(); + if ((await toggle.getAttribute('data-state')) === 'checked') { + return async () => undefined; + } + await toggle.click(); + await expect(page.getByRole('status').filter({ hasText: 'Saved' })).toBeVisible(); + return async () => { + await page.goto('/settings'); + await page.getByTestId(`switch-flag-${flag}`).click(); + await expect(page.getByRole('status').filter({ hasText: 'Saved' })).toBeVisible(); + }; +} diff --git a/tests/e2e/web/ui-experience.spec.ts b/tests/e2e/web/ui-experience.spec.ts index 93c3ff016..190ee89ae 100644 --- a/tests/e2e/web/ui-experience.spec.ts +++ b/tests/e2e/web/ui-experience.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from '@playwright/test'; import { expectAccessible } from './helpers/ui-accessibility'; +import { enableFeatureFlag } from './helpers/feature-flag-toggle'; // These checks invoke no installed agent. Workflow coverage temporarily enables // its feature flag through the UI and restores the previous value afterward. @@ -21,6 +22,12 @@ test.describe('web experience regressions', () => { await expect(search).not.toBeVisible(); }); + // Routes behind a default-off flag, enabled for the test and restored after. + const ROUTE_FLAGS: Record = { + '/workflows': 'scheduledWorkflows', + '/aspm/compliance': 'aspm', + }; + for (const route of [ '/features', '/tools', @@ -33,17 +40,8 @@ test.describe('web experience regressions', () => { ]) { test(`${route} has no automated accessibility violations in dark mode`, async ({ page }) => { await page.addInitScript(() => localStorage.setItem('shep-theme', 'dark')); - let restoreWorkflows = false; - if (route === '/workflows') { - await page.goto('/settings'); - const toggle = page.getByTestId('switch-flag-scheduledWorkflows'); - await expect(toggle).toBeEnabled(); - if ((await toggle.getAttribute('data-state')) === 'unchecked') { - restoreWorkflows = true; - await toggle.click(); - await expect(page.getByRole('status').filter({ hasText: 'Saved' })).toBeVisible(); - } - } + const flag = ROUTE_FLAGS[route]; + const restoreFlag = flag ? await enableFeatureFlag(page, flag) : null; try { await page.goto(route); expect(new URL(page.url()).pathname).toBe(route); @@ -55,11 +53,7 @@ test.describe('web experience regressions', () => { } await expectAccessible(page); } finally { - if (restoreWorkflows) { - await page.goto('/settings'); - await page.getByTestId('switch-flag-scheduledWorkflows').click(); - await expect(page.getByRole('status').filter({ hasText: 'Saved' })).toBeVisible(); - } + await restoreFlag?.(); } }); } diff --git a/tests/e2e/web/ui-populated-experience.spec.ts b/tests/e2e/web/ui-populated-experience.spec.ts index 85ff42f5e..c927e554a 100644 --- a/tests/e2e/web/ui-populated-experience.spec.ts +++ b/tests/e2e/web/ui-populated-experience.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { expectAccessible } from './helpers/ui-accessibility'; +import { enableFeatureFlag } from './helpers/feature-flag-toggle'; import { seedUiExperience } from './helpers/ui-experience-data'; test.describe('populated page accessibility', () => { @@ -154,44 +155,51 @@ test.describe('populated page accessibility', () => { localStorage.setItem('shep-theme', value); localStorage.setItem('shep:collaboration-onboarding-dismissed', 'true'); }, theme); - await page.goto(routes[surface]); - await expect(page.getByRole('main')).toBeVisible(); - await expect(page.getByRole('main')).not.toHaveText(''); - if (surface === 'repository') - await expect(page.getByTestId('repository-drawer')).toBeVisible(); - if (surface === 'feature') - await expect(page.getByTestId('feature-drawer-actions')).toBeVisible(); - if (surface === 'application') { - await expect(page.getByText('No file open', { exact: true })).toBeVisible(); - await expect(page.getByRole('combobox', { name: 'Agent and model' })).toBeEnabled(); - } - if (surface === 'agent-editor') - await expect(page.getByTestId('prompt-textarea-implement.system')).toBeVisible(); - if (surface === 'security-inventory') - await expect(page.getByText('Never', { exact: true }).first()).toBeVisible(); - if (surface === 'control-center') { - await expect(page.getByTestId('feature-node-title').first()).toBeVisible(); - const repository = page.getByTestId('repository-node-card').filter({ - has: page - .getByTestId('repository-node-name') - .filter({ hasText: 'UI review standalone repository' }), - }); - await expect(repository).toBeVisible(); - for (const name of ['Chat with agent', 'View sessions', 'New feature']) { - const control = repository.getByRole('button', { name, exact: true }); - await expect(control).toBeVisible(); - const bounds = (await control.boundingBox())!; - expect( - bounds.width, - `${name} target width after canvas scaling` - ).toBeGreaterThanOrEqual(24); - expect( - bounds.height, - `${name} target height after canvas scaling` - ).toBeGreaterThanOrEqual(24); + // ASPM is off by default (spec 133); turn it on for its surface. + const restoreFlag = + surface === 'security-inventory' ? await enableFeatureFlag(page, 'aspm') : null; + try { + await page.goto(routes[surface]); + await expect(page.getByRole('main')).toBeVisible(); + await expect(page.getByRole('main')).not.toHaveText(''); + if (surface === 'repository') + await expect(page.getByTestId('repository-drawer')).toBeVisible(); + if (surface === 'feature') + await expect(page.getByTestId('feature-drawer-actions')).toBeVisible(); + if (surface === 'application') { + await expect(page.getByText('No file open', { exact: true })).toBeVisible(); + await expect(page.getByRole('combobox', { name: 'Agent and model' })).toBeEnabled(); + } + if (surface === 'agent-editor') + await expect(page.getByTestId('prompt-textarea-implement.system')).toBeVisible(); + if (surface === 'security-inventory') + await expect(page.getByText('Never', { exact: true }).first()).toBeVisible(); + if (surface === 'control-center') { + await expect(page.getByTestId('feature-node-title').first()).toBeVisible(); + const repository = page.getByTestId('repository-node-card').filter({ + has: page + .getByTestId('repository-node-name') + .filter({ hasText: 'UI review standalone repository' }), + }); + await expect(repository).toBeVisible(); + for (const name of ['Chat with agent', 'View sessions', 'New feature']) { + const control = repository.getByRole('button', { name, exact: true }); + await expect(control).toBeVisible(); + const bounds = (await control.boundingBox())!; + expect( + bounds.width, + `${name} target width after canvas scaling` + ).toBeGreaterThanOrEqual(24); + expect( + bounds.height, + `${name} target height after canvas scaling` + ).toBeGreaterThanOrEqual(24); + } } + await expectAccessible(page); + } finally { + await restoreFlag?.(); } - await expectAccessible(page); }); } } diff --git a/tests/integration/infrastructure/repositories/sqlite-settings.repository.test.ts b/tests/integration/infrastructure/repositories/sqlite-settings.repository.test.ts index d38e17d4a..a318b1e6e 100644 --- a/tests/integration/infrastructure/repositories/sqlite-settings.repository.test.ts +++ b/tests/integration/infrastructure/repositories/sqlite-settings.repository.test.ts @@ -16,6 +16,7 @@ import { createInMemoryDatabase, tableExists } from '../../../helpers/database.h import { runSQLiteMigrations } from '@/infrastructure/persistence/sqlite/migrations.js'; import { SQLiteSettingsRepository } from '@/infrastructure/repositories/sqlite-settings.repository.js'; import type { Settings } from '@/domain/generated/output.js'; +import { createDefaultSettings } from '@/domain/factories/settings-defaults.factory.js'; import { AgentType, AgentEffort, @@ -533,6 +534,17 @@ describe('SQLiteSettingsRepository', () => { }); describe('feature flags', () => { + it('keeps an ASPM flag a user already turned on now that the default is off', async () => { + const settings = createTestSettings(); + settings.featureFlags = { ...createDefaultSettings().featureFlags!, aspm: true }; + + await repository.initialize(settings); + const loaded = await repository.load(); + + expect(createDefaultSettings().featureFlags?.aspm).toBe(false); + expect(loaded?.featureFlags?.aspm).toBe(true); + }); + it('should initialize settings with featureFlags and load them back', async () => { const settings = createTestSettings(); settings.featureFlags = { diff --git a/tests/unit/domain/factories/settings-defaults.factory.test.ts b/tests/unit/domain/factories/settings-defaults.factory.test.ts index 5f496b619..30c929b8e 100644 --- a/tests/unit/domain/factories/settings-defaults.factory.test.ts +++ b/tests/unit/domain/factories/settings-defaults.factory.test.ts @@ -319,7 +319,7 @@ describe('createDefaultSettings', () => { expect(settings.featureFlags).toBeDefined(); }); - it('should default feature flags with envDeploy / projects / codeReview / collaboration / aspm / supplyChainSecurity enabled', () => { + it('should default feature flags with envDeploy / projects / codeReview / collaboration / supplyChainSecurity enabled and aspm off', () => { const settings = createDefaultSettings(); expect(settings.featureFlags).toEqual({ envDeploy: true, @@ -328,7 +328,8 @@ describe('createDefaultSettings', () => { projects: true, codeReview: true, collaboration: true, - aspm: true, + // ASPM is a separate product category; users opt in (spec 133). + aspm: false, bedrockIntegration: true, whatsappDispatch: false, clusters: false, diff --git a/tests/unit/presentation/web/lib/feature-flags.test.ts b/tests/unit/presentation/web/lib/feature-flags.test.ts index 87cabb51c..07e2685e1 100644 --- a/tests/unit/presentation/web/lib/feature-flags.test.ts +++ b/tests/unit/presentation/web/lib/feature-flags.test.ts @@ -243,14 +243,22 @@ describe('requireFeatureFlag', () => { }); it('returns silently when settings is not initialized and the flag defaults to true', () => { - // collaboration / projects / codeReview / aspm all default to TRUE in the + // collaboration / projects / codeReview all default to TRUE in the // env-var fallback (see feature-flags.ts) — they're enabled by default so // a fresh install without a DB-persisted settings row still shows them. mockHasSettings.mockReturnValue(false); expect(() => requireFeatureFlag('collaboration')).not.toThrow(); expect(() => requireFeatureFlag('projects')).not.toThrow(); expect(() => requireFeatureFlag('codeReview')).not.toThrow(); + }); + + it('keeps ASPM off without settings unless NEXT_PUBLIC_FLAG_ASPM turns it on', () => { + mockHasSettings.mockReturnValue(false); + delete process.env.NEXT_PUBLIC_FLAG_ASPM; + expect(() => requireFeatureFlag('aspm')).toThrow(FeatureFlagDisabledError); + process.env.NEXT_PUBLIC_FLAG_ASPM = 'true'; expect(() => requireFeatureFlag('aspm')).not.toThrow(); + delete process.env.NEXT_PUBLIC_FLAG_ASPM; }); it('throws when settings is not initialized and the flag defaults to false', () => { diff --git a/tsp/domain/entities/settings.tsp b/tsp/domain/entities/settings.tsp index e2cea9ccc..d8e2a157c 100644 --- a/tsp/domain/entities/settings.tsp +++ b/tsp/domain/entities/settings.tsp @@ -716,8 +716,8 @@ model FeatureFlags { @doc("Enable WhatsApp-native task dispatch and interactive control (spec 101)") whatsappDispatch: boolean = false; - @doc("Enable the Application Security Posture Management (ASPM) module — /aspm web routes, `shep aspm` CLI command tree, and the posture SSE stream (spec 098)") - aspm: boolean = true; + @doc("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 133).") + aspm: boolean = false; @doc("Enable Clusters navigation and Kubernetes cluster management in the web UI") clusters: boolean = false; From 0b9221ffa0e55cb1dd587bac57f4f7dd97b57a66 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 10:18:51 +0000 Subject: [PATCH 04/13] fix(config): fold supply-chain security into the ASPM flag Supply-chain security had its own `supplyChainSecurity` flag and overlapped ASPM. It now has no flag of its own: isSupplyChainSecurityEnabled() derives it from featureFlags.aspm, and the feature-agent pre-check, the canvas security badge, the Settings section and `shep security enforce` all use it. SHEP_SUPPLY_CHAIN_SECURITY still overrides it for CI: "false" turns it off and "true" turns it on, which Shep's own security-enforce job already sets, so the release gate keeps running on a fresh install. The field leaves TypeSpec, the defaults, the mapper and the repository SQL. The feature_flag_supply_chain_security column stays (NOT NULL DEFAULT 1) so older builds keep reading it; no data is dropped. Claude-Session: https://claude.ai/code/session_01TxtN2VcWzg6NTCLj4DbmfL Co-Authored-By: Shep Bot --- .github/workflows/ci.yml | 7 +- SECURITY.md | 2 +- apis/json-schema/FeatureFlags.yaml | 5 -- docs/FEATURES.md | 3 +- docs/api/domain-models.md | 3 +- docs/cli/commands.md | 9 +- .../factories/settings-defaults.factory.ts | 1 - packages/core/src/domain/generated/output.ts | 4 - .../domain/shared/supply-chain-security.ts | 22 +++++ .../sqlite/mappers/settings.mapper.ts | 3 - ...-add-feature-flag-supply-chain-security.ts | 3 + .../sqlite-settings.repository.ts | 3 - .../feature-agent/nodes/node-helpers.ts | 10 +-- .../feature-agent/nodes/security-pre-check.ts | 4 +- .../cli/commands/security.command.ts | 27 +++--- .../web/app/(dashboard)/get-graph-data.ts | 5 +- .../settings/settings-page-client.stories.tsx | 1 - .../settings/settings-page-client.tsx | 14 +-- .../sqlite-settings.repository.test.ts | 9 +- .../settings-defaults.factory.test.ts | 5 +- .../shared/supply-chain-security.test.ts | 30 +++++++ .../sqlite/mappers/settings.mapper.test.ts | 90 +++---------------- .../nodes/security-pre-check.test.ts | 4 +- .../services/settings-service-update.test.ts | 1 - .../cli/commands/security.command.test.ts | 27 ++++-- .../settings/settings-page-client.test.tsx | 11 ++- .../web/layouts/app-shell-variant.test.tsx | 1 - translations/ar/cli.json | 2 +- translations/de/cli.json | 2 +- translations/en/cli.json | 2 +- translations/es/cli.json | 2 +- translations/fr/cli.json | 2 +- translations/he/cli.json | 2 +- translations/pt/cli.json | 2 +- translations/ru/cli.json | 2 +- translations/uk/cli.json | 2 +- tsp/domain/entities/settings.tsp | 3 - 37 files changed, 149 insertions(+), 176 deletions(-) create mode 100644 packages/core/src/domain/shared/supply-chain-security.ts create mode 100644 tests/unit/domain/shared/supply-chain-security.test.ts 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/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/FeatureFlags.yaml b/apis/json-schema/FeatureFlags.yaml index c3238d727..f8c0ef68d 100644 --- a/apis/json-schema/FeatureFlags.yaml +++ b/apis/json-schema/FeatureFlags.yaml @@ -42,10 +42,6 @@ properties: 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 @@ -69,7 +65,6 @@ required: - whatsappDispatch - aspm - clusters - - supplyChainSecurity - scheduledWorkflows - githubImport - queryAwareHarness diff --git a/docs/FEATURES.md b/docs/FEATURES.md index bdc533e9d..cfe6ce7f7 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -369,9 +369,8 @@ Toggle experimental features. Several of them gate whole command groups: | `collaboration` | Supervisor agent, agent questions and messages | | `bedrockIntegration` | `shep bedrock` | | `whatsappDispatch` | `shep whatsapp` | -| `aspm` | `shep aspm`, `/aspm` — off by default | +| `aspm` | `shep aspm`, `/aspm`, `shep security enforce` — off by default | | `clusters` | `shep cluster` | -| `supplyChainSecurity` | `shep security enforce` | | `scheduledWorkflows` | `shep workflow` | | `githubImport` | GitHub repository import | diff --git a/docs/api/domain-models.md b/docs/api/domain-models.md index a07f11b74..c16dc2f90 100644 --- a/docs/api/domain-models.md +++ b/docs/api/domain-models.md @@ -393,7 +393,6 @@ export type FeatureFlags = { whatsappDispatch: boolean; aspm: boolean; clusters: boolean; - supplyChainSecurity: boolean; scheduledWorkflows: boolean; githubImport: boolean; }; @@ -401,7 +400,7 @@ export type FeatureFlags = { 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 diff --git a/docs/cli/commands.md b/docs/cli/commands.md index 23891570d..9bf81304a 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -1106,16 +1106,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/packages/core/src/domain/factories/settings-defaults.factory.ts b/packages/core/src/domain/factories/settings-defaults.factory.ts index d88a55bac..bd287d826 100644 --- a/packages/core/src/domain/factories/settings-defaults.factory.ts +++ b/packages/core/src/domain/factories/settings-defaults.factory.ts @@ -242,7 +242,6 @@ export function createDefaultSettings(): Settings { bedrockIntegration: true, whatsappDispatch: false, clusters: false, - supplyChainSecurity: true, scheduledWorkflows: false, githubImport: true, queryAwareHarness: false, diff --git a/packages/core/src/domain/generated/output.ts b/packages/core/src/domain/generated/output.ts index cc67a302a..f54c99fcb 100644 --- a/packages/core/src/domain/generated/output.ts +++ b/packages/core/src/domain/generated/output.ts @@ -763,10 +763,6 @@ export type FeatureFlags = { * 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 */ 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..10dec7bf6 --- /dev/null +++ b/packages/core/src/domain/shared/supply-chain-security.ts @@ -0,0 +1,22 @@ +/** + * Supply-chain security is part of ASPM (spec 133): 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/persistence/sqlite/mappers/settings.mapper.ts b/packages/core/src/infrastructure/persistence/sqlite/mappers/settings.mapper.ts index c4f3969c9..31eb37bcd 100644 --- a/packages/core/src/infrastructure/persistence/sqlite/mappers/settings.mapper.ts +++ b/packages/core/src/infrastructure/persistence/sqlite/mappers/settings.mapper.ts @@ -158,7 +158,6 @@ export interface SettingsRow { 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) @@ -352,7 +351,6 @@ 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, @@ -814,7 +812,6 @@ 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, 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..2fc217993 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 133: 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/repositories/sqlite-settings.repository.ts b/packages/core/src/infrastructure/repositories/sqlite-settings.repository.ts index d2be8aa10..0c80544fb 100644 --- a/packages/core/src/infrastructure/repositories/sqlite-settings.repository.ts +++ b/packages/core/src/infrastructure/repositories/sqlite-settings.repository.ts @@ -71,7 +71,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, @@ -130,7 +129,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, @@ -276,7 +274,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, 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..9aa8ee90b 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 133). 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/src/presentation/cli/commands/security.command.ts b/src/presentation/cli/commands/security.command.ts index 5aefcabc6..4b2b52cd8 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 133): 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/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/components/features/settings/settings-page-client.stories.tsx b/src/presentation/web/components/features/settings/settings-page-client.stories.tsx index 25b3a2fd5..49775f219 100644 --- a/src/presentation/web/components/features/settings/settings-page-client.stories.tsx +++ b/src/presentation/web/components/features/settings/settings-page-client.stories.tsx @@ -44,7 +44,6 @@ export const AllSections: Story = { whatsappDispatch: false, aspm: false, clusters: false, - supplyChainSecurity: true, scheduledWorkflows: false, githubImport: true, queryAwareHarness: false, diff --git a/src/presentation/web/components/features/settings/settings-page-client.tsx b/src/presentation/web/components/features/settings/settings-page-client.tsx index 57038b3d9..4de73067f 100644 --- a/src/presentation/web/components/features/settings/settings-page-client.tsx +++ b/src/presentation/web/components/features/settings/settings-page-client.tsx @@ -92,6 +92,7 @@ import { import type { AvailableTerminal } from '@/app/actions/get-available-terminals'; import type { SettingsSecretPresence } from '@shepai/core/application/use-cases/settings/load-settings.use-case'; import { secretPlaceholder, secretUpdateValue } from '@/lib/secret-placeholder'; +import { isSupplyChainSecurityEnabled } from '@shepai/core/domain/shared/supply-chain-security'; const EDITOR_OPTIONS = [ { value: EditorType.VsCode, label: 'VS Code' }, @@ -400,7 +401,6 @@ export function SettingsPageClient({ bedrockIntegration: true, whatsappDispatch: false, clusters: false, - supplyChainSecurity: true, scheduledWorkflows: false, githubImport: true, queryAwareHarness: false, @@ -696,16 +696,16 @@ export function SettingsPageClient({ const [activeSection, setActiveSection] = useState('agent'); - // Filter sections based on feature flags. When supplyChainSecurity is off, - // hide the Security nav tab AND the section below so the feature is fully inert. + // Filter sections based on feature flags. Supply-chain security is part of + // ASPM: with aspm off, hide the Security nav tab AND the section below. const visibleSections = useMemo( () => SECTIONS.filter( (s: (typeof SECTIONS)[number]) => - (s.id !== 'security' || flags.supplyChainSecurity) && + (s.id !== 'security' || isSupplyChainSecurityEnabled(flags)) && (s.id !== 'harness' || flags.queryAwareHarness) ), - [flags.supplyChainSecurity, flags.queryAwareHarness] + [flags] ); // Track which section is in view via IntersectionObserver @@ -1359,8 +1359,8 @@ export function SettingsPageClient({ - {/* ── Security ── (hidden when supplyChainSecurity feature flag is off) */} - {flags.supplyChainSecurity ? ( + {/* ── Security ── (supply chain, part of ASPM: hidden when aspm is off) */} + {isSupplyChainSecurityEnabled(flags) ? (
{ whatsappDispatch: false, aspm: false, clusters: false, - supplyChainSecurity: true, scheduledWorkflows: false, githubImport: true, queryAwareHarness: false, @@ -578,7 +577,6 @@ describe('SQLiteSettingsRepository', () => { whatsappDispatch: false, aspm: false, clusters: false, - supplyChainSecurity: true, scheduledWorkflows: false, githubImport: true, queryAwareHarness: false, @@ -603,8 +601,7 @@ describe('SQLiteSettingsRepository', () => { aspm: false, clusters: false, // When settings is initialized without featureFlags, the mapper writes 0 for all - // flags (including supplyChainSecurity), so load-back returns false across the board. - supplyChainSecurity: false, + // flags, so load-back returns false across the board. scheduledWorkflows: false, githubImport: true, queryAwareHarness: false, @@ -626,7 +623,6 @@ describe('SQLiteSettingsRepository', () => { whatsappDispatch: false, aspm: false, clusters: false, - supplyChainSecurity: true, scheduledWorkflows: false, githubImport: true, queryAwareHarness: false, @@ -646,7 +642,6 @@ describe('SQLiteSettingsRepository', () => { whatsappDispatch: false, aspm: false, clusters: false, - supplyChainSecurity: true, scheduledWorkflows: false, githubImport: true, queryAwareHarness: false, @@ -666,7 +661,6 @@ describe('SQLiteSettingsRepository', () => { whatsappDispatch: false, aspm: false, clusters: false, - supplyChainSecurity: true, scheduledWorkflows: false, githubImport: true, queryAwareHarness: false, @@ -1027,7 +1021,6 @@ describe('SQLiteSettingsRepository', () => { whatsappDispatch: true, aspm: false, clusters: false, - supplyChainSecurity: false, scheduledWorkflows: false, githubImport: true, queryAwareHarness: false, diff --git a/tests/unit/domain/factories/settings-defaults.factory.test.ts b/tests/unit/domain/factories/settings-defaults.factory.test.ts index 30c929b8e..5f3145a20 100644 --- a/tests/unit/domain/factories/settings-defaults.factory.test.ts +++ b/tests/unit/domain/factories/settings-defaults.factory.test.ts @@ -319,7 +319,7 @@ describe('createDefaultSettings', () => { expect(settings.featureFlags).toBeDefined(); }); - it('should default feature flags with envDeploy / projects / codeReview / collaboration / supplyChainSecurity enabled and aspm off', () => { + it('should default feature flags with envDeploy / projects / codeReview / collaboration enabled and aspm off', () => { const settings = createDefaultSettings(); expect(settings.featureFlags).toEqual({ envDeploy: true, @@ -333,9 +333,6 @@ describe('createDefaultSettings', () => { bedrockIntegration: true, whatsappDispatch: false, clusters: false, - // Master kill switch for the supply chain security feature — - // defaults to true so existing users keep the feature they already see. - supplyChainSecurity: true, scheduledWorkflows: false, githubImport: true, queryAwareHarness: false, diff --git a/tests/unit/domain/shared/supply-chain-security.test.ts b/tests/unit/domain/shared/supply-chain-security.test.ts new file mode 100644 index 000000000..6f9a62b58 --- /dev/null +++ b/tests/unit/domain/shared/supply-chain-security.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { isSupplyChainSecurityEnabled } from '@/domain/shared/supply-chain-security.js'; + +describe('isSupplyChainSecurityEnabled', () => { + it('follows the ASPM flag — supply-chain security is part of ASPM (spec 133)', () => { + expect(isSupplyChainSecurityEnabled({ aspm: true })).toBe(true); + expect(isSupplyChainSecurityEnabled({ aspm: false })).toBe(false); + }); + + it('is off when no flags are available', () => { + expect(isSupplyChainSecurityEnabled(undefined)).toBe(false); + }); +}); + +describe('isSupplyChainSecurityEnabled with the SHEP_SUPPLY_CHAIN_SECURITY override', () => { + it('turns it off for "false" and "0" even when ASPM is on', () => { + expect(isSupplyChainSecurityEnabled({ aspm: true }, 'false')).toBe(false); + expect(isSupplyChainSecurityEnabled({ aspm: true }, '0')).toBe(false); + }); + + it('turns it on for "true" and "1" even when ASPM is off (CI opts in explicitly)', () => { + expect(isSupplyChainSecurityEnabled({ aspm: false }, 'true')).toBe(true); + expect(isSupplyChainSecurityEnabled(undefined, '1')).toBe(true); + }); + + it('ignores any other value and follows the ASPM flag', () => { + expect(isSupplyChainSecurityEnabled({ aspm: false }, '')).toBe(false); + expect(isSupplyChainSecurityEnabled({ aspm: true }, 'yes')).toBe(true); + }); +}); diff --git a/tests/unit/infrastructure/persistence/sqlite/mappers/settings.mapper.test.ts b/tests/unit/infrastructure/persistence/sqlite/mappers/settings.mapper.test.ts index b2bafcbb2..3126857c5 100644 --- a/tests/unit/infrastructure/persistence/sqlite/mappers/settings.mapper.test.ts +++ b/tests/unit/infrastructure/persistence/sqlite/mappers/settings.mapper.test.ts @@ -180,7 +180,6 @@ function createTestRow(overrides: Partial = {}): SettingsRow { feature_flag_whatsapp_dispatch: 0, feature_flag_aspm: 0, feature_flag_clusters: 0, - feature_flag_supply_chain_security: 1, feature_flag_scheduled_workflows: 0, interactive_agent_enabled: 1, interactive_agent_auto_timeout_minutes: 15, @@ -1474,87 +1473,18 @@ describe('Settings Mapper', () => { }); }); - describe('supplyChainSecurity feature flag (migration 056)', () => { - it('maps featureFlags.supplyChainSecurity=true to feature_flag_supply_chain_security=1', () => { - const settings = createTestSettings({ - featureFlags: { - envDeploy: false, - debug: false, - reactFileManager: false, - projects: false, - codeReview: false, - collaboration: false, - bedrockIntegration: false, - whatsappDispatch: false, - aspm: false, - clusters: false, - supplyChainSecurity: true, - scheduledWorkflows: false, - githubImport: true, - queryAwareHarness: false, - }, - }); - const row = toDatabase(settings); - expect(row.feature_flag_supply_chain_security).toBe(1); + describe('legacy supply-chain flag column (folded into ASPM, spec 133)', () => { + it('no longer writes feature_flag_supply_chain_security', () => { + const row = toDatabase(createTestSettings()) as unknown as Record; + expect(row).not.toHaveProperty('feature_flag_supply_chain_security'); }); - it('maps featureFlags.supplyChainSecurity=false to feature_flag_supply_chain_security=0', () => { - const settings = createTestSettings({ - featureFlags: { - envDeploy: false, - debug: false, - reactFileManager: false, - projects: false, - codeReview: false, - collaboration: false, - bedrockIntegration: false, - whatsappDispatch: false, - aspm: false, - clusters: false, - supplyChainSecurity: false, - scheduledWorkflows: false, - githubImport: true, - queryAwareHarness: false, - }, - }); - const row = toDatabase(settings); - expect(row.feature_flag_supply_chain_security).toBe(0); - }); - - it('reconstructs supplyChainSecurity=true from feature_flag_supply_chain_security=1', () => { - const row = createTestRow({ feature_flag_supply_chain_security: 1 }); - const settings = fromDatabase(row); - expect(settings.featureFlags?.supplyChainSecurity).toBe(true); - }); - - it('reconstructs supplyChainSecurity=false from feature_flag_supply_chain_security=0', () => { - const row = createTestRow({ feature_flag_supply_chain_security: 0 }); - const settings = fromDatabase(row); - expect(settings.featureFlags?.supplyChainSecurity).toBe(false); - }); - - it('round-trips supplyChainSecurity through toDatabase → fromDatabase', () => { - const settings = createTestSettings({ - featureFlags: { - envDeploy: true, - debug: true, - reactFileManager: true, - projects: true, - codeReview: true, - collaboration: true, - bedrockIntegration: true, - whatsappDispatch: true, - aspm: true, - clusters: true, - supplyChainSecurity: false, - scheduledWorkflows: false, - githubImport: true, - queryAwareHarness: false, - }, - }); - const row = toDatabase(settings); - const restored = fromDatabase(row); - expect(restored.featureFlags).toEqual(settings.featureFlags); + it('ignores the column on read', () => { + const row = { + ...createTestRow(), + feature_flag_supply_chain_security: 0, + } as Parameters[0]; + expect(fromDatabase(row).featureFlags).not.toHaveProperty('supplyChainSecurity'); }); }); diff --git a/tests/unit/infrastructure/services/agents/feature-agent/nodes/security-pre-check.test.ts b/tests/unit/infrastructure/services/agents/feature-agent/nodes/security-pre-check.test.ts index 518bcd755..f332bb520 100644 --- a/tests/unit/infrastructure/services/agents/feature-agent/nodes/security-pre-check.test.ts +++ b/tests/unit/infrastructure/services/agents/feature-agent/nodes/security-pre-check.test.ts @@ -142,7 +142,7 @@ describe('checkSecurityDisposition', () => { }); }); -describe('resolveEffectiveSecurityMode — supplyChainSecurity feature flag gate', () => { +describe('resolveEffectiveSecurityMode — supply-chain (ASPM) gate', () => { it('forces Disabled when the feature flag is false, regardless of state mode', () => { expect(resolveEffectiveSecurityMode(SecurityMode.Enforce, false)).toBe(SecurityMode.Disabled); expect(resolveEffectiveSecurityMode(SecurityMode.Advisory, false)).toBe(SecurityMode.Disabled); @@ -161,7 +161,7 @@ describe('resolveEffectiveSecurityMode — supplyChainSecurity feature flag gate }); it('composes with checkSecurityDisposition to skip all checks when flag is false', () => { - // Even an Enforce/Denied state collapses to a skip when supplyChainSecurity is off. + // Even an Enforce/Denied state collapses to a skip when supply-chain security is off. const effective = resolveEffectiveSecurityMode(SecurityMode.Enforce, false); const result = checkSecurityDisposition('implement', effective, { [SecurityActionCategory.PackageScriptExec]: SecurityActionDisposition.Denied, diff --git a/tests/unit/infrastructure/services/settings-service-update.test.ts b/tests/unit/infrastructure/services/settings-service-update.test.ts index 037671a55..b165f0286 100644 --- a/tests/unit/infrastructure/services/settings-service-update.test.ts +++ b/tests/unit/infrastructure/services/settings-service-update.test.ts @@ -79,7 +79,6 @@ describe('updateSettings', () => { whatsappDispatch: false, aspm: false, clusters: false, - supplyChainSecurity: true, scheduledWorkflows: false, githubImport: true, queryAwareHarness: false, diff --git a/tests/unit/presentation/cli/commands/security.command.test.ts b/tests/unit/presentation/cli/commands/security.command.test.ts index ff4ddc8f9..3c3c23a95 100644 --- a/tests/unit/presentation/cli/commands/security.command.test.ts +++ b/tests/unit/presentation/cli/commands/security.command.test.ts @@ -13,7 +13,7 @@ const { getSettingsMock, useCaseExecuteMock, messagesInfoMock } = vi.hoisted(() messagesInfoMock: vi.fn(), })); -// Mock settings service — allow per-test overrides of featureFlags.supplyChainSecurity +// Mock settings service — allow per-test overrides of featureFlags.aspm vi.mock('@/infrastructure/services/settings.service.js', () => ({ getSettings: () => getSettingsMock(), })); @@ -95,7 +95,7 @@ describe('createSecurityCommand', () => { expect(repoOpt!.defaultValue).toBe(process.cwd()); }); - describe('supplyChainSecurity feature flag gate', () => { + describe('ASPM feature flag gate (supply chain is part of ASPM)', () => { const originalEnv = process.env.SHEP_SUPPLY_CHAIN_SECURITY; beforeEach(() => { @@ -119,8 +119,8 @@ describe('createSecurityCommand', () => { await cmd.parseAsync(['enforce', '--repo', '/tmp/repo'], { from: 'user' }); } - it('becomes a no-op and skips the use case when featureFlags.supplyChainSecurity is false', async () => { - getSettingsMock.mockReturnValue({ featureFlags: { supplyChainSecurity: false } }); + it('becomes a no-op and skips the use case when featureFlags.aspm is false', async () => { + getSettingsMock.mockReturnValue({ featureFlags: { aspm: false } }); await runEnforce(); @@ -130,9 +130,9 @@ describe('createSecurityCommand', () => { ); }); - it('becomes a no-op when SHEP_SUPPLY_CHAIN_SECURITY=false even if the settings flag is true', async () => { + it('becomes a no-op when SHEP_SUPPLY_CHAIN_SECURITY=false even if aspm is on', async () => { process.env.SHEP_SUPPLY_CHAIN_SECURITY = 'false'; - getSettingsMock.mockReturnValue({ featureFlags: { supplyChainSecurity: true } }); + getSettingsMock.mockReturnValue({ featureFlags: { aspm: true } }); await runEnforce(); @@ -144,7 +144,7 @@ describe('createSecurityCommand', () => { it('also honors SHEP_SUPPLY_CHAIN_SECURITY=0 as a disable value', async () => { process.env.SHEP_SUPPLY_CHAIN_SECURITY = '0'; - getSettingsMock.mockReturnValue({ featureFlags: { supplyChainSecurity: true } }); + getSettingsMock.mockReturnValue({ featureFlags: { aspm: true } }); await runEnforce(); @@ -152,7 +152,7 @@ describe('createSecurityCommand', () => { }); it('runs the use case when the flag is true and no env override is set', async () => { - getSettingsMock.mockReturnValue({ featureFlags: { supplyChainSecurity: true } }); + getSettingsMock.mockReturnValue({ featureFlags: { aspm: true } }); useCaseExecuteMock.mockResolvedValue({ mode: 'Advisory', policy: { source: 'settings-default' }, @@ -168,8 +168,17 @@ describe('createSecurityCommand', () => { expect(useCaseExecuteMock).toHaveBeenCalledWith({ repositoryPath: '/tmp/repo' }); }); - it('runs the use case when featureFlags is absent (defaults to enabled)', async () => { + it('is a no-op when featureFlags is absent (ASPM is off by default)', async () => { getSettingsMock.mockReturnValue({}); + + await runEnforce(); + + expect(useCaseExecuteMock).not.toHaveBeenCalled(); + }); + + it('runs with ASPM off when SHEP_SUPPLY_CHAIN_SECURITY=true opts in (Shep CI)', async () => { + process.env.SHEP_SUPPLY_CHAIN_SECURITY = 'true'; + getSettingsMock.mockReturnValue({ featureFlags: { aspm: false } }); useCaseExecuteMock.mockResolvedValue({ mode: 'Advisory', policy: { source: 'settings-default' }, diff --git a/tests/unit/presentation/web/components/features/settings/settings-page-client.test.tsx b/tests/unit/presentation/web/components/features/settings/settings-page-client.test.tsx index 707973113..bb212d7e4 100644 --- a/tests/unit/presentation/web/components/features/settings/settings-page-client.test.tsx +++ b/tests/unit/presentation/web/components/features/settings/settings-page-client.test.tsx @@ -37,8 +37,9 @@ describe('SettingsPageClient', () => { }); it('renders all section components', () => { + const withAspm = { ...settings, featureFlags: { ...settings.featureFlags!, aspm: true } }; render( - + ); expect(screen.getByTestId('agent-settings-section')).toBeDefined(); expect(screen.getByTestId('environment-settings-section')).toBeDefined(); @@ -49,6 +50,14 @@ describe('SettingsPageClient', () => { expect(screen.getByTestId('database-settings-section')).toBeDefined(); }); + it('hides the supply-chain security section while ASPM is off', () => { + render( + + ); + expect(settings.featureFlags?.aspm).toBe(false); + expect(screen.queryByTestId('security-settings-section')).toBeNull(); + }); + it('passes shepHome and dbFileSize to database section', () => { render(); expect(screen.getByTestId('shep-home-path').textContent).toBe('/opt/shep'); diff --git a/tests/unit/presentation/web/layouts/app-shell-variant.test.tsx b/tests/unit/presentation/web/layouts/app-shell-variant.test.tsx index 7afeef542..b3a722b34 100644 --- a/tests/unit/presentation/web/layouts/app-shell-variant.test.tsx +++ b/tests/unit/presentation/web/layouts/app-shell-variant.test.tsx @@ -31,7 +31,6 @@ const defaultFlags = { whatsappDispatch: false, aspm: false, clusters: false, - supplyChainSecurity: false, scheduledWorkflows: false, githubImport: true, queryAwareHarness: false, diff --git a/translations/ar/cli.json b/translations/ar/cli.json index e40e3fc55..f94adc8b9 100644 --- a/translations/ar/cli.json +++ b/translations/ar/cli.json @@ -225,7 +225,7 @@ "governanceFindingsLabel": "GitHub Governance (audit-only)", "noFindings": "No findings", "failedToEnforce": "Failed to evaluate security posture", - "flagDisabledNote": "Supply chain security feature flag is off — enforce is a no-op" + "flagDisabledNote": "Supply-chain security is part of ASPM and the aspm feature flag is off — enforce is a no-op" } }, "feat": { diff --git a/translations/de/cli.json b/translations/de/cli.json index 8e35e687d..9d541a957 100644 --- a/translations/de/cli.json +++ b/translations/de/cli.json @@ -225,7 +225,7 @@ "governanceFindingsLabel": "GitHub Governance (audit-only)", "noFindings": "No findings", "failedToEnforce": "Failed to evaluate security posture", - "flagDisabledNote": "Supply chain security feature flag is off — enforce is a no-op" + "flagDisabledNote": "Supply-chain security is part of ASPM and the aspm feature flag is off — enforce is a no-op" } }, "feat": { diff --git a/translations/en/cli.json b/translations/en/cli.json index db41f8157..8e920f366 100644 --- a/translations/en/cli.json +++ b/translations/en/cli.json @@ -225,7 +225,7 @@ "governanceFindingsLabel": "GitHub Governance (audit-only)", "noFindings": "No findings", "failedToEnforce": "Failed to evaluate security posture", - "flagDisabledNote": "Supply chain security feature flag is off — enforce is a no-op" + "flagDisabledNote": "Supply-chain security is part of ASPM and the aspm feature flag is off — enforce is a no-op" } }, "feat": { diff --git a/translations/es/cli.json b/translations/es/cli.json index 0a7153ad6..757b259de 100644 --- a/translations/es/cli.json +++ b/translations/es/cli.json @@ -225,7 +225,7 @@ "governanceFindingsLabel": "GitHub Governance (audit-only)", "noFindings": "No findings", "failedToEnforce": "Failed to evaluate security posture", - "flagDisabledNote": "Supply chain security feature flag is off — enforce is a no-op" + "flagDisabledNote": "Supply-chain security is part of ASPM and the aspm feature flag is off — enforce is a no-op" } }, "feat": { diff --git a/translations/fr/cli.json b/translations/fr/cli.json index 7623b80d3..2efd73b86 100644 --- a/translations/fr/cli.json +++ b/translations/fr/cli.json @@ -225,7 +225,7 @@ "governanceFindingsLabel": "GitHub Governance (audit-only)", "noFindings": "No findings", "failedToEnforce": "Failed to evaluate security posture", - "flagDisabledNote": "Supply chain security feature flag is off — enforce is a no-op" + "flagDisabledNote": "Supply-chain security is part of ASPM and the aspm feature flag is off — enforce is a no-op" } }, "feat": { diff --git a/translations/he/cli.json b/translations/he/cli.json index db567d28c..b2cfa3f71 100644 --- a/translations/he/cli.json +++ b/translations/he/cli.json @@ -225,7 +225,7 @@ "governanceFindingsLabel": "GitHub Governance (audit-only)", "noFindings": "No findings", "failedToEnforce": "Failed to evaluate security posture", - "flagDisabledNote": "Supply chain security feature flag is off — enforce is a no-op" + "flagDisabledNote": "Supply-chain security is part of ASPM and the aspm feature flag is off — enforce is a no-op" } }, "feat": { diff --git a/translations/pt/cli.json b/translations/pt/cli.json index 8d32be3d6..77b1b7622 100644 --- a/translations/pt/cli.json +++ b/translations/pt/cli.json @@ -225,7 +225,7 @@ "governanceFindingsLabel": "GitHub Governance (audit-only)", "noFindings": "No findings", "failedToEnforce": "Failed to evaluate security posture", - "flagDisabledNote": "Supply chain security feature flag is off — enforce is a no-op" + "flagDisabledNote": "Supply-chain security is part of ASPM and the aspm feature flag is off — enforce is a no-op" } }, "feat": { diff --git a/translations/ru/cli.json b/translations/ru/cli.json index e23c8b3cc..bc8ff6db0 100644 --- a/translations/ru/cli.json +++ b/translations/ru/cli.json @@ -225,7 +225,7 @@ "governanceFindingsLabel": "GitHub Governance (audit-only)", "noFindings": "No findings", "failedToEnforce": "Failed to evaluate security posture", - "flagDisabledNote": "Supply chain security feature flag is off — enforce is a no-op" + "flagDisabledNote": "Supply-chain security is part of ASPM and the aspm feature flag is off — enforce is a no-op" } }, "feat": { diff --git a/translations/uk/cli.json b/translations/uk/cli.json index 279223ee1..6da91a5a0 100644 --- a/translations/uk/cli.json +++ b/translations/uk/cli.json @@ -225,7 +225,7 @@ "governanceFindingsLabel": "GitHub Governance (audit-only)", "noFindings": "No findings", "failedToEnforce": "Failed to evaluate security posture", - "flagDisabledNote": "Supply chain security feature flag is off — enforce is a no-op" + "flagDisabledNote": "Supply-chain security is part of ASPM and the aspm feature flag is off — enforce is a no-op" } }, "feat": { diff --git a/tsp/domain/entities/settings.tsp b/tsp/domain/entities/settings.tsp index d8e2a157c..c61aa9efe 100644 --- a/tsp/domain/entities/settings.tsp +++ b/tsp/domain/entities/settings.tsp @@ -722,9 +722,6 @@ model FeatureFlags { @doc("Enable Clusters navigation and Kubernetes cluster management in the web UI") clusters: boolean = false; - @doc("Enable the supply chain security feature (policy engine, badges, settings, CLI, CI gate). When false, the feature is inert regardless of SecurityMode.") - supplyChainSecurity: boolean = true; - @doc("Enable scheduled workflows feature — workflow creation, scheduling, and execution") scheduledWorkflows: boolean = false; From d3a21577e5535bd7fbbc0e53a77e4e7eb157745f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 10:38:54 +0000 Subject: [PATCH 05/13] fix(agents): remove the unread autoUpdate setting and placeholder agents Nothing read `system.autoUpdate`, so it leaves TypeSpec, the defaults and the mapper. Its sys_auto_update column is NOT NULL with no default, so the mapper keeps writing 1 (the old default) and never reads it; older builds still see their usual value. The Aider and Continue agent types were "coming soon" placeholders with no executor. They leave the AgentType enum, the agent catalog, the canvas icons, the TUI picker translations and the stories. A settings row that still holds either value reads back as the default agent, so no data is rewritten. Claude-Session: https://claude.ai/code/session_01TxtN2VcWzg6NTCLj4DbmfL Co-Authored-By: Shep Bot --- apis/json-schema/AgentType.yaml | 2 -- apis/json-schema/SystemConfig.yaml | 5 --- docs/api/domain-models.md | 10 ++---- docs/architecture/agent-system.md | 6 ++-- docs/architecture/settings-service.md | 6 ++-- docs/development/adding-agent-types.md | 4 +-- .../factories/settings-defaults.factory.ts | 1 - packages/core/src/domain/generated/output.ts | 6 ---- .../core/src/domain/shared/agent-catalog.ts | 30 ---------------- .../sqlite/mappers/settings.mapper.ts | 23 ++++++++++--- .../platform-agent-auth-detector.service.ts | 2 +- .../common/feature-node/agent-type-icons.tsx | 4 --- .../feature-node/feature-node.stories.tsx | 26 +------------- .../control-center/control-center.stories.tsx | 2 +- .../features-canvas.stories.tsx | 4 +-- .../agent-settings-section.stories.tsx | 9 ----- .../settings/agent-settings-section.tsx | 7 ++-- .../web/public/icons/agents/aider.png | Bin 5641 -> 0 bytes .../web/public/icons/agents/continue.jpeg | Bin 9763 -> 0 bytes tests/e2e/cli/settings-init.test.ts | 2 +- .../sqlite-settings.repository.test.ts | 32 ++++-------------- .../approve-after-failed-rejection.test.ts | 2 +- .../resume-after-error-merge.test.ts | 2 +- .../resume-after-error.test.ts | 2 +- .../resume-feedback-propagation.test.ts | 2 +- .../agents/graph-state-transitions/setup.ts | 2 +- .../agents/hitl-approval-flow.test.ts | 2 +- .../resume-agent-session.use-case.test.ts | 2 +- .../initialize-settings.use-case.test.ts | 2 +- .../complete-onboarding.use-case.test.ts | 2 +- .../update-settings.use-case.test.ts | 18 ---------- .../settings-defaults.factory.test.ts | 6 ++-- .../generated/agent-type-codex-cli.test.ts | 2 -- .../generated/agent-type-copilot-cli.test.ts | 2 -- .../unit/domain/shared/agent-catalog.test.ts | 16 +++++---- .../shared/agent-resume-descriptor.test.ts | 2 -- .../domain/shared/space-environment.test.ts | 2 +- .../sqlite/mappers/settings.mapper.test.ts | 28 +++++++++++++-- .../agents/agent-executor-factory.test.ts | 10 +++--- .../agents/agent-validator.service.test.ts | 13 ++----- .../commands/settings/agent.command.test.ts | 2 +- .../cli/commands/settings/ide.command.test.ts | 2 +- .../commands/settings/model.command.test.ts | 2 +- .../web/actions/update-settings.test.ts | 2 +- translations/ar/tui.json | 8 ----- translations/de/tui.json | 8 ----- translations/en/tui.json | 8 ----- translations/es/tui.json | 8 ----- translations/fr/tui.json | 8 ----- translations/he/tui.json | 8 ----- translations/pt/tui.json | 8 ----- translations/ru/tui.json | 8 ----- translations/uk/tui.json | 8 ----- tsp/common/enums/agent-config.tsp | 19 +---------- tsp/domain/entities/settings.tsp | 11 +----- 55 files changed, 105 insertions(+), 303 deletions(-) delete mode 100644 src/presentation/web/public/icons/agents/aider.png delete mode 100644 src/presentation/web/public/icons/agents/continue.jpeg 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/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/api/domain-models.md b/docs/api/domain-models.md index c16dc2f90..99b1256d4 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; }; ``` @@ -534,8 +533,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', @@ -546,10 +543,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 133; 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..30782a203 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 133). 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/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/domain/factories/settings-defaults.factory.ts b/packages/core/src/domain/factories/settings-defaults.factory.ts index bd287d826..b74ea7591 100644 --- a/packages/core/src/domain/factories/settings-defaults.factory.ts +++ b/packages/core/src/domain/factories/settings-defaults.factory.ts @@ -114,7 +114,6 @@ export function createDefaultSettings(): Settings { }; const system: SystemConfig = { - autoUpdate: true, logLevel: DEFAULT_LOG_LEVEL, }; diff --git a/packages/core/src/domain/generated/output.ts b/packages/core/src/domain/generated/output.ts index f54c99fcb..7403f8ed3 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 */ @@ -559,8 +555,6 @@ export enum AgentType { CodexCli = 'codex-cli', CopilotCli = 'copilot-cli', GeminiCli = 'gemini-cli', - Aider = 'aider', - Continue = 'continue', Cursor = 'cursor', Cline = 'cline', OpenRouter = 'openrouter', diff --git a/packages/core/src/domain/shared/agent-catalog.ts b/packages/core/src/domain/shared/agent-catalog.ts index 6772837e7..14ab99950 100644 --- a/packages/core/src/domain/shared/agent-catalog.ts +++ b/packages/core/src/domain/shared/agent-catalog.ts @@ -670,36 +670,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/infrastructure/persistence/sqlite/mappers/settings.mapper.ts b/packages/core/src/infrastructure/persistence/sqlite/mappers/settings.mapper.ts index 31eb37bcd..07f94a0ca 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. @@ -81,7 +89,11 @@ export interface SettingsRow { 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 133). + * Always written as LEGACY_SYS_AUTO_UPDATE and never read. + */ + sys_auto_update: number; sys_log_level: string; // AgentConfig (agent.*) @@ -273,7 +285,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) @@ -736,15 +748,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 133) 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 }), }, 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/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/features/control-center/control-center.stories.tsx b/src/presentation/web/components/features/control-center/control-center.stories.tsx index 82e5a7571..c020be386 100644 --- a/src/presentation/web/components/features/control-center/control-center.stories.tsx +++ b/src/presentation/web/components/features/control-center/control-center.stories.tsx @@ -341,7 +341,7 @@ const fullSpectrumFeatures: FeatureNodeType[] = [ progress: 71, repositoryPath: '/home/user/acme/infra', branch: 'feat/terraform-modules', - agentType: 'aider', + agentType: 'cline', startedAt: Date.now() - 22 * 60_000, showHandles: true, onDelete: noop, diff --git a/src/presentation/web/components/features/features-canvas/features-canvas.stories.tsx b/src/presentation/web/components/features/features-canvas/features-canvas.stories.tsx index 358678462..ba7c04483 100644 --- a/src/presentation/web/components/features/features-canvas/features-canvas.stories.tsx +++ b/src/presentation/web/components/features/features-canvas/features-canvas.stories.tsx @@ -161,7 +161,7 @@ const multipleNodes: FeatureNodeType[] = [ repositoryName: 'my-repo', branch: 'feat/email-service', worktreePath: '/home/user/.shep/repos/abc123/wt/feat-email-service', - agentType: 'aider', + agentType: 'cline', modelId: 'claude-sonnet-4-6', hasAgentRun: true, hasPlan: true, @@ -1025,7 +1025,7 @@ const fullSpectrumNodes: CanvasNodeType[] = [ state: 'error', progress: 55, errorMessage: 'ENOMEM — out of memory during test execution', - agentType: 'aider', + agentType: 'cline', modelId: 'claude-sonnet-4-6', repositoryPath: '/home/dev/platform', repositoryName: 'platform', diff --git a/src/presentation/web/components/features/settings/agent-settings-section.stories.tsx b/src/presentation/web/components/features/settings/agent-settings-section.stories.tsx index 5e8dbff6b..d32024206 100644 --- a/src/presentation/web/components/features/settings/agent-settings-section.stories.tsx +++ b/src/presentation/web/components/features/settings/agent-settings-section.stories.tsx @@ -42,15 +42,6 @@ export const CodexCli: Story = { }, }; -export const AiderSession: Story = { - args: { - agent: { - type: AgentType.Aider, - authMethod: AgentAuthMethod.Session, - }, - }, -}; - export const CopilotCli: Story = { args: { agent: { diff --git a/src/presentation/web/components/features/settings/agent-settings-section.tsx b/src/presentation/web/components/features/settings/agent-settings-section.tsx index e0d66ec9b..a00984105 100644 --- a/src/presentation/web/components/features/settings/agent-settings-section.tsx +++ b/src/presentation/web/components/features/settings/agent-settings-section.tsx @@ -24,10 +24,9 @@ import type { AgentConfig } from '@shepai/core/domain/generated/output'; /** * Selectable agents, derived from the domain catalog. * - * This list used to be hand-maintained here and had drifted twice over: it - * never learned about `kimi-code`, and it offered Aider and Continue as plain - * enabled options even though both are `supported: false` and have no executor - * — picking either threw inside `AgentExecutorFactory.createExecutor()`. + * This list used to be hand-maintained here and drifted: it never learned + * about `kimi-code`. A catalog entry marked `supported: false` renders as a + * disabled "Coming Soon" option. */ const AGENT_TYPE_OPTIONS = listAgentDescriptors().map((descriptor) => ({ value: descriptor.type, diff --git a/src/presentation/web/public/icons/agents/aider.png b/src/presentation/web/public/icons/agents/aider.png deleted file mode 100644 index cf966cd667a0eb097ba1a3aef58333095671deaf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5641 zcmYj#cQ_mD-+dxAYE)4?rAASsh*6_PY*o9|UbP8g#~!6-)vA)>L8z5lrLm$`joMN% zW3@)b-V`N2eZSw|^}hFYozFR+bKU3sasPQIKGV~np=6;1001s8T}U&pzBG= zaB@8XGS763)B(m|fFuWySOC0A2U>~&Re6C5c+AT|MgWIyn$^PtE-R04j#R00MIU{pZ^3bu!a**3^6mptxSh0rWr+ z0C=tbf(1zWKWu-kS^ulut~LLE?~q(;F(3(m?3#VA!9eoAS!n!?|H%ej`+v{VYy9o} z|7`E96d?9DnMMw{aqWwWi-`i$|D%D|Jpkl?eKAo{Ao4%GL?Qq{g8WbK-xmV?AAP+7 z>Q_RX0DwEinrg~MLI1YqB?cUPsa85vWf}N)?}|1Q!E5<}A*2niZpmNriQCcK(DBjb zuLIo5+d;{Un{7mXzb}H}G(m~Ph@UEovf|;}QI-*5G#4t*;S{ER#$!)B!aCO#>5Cgy z+7&57`|{kaJjoX-&Ocb;7Y%JsLvSL1M`E0;9^`t2SKLiDhoEQT*`cy1xrgaTPHMPH z&+oxbKeD$rBMe`VJGrKk>3FY^NH-9a7{qtVpd_}OURF<i0jho=Pq-rCUr!=Az zeFO@`GKO#lD+y0%?d`rSoY_-N=7ff)VA#LKHwKkLcu!z&wwSwC5l z%Nh?0{R~B%-^G^~8mkMQt&MPY8QJ~(6qD$+T@QS75|z?a!s!$Q!L0N`u0H>F#C}CU z(CZ(!(L`dwAT{=MclFYonZp|mN(d2g;BjoT+@Ls-pUc-77D`ZBD- z{swnUfrcudZ{3SOG>ECtP_V-8!l=!gk;ux@{*qX`wS-5m#d6z&g0E7t?>}59rFepS z^{h%L(rRt(JUB1)nQZf`3p0<$_JUNlvRy-)D%eajIbJLJ=#gem|K@o@{97DNVF6P} z+;6vM7f+|lE(vw(rj9w-cnIz+i$;KlH!wwI>AM z`jkEOxx+d*ad5CB5B27c1RJo${SbZ-5puW1&Du9}U99Pguk*enXEq%$*LmN|x!dJa zyaGqSWsXICE@PRQHV2YQLCFQkpswDHVDG0wGT7=8-0^OdE+m1xoI@$K)jO)Vnw8^) z$p_iLzy3*IZ)zrcX@c7MG&hZ+JBLf0H)1v4nbK2z##vD5!V-B@v;GvifFa3TR~I&V z-tj!PnvfYMP9_;gaA|MaR|oh}4qwB&h)kJjYh>UMlFT{oV|lgUt^4)Eg|s}Fn!qgX z5G~As)V_7NMT$z4!_A*TwWU-_pLGSxq^d87e@YaDe~rCPHLIKvPHO$l)t(-P{CDlw ze#OJZzH(GzBvHL-X>0a{~knBxstW?#LGU#ClhGmZg>@2|-mr&Cz0!kg`rC;%F=) zmd897BU6Wlz9^fC%6c@KV>}Vg@4X~{+IU>N1C&k6E0W{f5^T%M(3HX2@GPw zcckQA9*C%L{9p>p^km#v9TPi&so7{9IVcFft8S?E^dcW__I#T>3lfWSO{9GzN@Mj^ z$~G1vLZwnM23k%2N3Hpk=bp-|cn(zbC1I?+>=))pyTX|VUQAjiVd@TW6(ah=MVtap z=-1HISiEblcpfQI0U{a?A9adqCQYic7@^2B^4=Ju0JTKxK={!q2l5&;B+)R&l@XgbG%Ta9XAg)Qk z=XCP(32sTHQq{9~U_80ED(*Z%KV@y57hzdo)om`dRPEtI&c>71L9536eKOg1#prd0 zv3=^SH%r0VnXJAyJ2$PSz2lmT&jfgjR%PGB?~}r7QsfJ0$DOSX-T9q=lPJ#u9l~}t zP4?uN3{Gln%KoS*KgrIW~VxQcEh`DTf<(IJqp(4}aDo+46HTNR5&TF4Qs` zqrhy=>gKPP!vcp?XrC|5KYNauxGLi{er#0GaJI$i)tQFp#V__%GFa-|mZ=?1tT1sJ zpbK3mn58S3>+Y`CAcQ5dE9h0=0qUAIMyI6fK5!^L<9dEIaDVzBq?Y9cf@NWI;cWM^cp@<8KxSgwe ze=q_fIIHxnx-$;S7EjH8JT@W2yzw*4c+HUl16E3rU>oT|EsHntpf(L%2wyMPHpbHf zf)O8x;2aB7tQlaS`N4D|Df}MoBbv10XD5$?`s!9Zp$B>RZuv#w3eZS zW9pqX-{@Q1!K#u-BegI0?xbR!B5g+@FQo=MYPWN z*vHak79O?~0E!Ml9$**JI}!r9EcDkswzgmL*$nfFn~S1Yt?TrR!mA`OYZabuRX zH1o9g%r|^QH=sVIx;D{QH~6fCrPSn!)&$ ziIFxsHV=H$mvm5KB*wtbqD5ZHd|OJ0KRmHODz7E>@DRjsDe+wTH*Vp`ci>m{eE)-7 zCZ}7bweb(Xy^jxY%b^QL0t@4TJ*XD)2hxmrdum|Z@7j7A{tly_Vw5(WcO`cO8_Q}D zRhpAVop3IfKTAGKW6?4%wQdPYI_X1unjfw1>Wl_jC8I<*HaW21D>itbC{c6>;r#-l zqdgfby0Fo?nxOHql%myXCaWVX-6Ib^fqqgSb?k1aSIn;!bCw7giRsb#n_|b+W8~Flmg7elBu1V3SVu6buQnuytyI z;r=*W?t@zEQf9J3$l=hPHELnc!tbniXhLF5Kk%CV(IR;?%WcqyF)gSTQb{L<9Ac*r z@~s?d{CDSfTIfnF1<&G=)-Y^Q;AWJTZmveNamQq&j(2YzEr$?XC0OdQF_~TtkQZ7^ z;u==^OJ`_3DNH`#PTS0-zd4c5b*)t4#_(~)(j9}rd_L$6q%@3hKo+5Pi_1v&Lb*O( zt&AF5m%zoBoc*giorxKEu~JmFZ?ES+?!4;a;r|K>jgBA}*zj!vU=?gAE`|DKh-I6S z!XwirxMn2{huw~&j=P1hH;3nypIk*X{E+7-17+FJ{)Xrj<#kTy11t-|Deid}I7f-_ z8Z5C6?*zvjHc{PA(yO{EGwhOg(8rMo+mu!(VTEUHF{;8|o3#zY%Hl7VU`<^E$QQPq z#qMKG?d!3Cm1m*sH|s(5TW@ce-t}=>ekNyKR-z4q6d$*mz!I^N< z+hNZq!T?lJ+V-JrBbNwcw7$~|n>%2&oJpi71KIf@rXXk-5}|k_XQsHn_5-3W+@>v` z*QpjQUb?A!IkNMFOFXrf&+Xfn+sZEfGJAOBBHj5>T!Zd0b>?t=GDT%+^YA;`@Lh~ zg@*;*+OfvYF%?KGpoj`Bqi_Euo<9NdF9;Q(K$p!aBNZ$qSHWFHu4Q zf$#@D3oInOuMg@NsTA0a>FX+P=h35Sb>R~IRb8?keX~gBo4RK|1Rj6GzS9?yCPk3w z>G6RJ)f<#sf^vM-*4WPcPo6T(y5}_N5qedIjQJhD&lxWoHol`6PJ~H6x+`5-bIU8$ zPRpc!f2^(C4#DgO*{EJE%GerJqw~tnNN!R@}3=}@f4gJR>a)7nS(YU5~3N5yr$}#Ogg0~9= z0Fw@hXk|j~KovM;HK&rvO4EkA&R^0`ly*aI(o1}bj%cVv{5?Q@?n!i{&}AA6pczKu zlI2%)Ue43%GT}L7nv3+@zPLux^6MH9^hV>c=R_Mb%73AI`qq!DIq^jC@y7MYVDpuW8T8^5>h(z7C&-3?`FJg*;85^frq_oV=) zTfj%;b~ST&=OPO4q4_QJw&PRrJUX+gQ&(tL$@#@ss>^&NEtjfN7n&&5IEUV&r zD*A?gz?Pb+<#SF(Q@t?#h<&hW##}l>**@Wr=d5V5h*4bh!j=-YG?Hsyt~_{7qluoY zD{c4C35phWgr$Q%>e-PBdgW$0(%nfmU)coAtdz}h%5B+XguHt=%!A#G+gxN7n_EgI z8+cqN#u_*WAphF|A1b)Iu`{e-9G?`dp8H@R{lE za;S`$#FF7Ubbg^P>@!xswT6W8eg9^_??5>(t2Q0_>6DCNCIpY8k*jqOV7V~ z9r{cS7e~4{!cJtWN6I%O{ZzVmySb=p{U`c8FQ+AO=-!sp^m#0DneYT;Ik2O}v zy}Od*Jj)O4bj}$@K85=oi2?t`{=ocbZ-BKm4_6wWa#*6l0t_ohTqHn)y@;!29#E4GVe( zHQgf|xbN@hPeP8RkzHSXW|)m$m%U1TzCoggx8Sc{ z>y0!WJuZHqFl9uZeaoXE`=5TNw?SnEdY-e^#~FuKEY`)0HyV;?R;vZ;Y08u2U!Oje zr+`T@>nV(ZY##o~g)->0=YM)=n+*je?HT zEI}JTXe1zB53AIa#AFmq)u>1ExKmoss@;MUrna%Fpp> zO=dbnp5uOclETBjh-8L${%B!@T`dGXMYWf<_{M358-{aLQDh*b$GPqtCqIY@>q2~e zALdiX;A|}bgP&Jo{F6SswYgOF(vUp*D2B|T$$h*2U|u09CEz=)b|N1>>2@IZoE`IF z-vy$m6sGzZwr>kp5WPi(OJM<t>t^E)Cp8(y4 z!AMsCz{Erza1;Ol908o=ISAn4N?$Kgp2NTO&RqHYAF?}Fmi=SS0j{jVa{$20?R~k| zxdVT%MLs+9+uC66_}6xWdwtdUXYcI#U7*U>l`oF~4spk?sH$AydG))@!_@=$>5p-h zD_3}a`d#n-J^*mwr{DFyDH;V4nr}9>9C(Fz?|*yu62bj~wAW%J&T)-?3wS z{HIQz_(q6dSXhW(P*C)ul(^`5i3@^)mlQ8aNXy8{%ZZ2suK;DQNXg2{epSMA1dmG#^2EgzLR-z(+n5aRjT{#5>3&(-nu)n|Tt@{dn>&Ro8(a^S4R{vhDQ zK_2c`9y|lk2QV)E)9U}%H)lEums3by?yRA)4Hx;9tWVIh)iLPA5cR$~%L7J78!Ao^ zMz1N%OKd0H7cp)LwjDBFS336Hw|l>;COls#u1=WO{mDiA%qK^*_|To?1Ilv;Wj7M| zwx92f4f0wniV3Wr*=_wlPuC(T$quui)#N3<4XleYe^{Gjz=D_`V9@-J(_Ls`qWcy$~hFg>ER0qumb0VUvzM=esN_G7!z zK(#5By`}Y~ZJRpFiHyAEn=KULg__PckJbh1QGJwL4!z&{aHF9w*l-E>#b}5#T}Ae# zt^SbP??uka+7rc3KU#+Ot8aG>G{Y1DF5mqRrabU=vzCO1IKfGp zFW5YzxS|Z{AbUhNo)kJWD43c&43AkA(D6wSF`=H_HGBSgw!tm!&HNI_;FH~LmQ)|2 z9dFJKlMtRJhZje1CSnzq=FD3d+XA~y;m<6aD#7I;UaQ1j+4gecBEmKamOJx~5@d8O zAw5^oT<8}xZ7D*=&wwiGK98-;lU-fG1)%BZkkF2{BF1_D@Ad&oy*m*7QpX|jH1`N; z{DRZ=+z?soV z`-J;;4K2a={=2f3k^c*AE`sgc> zmDgR?`Hn0XY=G>oEP@|e8Z#rgI+@K*9GTesW+;t(vpLEYtblVAtgCNM-qPfE@AWe~ z?T6H3(jNE$sr4>8c363fPA$_Yj{?zfZ`0EbHN!)-~Kwhve6yw!lHr?>{~VD z+$mdvRxeR&F)oP-TGlcTl5}C=m~RS8uvY!{y6zC5wT?$v)=t{=ihWLN<1Zr7u7!kW z&bx4;&VqXu3@Mk>TcfjRFS!!l@-RnZ0OOSCTPdEZ%CUnD_vU0~n@q{8OVrxi@(wZ_ zpt9a$iXHm^{g~a}eSjZsXL1P8ybox#OWTVS*v{MI;7fvCuhHK~g+9Nlm3>p@bdR-! zLUc7VcTZT?VRbL3Hqrcf0}hmRUBGVa_=8Q38D^FCJ!xtsHC<2F5pT?v`r+1p-o*#5 z;+H`u2r0!XuzA(Iqbv6<)Amdg{O@v#*4wVgy*IhZ>Fdua9^owN zp@R5TKC~z+j0*J&rKMa1$1Df9Ll%><%!b~Xbjg07LAFZheLF%-~=}j<(M!j z(0G*+YF&WW;%BdN4d?PE&!ib8c}Y%AKF(%crVVIE)JRK!&5pSFU)hl`4N@acr&>T{ zE(%dCmQ7}gmra5aM-CfK9mHkmziBvDme0QU)YKxxClCH4yGuDLa_NiIUi_PmqAF5K z@wBFnyLBNBFT9un;w&mb)qMw-*WQJey~m>8{+zu1hb;Eq zFY=j+{!gvXOS7mKmLIhBMM;>|{Tlx%i&cXgEG9!!p$ny8I|56pWnOvtQ=Ot}=2 zGJMyry2O0o%t8=wD@$-*A`~GShGFWf_%<$AUdUK_>P0e*Q*ZVn7vJw07OtunL(VVJ z*HYJLKF2+iUU{oIXr<_^tA|XCi24g|WOthz6TR}z&C*g!W>+6Oc?*+C#szt1Q_wuI zxR2}FmWkoPR@H6C^VHv3s*Y zoYJ`&vo1*WCOV*6fzx@7UKWT%dgQ=ZQi6-f$lielX7=v)14cpnfQQAa$ViK#qXALb zVFqjLo*rx-+98u@c4kbP9UYNVZrXeg3mjp#kH+ayI~(RmkvHDGI`zS!v-E_JNO8QY zhrVs~Pc?!SuDzHf__P-aD9D-Eesz>J@B1KD{hDfbL9mzTFo-Y=Ej|X$zzHRNk(z6C zcqh_u`^%X8sN?h1z4HFR)pTIMZs($ zQ4Zw(x>Z3F^fR%k4*wtkRjjQ@OwKpz%e>oV0@TJ)!&nwpP;{Fp{Ch|{SzU{ zfH$*P+4owm(}F4A?izfJoFl~R`p>$kBL%Y>N|DBOgcC=U`Z|Mg)(QoK;j*UQX8Dgs z3g-LGlE%|iQwJ-(GSFT9LNAk6MRn*EFLrww8x>!??ZSFyl_GD14WAGKWT!5)$p>kc zm=@ltn4Sa*ivxkW#Cos!`Znv}Nq!(sYT0{G5p~b@QFbK!Q zFTiT$ld&8a#a8Op+rWupuk0D?w?KcQbfN{gG&j3g*W|+y(C^0KV|bi5ps50fvDuF; z>YC@Bdp+9AU!({LS0v5Si*|Qusm6Y5c5ks|)a!nPAKx;j3%MV;&bTl z1C&upq|0~1ugkS2yKBoQvnLoxZ*zz(sPjGCrd_RVvu3ngu2z1qkDP>>F@tQA)`|HM zCTkf=mhXznx}C~th42~FMAleQ4Ep2fCMaKhqNhZ`K5KU3sl*d0=rsSXSG$Qcry7sUuulTn@iBH8O)2*qpNZ5bS? z-T`Pmuh>=IHc&aZ1*w(Ce;lTHPg$beSH8sdfIG|>uD3{LRK9@RZjhW+OtO?vK9Xc< zPFD9Tm#_c%Yl`$Q?cgZYJ#ab+M`zmr@pc*HMi2!VkA zT5SrF@|Mh&ohf)h(tZD!Q+?tY`2x#QYl4Mc87wF6oh%e(n3_<<(&uJmJ`lFeBO(bIjR-}6|EnzJy( zmoCRBEfDg97Q5I<_r#1>*ZbPHR0Yc+`dRuW&)+Yb*eiC&D; zX|I=x^mcKB^iqmJrZ^!%7G1*Qo?^Loav3#Mz$^2!nXWwZ>^?x1Fd`RGnv*VP9vL6S$PtqXbb6voP}aM`ln9TCjd{%pT>17Ho1vq+GJxq<+AzWEvRv}$9&>O z@J*Z9>|3o%tv)PIN50zTVkbr=C1=X)8fKJIq<~S;^X=%@hO0U_X-2Q)nh#-N@-IWj zq}oV&Aw|u;8!4k{c!HO&O;yUDh!dB!VYLsi62h=4M7G5}Hr?!dVRpsPuz z;?^Ty`3cYhr-~AAE^m`cubz)n`-W0V<$4RN*FWEI%|ur?IUrtnl7nkF*GNqB&aZlK zS1hGAO@!Y6l)C6`E?pbcT*Ld2a8b_U_w!h$K%-XzJdwG51O~G?KH%~ z#y$WuWSsO;s7w&_>iRpX9&#G5{{Fj(pZ+xAKlD7~6P=c(D%RKFOmBYg2z47+JW{p_tB6~dyq1X`-Wzd$ctwJS**Mko94X$ECVT5H zQ0-f5;66a62ApK1xDx#xp#9-r=K5RE*@#a$0X|(_R+p0xM_BsjUtenl8^tw5&uW68 zEnZH~7?mV9(RhnI`Nh~|(9qy)2pYsI5&He9l4@#QZXS!($jsAhk^{okc;%`T8MbFA z85g00@yYp`Mz~xfvn&0+-sGv4^014J&f1J!5^<CGY@Yn_(2 zKu%-Ar*ILha^iM@d1Z7Psxodd0BssY4Rue+Z!`ENKHm%K{X^mx&wdMn{?P|Xa_|om z+w#zAoRdO~MPlO2w~Q&nqGROZq+eees63H4DkUtLNf5|1@wt((r?|w1R@HZ{SESIi zt|ORnR$+< z599?+uAmbQX~k&mo^<4`$3EGI z#-}0k7AD^8!6jIUTmv_!VluwwR3Pna*qTL{L@WOYzGcsTB2*XW@Io`zIqi1TY6=I8 z#k?rw^mc?{`vd1MKA+k<7ajR-cvmE%b^MnXuL8ET?lGS`lhveFCFeY`Q5E;9OpCCb zCG%B}>cT6Eq}VZGTE<0#_6Y$cd0DycxMXL%kPPt<+4~p9gAu{~m=nfmRDIW` z>wZ#-5jjAVQ;;;#GbgwqA9(}E+3424z}H$-spXG{jWuP5t4Fvi1YyHskI=`oBS2}K zA4jbF_5r72tcI$4-!?v|Sbu=*>;$>#kY%7*xX;oYIg{ye_8RMpZXEWtmv8;uGFlDiGQ3UlNp16yG`8+`N2)U(Ras3L6mxa$4Hsv5wCMWJSk~-9RF=wX3F5a zH9AlLuloIBLOB!ceE?9%i4QROV?m{P8mTh8d~2gfHRXKG3e|lc3e)ic z-)1bEEbJlGEw>N1HMCH2G}n`HC7q!Ba;8_FZJg8lr^ShkiqtJrH>Gr#9jrAI{mh6w zo4L^uC)c1Hy;B><9^&8g@3hq}P-Q#!qw;L=6y8;~0Qmh<%3qW(EJVvU{QCMWRiBVlEK{O>>dc%uihY$VF6clU0H^IK&$gDB9r~ zJXh4C5#V@=HkcP+hR=7PRSZ>yl5I6}YPdM=Ub&9yecdqD!(kU(^MfZNGJWta8WeH%-`{uzIFb9uPCQ7AcMklZ zcP{AphQP!kc{A2k1@Mr_XuRCOhBT zF0PSndZyJts$&>R>wZHcphE{R2WIW$eILAiv zU|;)dfxdg4g2C1?UgVZuLx2>0>8fzH0xN{UnU*;cUp3bD^ZBZLs-P(RuX zbyxPijO$FSDLUeKw-ILz%2DpYLtI*6kGJ8|{MhwfHb*;s3n2C7-GV5*{R3>;$YOmyM zw2`F-GVNL09lRF5@o`mO`8Y0XECtQtv_h!xU9$u0JGH~XFA@gzr^VAki_g%vjdzXq z0lBYpRZ|c8y(vGLaqb@84sBuB0kPI1&6_8}%5To8iIE3dyK|kYuUgILTdsn&h=dq0 zJAX54wSP0~MR@XT`_mutLW3OUHT#)8kMi@X<^B8C`N^RydR1H4ay?TGb@LOp$lEfp zJZt#;ZS{U5jCZIxnF?QKl;H0_t7M@nS)(6#WTcZVQPi^tySG;c%q+N^Mc*3Ky!ZYT zY#O1SgIN#w;(eo7WBcSAqh#5hDETY#IzFZJC6(&k8*ELj6Vt*FD|gVIy>-3nZjc^?Fao%P zGix0?*`nlGjI0ULSd}u(a=dG96wtB$pYX4-Nq|_9%{!$s_MLK?C)}DeE$1IVH@kMh zQJUua{#=#y@^4_MS!{i1HB z0*<|yI9ty@S96Lg8{l6RiAPJ-o2S;ats8M^y?XffT}w&cDx)D)MbkaT)T(QrcPz0o zD5OQ5@+n_}v2lJLFnnrfknRhvBeltFN$LGs9{GGxzv$f`HG(q5p4~$T9e<$`1gkB8rso$x?d7`Wp?T8VXPFI& zbC<6Mx0D+lM7qRt>Kw=yG)ye2v(3&V-<7FXgV5$fraluN+?;!?b!`MY(?`%)VAX8e;+Wp*yVFS{mGo_ zK0u^Z;UfjJ-sh!IRMu0`6YC2O*KL!@XV>9FE?$dK!s)qES=ou9geKFaZ^{NgYD_nNOCq+3+8nP3lr0Ck3 z82sYAkladeFM(Es3InH7Icaiso}3B_$t}oeqAx2CVS&NP2od5kS0Q2Dm;8;WO$ts} z_(0+1u*7t$xAxbSQur3>GZ%w{iZ(io2YpeON43PRzc`mv(t%kQkSj2?s3OV*@F)BG zq5`KVej#fj443WiYJYhhMY{pt;-qw1dBAnjPr^RQQpMuk`%g}73IpDEVDMhJv z?G?WBXUB{t*6_CUM~u8Gct-lLa+GiHX8Tp&(s$gWUL@ej`_u8gTh+EHO}PZkMi3p2 z9WJ8g#%lB0d` zpnQ+&UBF#x>G*oGTDZH_<(%_Dq)?mGaqP%xb#?|M=%u9#%0sqU%jJA54DI;*78f1Y zh9JZAAYRM#HDNYMyNAnM-1(%XDPjaR!VEF&Kbr(fj01iz%JFQAXqlJ-^JMb5kO!37 zU0MLio*k_hkJT%i;O1tl8dz`h!2F37i2g(@H(sa>Xn19qb@i zCNj9`a$sVY*eT@C^W0EW*HoOu4Mkcj72Hr{l_p&gsXDxZYO5!Q+NzB}&7x}kNfJ`6 zM9==j5N<{89IDK7@v}>m^KCWa%qyI1Z!X5&W+_rg?*$b&WEmDK{wKUE`ET&f74`AU z!@scczuNGx5o506NVPGm3nw(e{bcn(K;6-Cvoyz%f%q}!%(^J`nyyEU=* zO9{`e6jvq8n)3J??&3M(r<@$U#Xp4Vx9eSKs%Pt$${zf|abVA4VM1VSarasI4&hU$ z!ra*Q*@x=?$LVMo+@hSEjq)(%Crud|@Q+ql#K0ueoQ-AwEyw=Xzx=-w#P { const settings = JSON.parse(afterResult.stdout); expect(settings.models.default).toBe('claude-opus-5-5'); expect(settings.environment.defaultEditor).toBe('vscode'); - expect(settings.system.autoUpdate).toBe(true); + expect(settings.system).toEqual({ logLevel: 'info' }); }, 30_000); it('should not silently modify settings without --force', () => { diff --git a/tests/integration/infrastructure/repositories/sqlite-settings.repository.test.ts b/tests/integration/infrastructure/repositories/sqlite-settings.repository.test.ts index 0c78a19f2..2a1e12165 100644 --- a/tests/integration/infrastructure/repositories/sqlite-settings.repository.test.ts +++ b/tests/integration/infrastructure/repositories/sqlite-settings.repository.test.ts @@ -51,7 +51,6 @@ describe('SQLiteSettingsRepository', () => { terminalPreference: TerminalType.System, }, system: { - autoUpdate: true, logLevel: 'info', }, agent: { @@ -348,23 +347,16 @@ describe('SQLiteSettingsRepository', () => { // Assert expect(loaded?.system).toEqual({ - autoUpdate: true, logLevel: 'info', }); }); - it('should convert integer back to boolean for autoUpdate', async () => { - // Arrange - create with autoUpdate = false - const settings = createTestSettings(); - settings.system.autoUpdate = false; - await repository.initialize(settings); + it('does not surface the removed autoUpdate setting (spec 133)', async () => { + await repository.initialize(createTestSettings()); - // Act const loaded = await repository.load(); - // Assert - expect(loaded?.system.autoUpdate).toBe(false); - expect(typeof loaded?.system.autoUpdate).toBe('boolean'); + expect(loaded?.system).not.toHaveProperty('autoUpdate'); }); it('should parse ISO 8601 timestamps back to Date objects', async () => { @@ -502,7 +494,6 @@ describe('SQLiteSettingsRepository', () => { // Modify system config settings.system = { - autoUpdate: false, logLevel: 'error', }; @@ -903,17 +894,9 @@ describe('SQLiteSettingsRepository', () => { expect(row.sys_auto_update).toBeDefined(); }); - it('should correctly convert boolean to integer for storage', async () => { - // Arrange - const settingsTrue = createTestSettings(); - settingsTrue.system.autoUpdate = true; - - const settingsFalse = createTestSettings(); - settingsFalse.id = 'test-false'; - settingsFalse.system.autoUpdate = false; - + it('keeps writing the legacy NOT NULL sys_auto_update column (spec 133)', async () => { // Act - await repository.initialize(settingsTrue); + await repository.initialize(createTestSettings()); // Assert const rowTrue = db @@ -922,7 +905,7 @@ describe('SQLiteSettingsRepository', () => { expect(rowTrue.sys_auto_update).toBe(1); }); - it('should correctly convert integer back to boolean when loading', async () => { + it('loads a row written by an older build that stored sys_auto_update = 0', async () => { // Arrange - manually insert with integer value db.prepare( `INSERT INTO settings ( @@ -949,8 +932,7 @@ describe('SQLiteSettingsRepository', () => { const loaded = await repository.load(); // Assert - expect(loaded?.system.autoUpdate).toBe(false); - expect(typeof loaded?.system.autoUpdate).toBe('boolean'); + expect(loaded?.system).toEqual({ logLevel: 'info' }); }); }); diff --git a/tests/integration/infrastructure/services/agents/graph-state-transitions/approve-after-failed-rejection.test.ts b/tests/integration/infrastructure/services/agents/graph-state-transitions/approve-after-failed-rejection.test.ts index 2dddd09e5..e99262a7b 100644 --- a/tests/integration/infrastructure/services/agents/graph-state-transitions/approve-after-failed-rejection.test.ts +++ b/tests/integration/infrastructure/services/agents/graph-state-transitions/approve-after-failed-rejection.test.ts @@ -98,7 +98,7 @@ describe('Graph State Transitions › Approve After Failed Rejection', () => { models: { default: 'claude-sonnet-4' }, user: {}, environment: { defaultEditor: 'vscode', shellPreference: 'bash' }, - system: { autoUpdate: false, logLevel: 'error' }, + system: { logLevel: 'error' }, agent: { type: 'claude-code', authMethod: 'session' }, notifications: { inApp: { enabled: false }, diff --git a/tests/integration/infrastructure/services/agents/graph-state-transitions/resume-after-error-merge.test.ts b/tests/integration/infrastructure/services/agents/graph-state-transitions/resume-after-error-merge.test.ts index 6f31f8a71..ce1eb0ca0 100644 --- a/tests/integration/infrastructure/services/agents/graph-state-transitions/resume-after-error-merge.test.ts +++ b/tests/integration/infrastructure/services/agents/graph-state-transitions/resume-after-error-merge.test.ts @@ -134,7 +134,7 @@ describe('Graph State Transitions › Resume After Error at Merge', () => { models: { default: 'claude-sonnet-4' }, user: {}, environment: { defaultEditor: 'vscode', shellPreference: 'bash' }, - system: { autoUpdate: false, logLevel: 'error' }, + system: { logLevel: 'error' }, agent: { type: 'claude-code', authMethod: 'session' }, notifications: { inApp: { enabled: false }, diff --git a/tests/integration/infrastructure/services/agents/graph-state-transitions/resume-after-error.test.ts b/tests/integration/infrastructure/services/agents/graph-state-transitions/resume-after-error.test.ts index 0ce064239..306f2b0e7 100644 --- a/tests/integration/infrastructure/services/agents/graph-state-transitions/resume-after-error.test.ts +++ b/tests/integration/infrastructure/services/agents/graph-state-transitions/resume-after-error.test.ts @@ -137,7 +137,7 @@ describe('Graph State Transitions › Resume After Error', () => { models: { default: 'claude-sonnet-4' }, user: {}, environment: { defaultEditor: 'vscode', shellPreference: 'bash' }, - system: { autoUpdate: false, logLevel: 'error' }, + system: { logLevel: 'error' }, agent: { type: 'claude-code', authMethod: 'session' }, notifications: { inApp: { enabled: false }, diff --git a/tests/integration/infrastructure/services/agents/graph-state-transitions/resume-feedback-propagation.test.ts b/tests/integration/infrastructure/services/agents/graph-state-transitions/resume-feedback-propagation.test.ts index 136cd9092..4be8e7b11 100644 --- a/tests/integration/infrastructure/services/agents/graph-state-transitions/resume-feedback-propagation.test.ts +++ b/tests/integration/infrastructure/services/agents/graph-state-transitions/resume-feedback-propagation.test.ts @@ -146,7 +146,7 @@ describe('Graph State Transitions › Resume Feedback Propagation', () => { models: { default: 'claude-sonnet-4' }, user: {}, environment: { defaultEditor: 'vscode', shellPreference: 'bash' }, - system: { autoUpdate: false, logLevel: 'error' }, + system: { logLevel: 'error' }, agent: { type: 'claude-code', authMethod: 'session' }, notifications: { inApp: { enabled: false }, diff --git a/tests/integration/infrastructure/services/agents/graph-state-transitions/setup.ts b/tests/integration/infrastructure/services/agents/graph-state-transitions/setup.ts index deb5bce0b..57b687af2 100644 --- a/tests/integration/infrastructure/services/agents/graph-state-transitions/setup.ts +++ b/tests/integration/infrastructure/services/agents/graph-state-transitions/setup.ts @@ -49,7 +49,7 @@ function ensureSettingsInitialized(): void { models: { default: 'claude-sonnet-4' }, user: {}, environment: { defaultEditor: 'vscode', shellPreference: 'bash' }, - system: { autoUpdate: false, logLevel: 'error' }, + system: { logLevel: 'error' }, agent: { type: 'claude-code', authMethod: 'session' }, notifications: { inApp: { enabled: false }, diff --git a/tests/integration/infrastructure/services/agents/hitl-approval-flow.test.ts b/tests/integration/infrastructure/services/agents/hitl-approval-flow.test.ts index 494339fdf..77cdef9eb 100644 --- a/tests/integration/infrastructure/services/agents/hitl-approval-flow.test.ts +++ b/tests/integration/infrastructure/services/agents/hitl-approval-flow.test.ts @@ -57,7 +57,7 @@ describe('HITL Approval Flow (Graph-level)', () => { models: { default: 'claude-sonnet-4' }, user: {}, environment: { defaultEditor: 'vscode', shellPreference: 'bash' }, - system: { autoUpdate: false, logLevel: 'error' }, + system: { logLevel: 'error' }, agent: { type: 'claude-code', authMethod: 'session' }, notifications: { inApp: { enabled: false }, diff --git a/tests/unit/application/use-cases/agents/resume-agent-session.use-case.test.ts b/tests/unit/application/use-cases/agents/resume-agent-session.use-case.test.ts index a90f9d67b..176cfe8f8 100644 --- a/tests/unit/application/use-cases/agents/resume-agent-session.use-case.test.ts +++ b/tests/unit/application/use-cases/agents/resume-agent-session.use-case.test.ts @@ -90,7 +90,7 @@ describe('ResumeAgentSessionUseCase', () => { it('fails without spawning anything for an unsupported provider', async () => { await expect( - useCase.execute({ sessionId: SESSION_ID, agentType: AgentType.Aider, cwd: CWD }) + useCase.execute({ sessionId: SESSION_ID, agentType: 'not-an-agent' as AgentType, cwd: CWD }) ).rejects.toThrow(AgentResumeUnsupportedError); expect(createTerminal.execute).not.toHaveBeenCalled(); diff --git a/tests/unit/application/use-cases/initialize-settings.use-case.test.ts b/tests/unit/application/use-cases/initialize-settings.use-case.test.ts index 43f1b9d65..f54806e88 100644 --- a/tests/unit/application/use-cases/initialize-settings.use-case.test.ts +++ b/tests/unit/application/use-cases/initialize-settings.use-case.test.ts @@ -36,7 +36,7 @@ describe('InitializeSettingsUseCase', () => { expect(result).toBeDefined(); expect(result.models.default).toBe(DEFAULT_MODEL_ID); expect(result.environment.defaultEditor).toBe('vscode'); - expect(result.system.autoUpdate).toBe(true); + expect(result.system.logLevel).toBe('info'); }); it('should call repository.initialize() with new settings', async () => { diff --git a/tests/unit/application/use-cases/settings/complete-onboarding.use-case.test.ts b/tests/unit/application/use-cases/settings/complete-onboarding.use-case.test.ts index eef7bb260..3064fdcb1 100644 --- a/tests/unit/application/use-cases/settings/complete-onboarding.use-case.test.ts +++ b/tests/unit/application/use-cases/settings/complete-onboarding.use-case.test.ts @@ -19,7 +19,7 @@ function createTestSettings(overrides: Partial = {}): Settings { shellPreference: 'bash', terminalPreference: TerminalType.System, }, - system: { autoUpdate: true, logLevel: 'info' }, + system: { logLevel: 'info' }, agent: { type: AgentType.ClaudeCode, authMethod: AgentAuthMethod.Session, diff --git a/tests/unit/application/use-cases/update-settings.use-case.test.ts b/tests/unit/application/use-cases/update-settings.use-case.test.ts index de112b028..780c500ce 100644 --- a/tests/unit/application/use-cases/update-settings.use-case.test.ts +++ b/tests/unit/application/use-cases/update-settings.use-case.test.ts @@ -198,24 +198,6 @@ describe('UpdateSettingsUseCase', () => { }); describe('system configuration updates', () => { - it('should update autoUpdate flag', async () => { - // Arrange - const settings = createDefaultSettings(); - const updatedSettings: Settings = { - ...settings, - system: { - ...settings.system, - autoUpdate: false, - }, - }; - - // Act - const result = await useCase.execute(updatedSettings); - - // Assert - expect(result.system.autoUpdate).toBe(false); - }); - it('should update log level', async () => { // Arrange const settings = createDefaultSettings(); diff --git a/tests/unit/domain/factories/settings-defaults.factory.test.ts b/tests/unit/domain/factories/settings-defaults.factory.test.ts index 5f3145a20..ac01d70fc 100644 --- a/tests/unit/domain/factories/settings-defaults.factory.test.ts +++ b/tests/unit/domain/factories/settings-defaults.factory.test.ts @@ -174,14 +174,14 @@ describe('createDefaultSettings', () => { }); describe('SystemConfig defaults', () => { - it('should set autoUpdate to true', () => { + it('has no autoUpdate setting — nothing ever read it (spec 133)', () => { // Act const settings = createDefaultSettings(); const system: SystemConfig = settings.system; // Assert expect(system).toBeDefined(); - expect(system.autoUpdate).toBe(true); + expect(system).not.toHaveProperty('autoUpdate'); }); it('should set logLevel to "info"', () => { @@ -198,7 +198,6 @@ describe('createDefaultSettings', () => { // Assert expect(settings.system).toEqual({ - autoUpdate: true, logLevel: 'info', }); }); @@ -549,7 +548,6 @@ describe('createDefaultSettings', () => { defaultCloneDirectory: '~/repos', }); expect(settings.system).toEqual({ - autoUpdate: true, logLevel: 'info', }); expect(settings.agent).toEqual({ diff --git a/tests/unit/domain/generated/agent-type-codex-cli.test.ts b/tests/unit/domain/generated/agent-type-codex-cli.test.ts index c420c6aac..f01adcfa5 100644 --- a/tests/unit/domain/generated/agent-type-codex-cli.test.ts +++ b/tests/unit/domain/generated/agent-type-codex-cli.test.ts @@ -18,7 +18,5 @@ describe('AgentType.CodexCli', () => { expect(AgentType.GeminiCli).toBe('gemini-cli'); expect(AgentType.Cursor).toBe('cursor'); expect(AgentType.Dev).toBe('dev'); - expect(AgentType.Aider).toBe('aider'); - expect(AgentType.Continue).toBe('continue'); }); }); diff --git a/tests/unit/domain/generated/agent-type-copilot-cli.test.ts b/tests/unit/domain/generated/agent-type-copilot-cli.test.ts index ada00fe22..2bee66282 100644 --- a/tests/unit/domain/generated/agent-type-copilot-cli.test.ts +++ b/tests/unit/domain/generated/agent-type-copilot-cli.test.ts @@ -19,7 +19,5 @@ describe('AgentType.CopilotCli', () => { expect(AgentType.GeminiCli).toBe('gemini-cli'); expect(AgentType.Cursor).toBe('cursor'); expect(AgentType.Dev).toBe('dev'); - expect(AgentType.Aider).toBe('aider'); - expect(AgentType.Continue).toBe('continue'); }); }); diff --git a/tests/unit/domain/shared/agent-catalog.test.ts b/tests/unit/domain/shared/agent-catalog.test.ts index 72ef84d15..f83001faa 100644 --- a/tests/unit/domain/shared/agent-catalog.test.ts +++ b/tests/unit/domain/shared/agent-catalog.test.ts @@ -134,15 +134,17 @@ describe('known drift regressions', () => { expect(isSupportedAgentType(AgentType.GeminiCli)).toBe(true); }); - it('should treat aider and continue as unsupported', () => { - expect(isSupportedAgentType(AgentType.Aider)).toBe(false); - expect(isSupportedAgentType(AgentType.Continue)).toBe(false); + it('has no coming-soon placeholder agents (Aider and Continue were removed, spec 133)', () => { + const types: string[] = Object.values(AgentType); + expect(types).not.toContain('aider'); + expect(types).not.toContain('continue'); + expect(getAgentDescriptor('aider')).toBeUndefined(); + expect(isSupportedAgentType('continue')).toBe(false); }); - it('should exclude unsupported agents from the supported list', () => { - const supported = listSupportedAgentTypes(); - expect(supported).not.toContain(AgentType.Aider); - expect(supported).not.toContain(AgentType.Continue); + it('lists no unsupported agents', () => { + expect(listAgentDescriptors().filter((d) => !d.supported)).toEqual([]); + expect(listSupportedAgentTypes()).toHaveLength(listAgentDescriptors().length); }); }); diff --git a/tests/unit/domain/shared/agent-resume-descriptor.test.ts b/tests/unit/domain/shared/agent-resume-descriptor.test.ts index 4d4692654..3f06b35cc 100644 --- a/tests/unit/domain/shared/agent-resume-descriptor.test.ts +++ b/tests/unit/domain/shared/agent-resume-descriptor.test.ts @@ -28,8 +28,6 @@ const EXPECTED_BINARIES: Record = { [AgentType.KimiCode]: 'kimi', [AgentType.CopilotCli]: null, [AgentType.GeminiCli]: null, - [AgentType.Aider]: null, - [AgentType.Continue]: null, [AgentType.Cline]: null, [AgentType.OpenRouter]: null, [AgentType.TogetherAi]: null, diff --git a/tests/unit/domain/shared/space-environment.test.ts b/tests/unit/domain/shared/space-environment.test.ts index a8c59e56a..61ce71207 100644 --- a/tests/unit/domain/shared/space-environment.test.ts +++ b/tests/unit/domain/shared/space-environment.test.ts @@ -86,7 +86,7 @@ describe('spaceAgent', () => { ok: true, agentType: AgentType.ClaudeCode, }); - expect(spaceAgent(settings, AgentType.Aider)).toEqual({ ok: false }); + expect(spaceAgent(settings, AgentType.Cursor)).toEqual({ ok: false }); }); it('picks the first allowed agent, or leaves the default when every agent is allowed', () => { diff --git a/tests/unit/infrastructure/persistence/sqlite/mappers/settings.mapper.test.ts b/tests/unit/infrastructure/persistence/sqlite/mappers/settings.mapper.test.ts index 3126857c5..467d2c1e0 100644 --- a/tests/unit/infrastructure/persistence/sqlite/mappers/settings.mapper.test.ts +++ b/tests/unit/infrastructure/persistence/sqlite/mappers/settings.mapper.test.ts @@ -17,6 +17,7 @@ import { } from '@/infrastructure/persistence/sqlite/mappers/settings.mapper.js'; import type { Settings } from '@/domain/generated/output.js'; import { AgentEffort } from '@/domain/generated/output.js'; +import { createDefaultSettings } from '@/domain/factories/settings-defaults.factory.js'; import { AgentType, AgentAuthMethod, @@ -50,7 +51,6 @@ function createTestSettings(overrides: Partial = {}): Settings { terminalPreference: TerminalType.System, }, system: { - autoUpdate: true, logLevel: 'info', }, agent: { @@ -491,6 +491,31 @@ describe('Settings Mapper', () => { }); }); + describe('fromDatabase() - removed agent types (spec 133)', () => { + it.each(['aider', 'continue'])( + 'reads a persisted %s agent type back as the default agent', + (legacyType) => { + const settings = fromDatabase(createTestRow({ agent_type: legacyType })); + expect(settings.agent.type).toBe(createDefaultSettings().agent.type); + } + ); + + it('keeps a known agent type', () => { + const settings = fromDatabase(createTestRow({ agent_type: AgentType.CodexCli })); + expect(settings.agent.type).toBe(AgentType.CodexCli); + }); + }); + + describe('legacy sys_auto_update column (spec 133)', () => { + it('still writes the NOT NULL column so inserts and older builds keep working', () => { + expect(toDatabase(createTestSettings()).sys_auto_update).toBe(1); + }); + + it('does not surface autoUpdate on read', () => { + expect(fromDatabase(createTestRow()).system).not.toHaveProperty('autoUpdate'); + }); + }); + describe('fromDatabase() - preserves existing field mappings', () => { it('should still reconstruct all pre-existing fields correctly', () => { const row = createTestRow(); @@ -501,7 +526,6 @@ describe('Settings Mapper', () => { expect(settings.models.default).toBe('claude-opus-4'); expect(settings.user.name).toBe('Test User'); expect(settings.environment.defaultEditor).toBe('vscode'); - expect(settings.system.autoUpdate).toBe(true); expect(settings.agent.type).toBe('claude-code'); }); }); diff --git a/tests/unit/infrastructure/services/agents/agent-executor-factory.test.ts b/tests/unit/infrastructure/services/agents/agent-executor-factory.test.ts index e286b0386..df8f5b609 100644 --- a/tests/unit/infrastructure/services/agents/agent-executor-factory.test.ts +++ b/tests/unit/infrastructure/services/agents/agent-executor-factory.test.ts @@ -94,14 +94,14 @@ describe('AgentExecutorFactory', () => { expect(executor1).toBe(executor2); }); - it('should throw for aider agent type', () => { - const aiderConfig: AgentConfig = { - type: AgentType.Aider, + it('should throw for an unknown agent type', () => { + const unknownConfig: AgentConfig = { + type: 'not-an-agent' as AgentType, authMethod: AgentAuthMethod.Session, }; - expect(() => factory.createExecutor(AgentType.Aider, aiderConfig)).toThrow( - 'Unsupported agent type: aider' + expect(() => factory.createExecutor('not-an-agent' as AgentType, unknownConfig)).toThrow( + 'Unsupported agent type: not-an-agent' ); }); diff --git a/tests/unit/infrastructure/services/agents/agent-validator.service.test.ts b/tests/unit/infrastructure/services/agents/agent-validator.service.test.ts index 662c48abb..be2afb18b 100644 --- a/tests/unit/infrastructure/services/agents/agent-validator.service.test.ts +++ b/tests/unit/infrastructure/services/agents/agent-validator.service.test.ts @@ -118,24 +118,15 @@ describe('AgentValidatorService', () => { }); describe('isAvailable - unsupported agents', () => { - it('should return not available for aider', async () => { + it('should return not available for an unknown agent type', async () => { // Act - const result = await service.isAvailable(AgentType.Aider); + const result = await service.isAvailable('not-an-agent' as AgentType); // Assert expect(result.available).toBe(false); expect(result.error).toContain('not supported yet'); expect(mockExec).not.toHaveBeenCalled(); }); - - it('should return not available for continue', async () => { - // Act - const result = await service.isAvailable(AgentType.Continue); - - // Assert - expect(result.available).toBe(false); - expect(result.error).toContain('not supported yet'); - }); }); describe('isAvailable - codex-cli', () => { diff --git a/tests/unit/presentation/cli/commands/settings/agent.command.test.ts b/tests/unit/presentation/cli/commands/settings/agent.command.test.ts index a00e6e7e7..97b8cc93f 100644 --- a/tests/unit/presentation/cli/commands/settings/agent.command.test.ts +++ b/tests/unit/presentation/cli/commands/settings/agent.command.test.ts @@ -45,7 +45,7 @@ describe('Agent Command', () => { }, user: {}, environment: { defaultEditor: 'vscode', shellPreference: 'bash' }, - system: { autoUpdate: true, logLevel: 'info' }, + system: { logLevel: 'info' }, agent: { type: AgentType.ClaudeCode, authMethod: AgentAuthMethod.Session }, createdAt: new Date(), updatedAt: new Date(), diff --git a/tests/unit/presentation/cli/commands/settings/ide.command.test.ts b/tests/unit/presentation/cli/commands/settings/ide.command.test.ts index 8ec333b64..a876b4aca 100644 --- a/tests/unit/presentation/cli/commands/settings/ide.command.test.ts +++ b/tests/unit/presentation/cli/commands/settings/ide.command.test.ts @@ -88,7 +88,7 @@ describe('IDE Command', () => { }, user: {}, environment: { defaultEditor: 'vscode', shellPreference: 'bash' }, - system: { autoUpdate: true, logLevel: 'info' }, + system: { logLevel: 'info' }, agent: { type: 'claude-code', authMethod: 'session' }, createdAt: new Date(), updatedAt: new Date(), diff --git a/tests/unit/presentation/cli/commands/settings/model.command.test.ts b/tests/unit/presentation/cli/commands/settings/model.command.test.ts index 29c1de4ab..27b163336 100644 --- a/tests/unit/presentation/cli/commands/settings/model.command.test.ts +++ b/tests/unit/presentation/cli/commands/settings/model.command.test.ts @@ -43,7 +43,7 @@ vi.mock('@/infrastructure/services/settings.service.js', () => ({ agent: { type: AgentType.ClaudeCode, authMethod: AgentAuthMethod.Session }, user: {}, environment: { defaultEditor: 'vscode', shellPreference: 'bash' }, - system: { autoUpdate: true, logLevel: 'info' }, + system: { logLevel: 'info' }, workflow: { openPrOnImplementationComplete: false, approvalGateDefaults: { diff --git a/tests/unit/presentation/web/actions/update-settings.test.ts b/tests/unit/presentation/web/actions/update-settings.test.ts index bf599f87b..d6d941d8a 100644 --- a/tests/unit/presentation/web/actions/update-settings.test.ts +++ b/tests/unit/presentation/web/actions/update-settings.test.ts @@ -76,7 +76,7 @@ describe('updateSettingsAction server action', () => { }); it('calls revalidatePath on root layout after success', async () => { - await updateSettingsAction({ system: { autoUpdate: false } }); + await updateSettingsAction({ system: { logLevel: 'warn' } }); expect(mockRevalidatePath).toHaveBeenCalledWith('/', 'layout'); }); diff --git a/translations/ar/tui.json b/translations/ar/tui.json index cc35ede8d..bad564bb3 100644 --- a/translations/ar/tui.json +++ b/translations/ar/tui.json @@ -43,14 +43,6 @@ "name": "Ollama", "description": "تشغيل نماذج LLM محلية عبر Ollama (لا يتطلب مفتاح API، يعمل على localhost)" }, - "aider": { - "name": "Aider", - "disabled": "(قريبًا)" - }, - "continue": { - "name": "Continue", - "disabled": "(قريبًا)" - }, "kimiCode": { "name": "Kimi Code", "description": "وكيل طرفية Kimi Code CLI من Moonshot AI (نماذج Kimi K)" diff --git a/translations/de/tui.json b/translations/de/tui.json index 9205b6a31..9e35aefcf 100644 --- a/translations/de/tui.json +++ b/translations/de/tui.json @@ -43,14 +43,6 @@ "name": "Ollama", "description": "Lokale LLMs über Ollama ausführen (kein API-Schlüssel erforderlich, läuft auf localhost)" }, - "aider": { - "name": "Aider", - "disabled": "(Demnächst verfügbar)" - }, - "continue": { - "name": "Continue", - "disabled": "(Demnächst verfügbar)" - }, "kimiCode": { "name": "Kimi Code", "description": "Kimi Code CLI-Terminal-Agent von Moonshot AI (Kimi-K-Modelle)" diff --git a/translations/en/tui.json b/translations/en/tui.json index 71d0c039d..49e6b2e27 100644 --- a/translations/en/tui.json +++ b/translations/en/tui.json @@ -43,14 +43,6 @@ "name": "Ollama", "description": "Run local LLMs via Ollama (no API key required, runs on localhost)" }, - "aider": { - "name": "Aider", - "disabled": "(Coming Soon)" - }, - "continue": { - "name": "Continue", - "disabled": "(Coming Soon)" - }, "kimiCode": { "name": "Kimi Code", "description": "Moonshot AI's Kimi Code CLI terminal agent (Kimi K-series models)" diff --git a/translations/es/tui.json b/translations/es/tui.json index 3995f5df1..ac68dfc88 100644 --- a/translations/es/tui.json +++ b/translations/es/tui.json @@ -43,14 +43,6 @@ "name": "Ollama", "description": "Ejecutar LLMs locales a través de Ollama (no requiere clave API, se ejecuta en localhost)" }, - "aider": { - "name": "Aider", - "disabled": "(Próximamente)" - }, - "continue": { - "name": "Continue", - "disabled": "(Próximamente)" - }, "kimiCode": { "name": "Kimi Code", "description": "Agente de terminal Kimi Code CLI de Moonshot AI (modelos Kimi K)" diff --git a/translations/fr/tui.json b/translations/fr/tui.json index be91eedbe..60a1f658a 100644 --- a/translations/fr/tui.json +++ b/translations/fr/tui.json @@ -43,14 +43,6 @@ "name": "Ollama", "description": "Exécuter des LLMs locaux via Ollama (pas de clé API requise, fonctionne sur localhost)" }, - "aider": { - "name": "Aider", - "disabled": "(Bientôt disponible)" - }, - "continue": { - "name": "Continue", - "disabled": "(Bientôt disponible)" - }, "kimiCode": { "name": "Kimi Code", "description": "Agent de terminal Kimi Code CLI de Moonshot AI (modèles Kimi K)" diff --git a/translations/he/tui.json b/translations/he/tui.json index 22dd46f6f..5af6e220d 100644 --- a/translations/he/tui.json +++ b/translations/he/tui.json @@ -43,14 +43,6 @@ "name": "Ollama", "description": "הרצת מודלי LLM מקומיים דרך Ollama (לא נדרש מפתח API, רץ על localhost)" }, - "aider": { - "name": "Aider", - "disabled": "(בקרוב)" - }, - "continue": { - "name": "Continue", - "disabled": "(בקרוב)" - }, "kimiCode": { "name": "Kimi Code", "description": "סוכן טרמינל Kimi Code CLI מבית Moonshot AI (מודלי Kimi K)" diff --git a/translations/pt/tui.json b/translations/pt/tui.json index 48ae28859..9220edbd1 100644 --- a/translations/pt/tui.json +++ b/translations/pt/tui.json @@ -43,14 +43,6 @@ "name": "Ollama", "description": "Executar LLMs locais via Ollama (não requer chave API, roda em localhost)" }, - "aider": { - "name": "Aider", - "disabled": "(Em Breve)" - }, - "continue": { - "name": "Continue", - "disabled": "(Em Breve)" - }, "kimiCode": { "name": "Kimi Code", "description": "Agente de terminal Kimi Code CLI da Moonshot AI (modelos Kimi K)" diff --git a/translations/ru/tui.json b/translations/ru/tui.json index 867f65e3f..47f5d60f3 100644 --- a/translations/ru/tui.json +++ b/translations/ru/tui.json @@ -43,14 +43,6 @@ "name": "Ollama", "description": "Запуск локальных LLM через Ollama (API-ключ не требуется, работает на localhost)" }, - "aider": { - "name": "Aider", - "disabled": "(Скоро)" - }, - "continue": { - "name": "Continue", - "disabled": "(Скоро)" - }, "kimiCode": { "name": "Kimi Code", "description": "Терминальный агент Kimi Code CLI от Moonshot AI (модели Kimi K)" diff --git a/translations/uk/tui.json b/translations/uk/tui.json index 74610b8a1..2cf5729dd 100644 --- a/translations/uk/tui.json +++ b/translations/uk/tui.json @@ -43,14 +43,6 @@ "name": "Ollama", "description": "Запуск локальних LLM через Ollama (API-ключ не потрібен, працює на localhost)" }, - "aider": { - "name": "Aider", - "disabled": "(Незабаром)" - }, - "continue": { - "name": "Continue", - "disabled": "(Незабаром)" - }, "kimiCode": { "name": "Kimi Code", "description": "Термінальний агент Kimi Code CLI від Moonshot AI (моделі Kimi K)" diff --git a/tsp/common/enums/agent-config.tsp b/tsp/common/enums/agent-config.tsp index bad325f8a..0ebd3d7d1 100644 --- a/tsp/common/enums/agent-config.tsp +++ b/tsp/common/enums/agent-config.tsp @@ -8,8 +8,7 @@ * ## Supported Agents * * Claude Code, Kimi Code, Codex CLI, Copilot CLI, Gemini CLI, Cursor, Cline, OpenRouter, - * Together AI, Ollama and LLMProxy are fully supported. Aider and Continue are listed for - * future extensibility and appear as "Coming Soon" in the TUI wizard. + * Together AI and Ollama are fully supported, and so is LLMProxy. */ /** * The AI coding agent tool used by Shep for all LLM-powered operations. @@ -27,8 +26,6 @@ * ollama ← Currently supported (SDK-based, local inference) * llmproxy ← Currently supported (SDK-based, local proxy) * dev ← Currently supported (no binary — local mock) - * aider ← Coming Soon - * continue ← Coming Soon * ``` */ @doc("AI coding agent tool selection") @@ -70,20 +67,6 @@ enum AgentType { @doc("Gemini CLI by Google") GeminiCli: "gemini-cli", - /** - * Aider AI coding assistant. - * Not yet supported — listed for future extensibility. - */ - @doc("Aider AI coding assistant (coming soon)") - Aider: "aider", - - /** - * Continue IDE extension. - * Not yet supported — listed for future extensibility. - */ - @doc("Continue IDE extension (coming soon)") - Continue: "continue", - /** * Cursor AI coding agent. * Supported — uses the `cursor-agent` binary for CLI execution. diff --git a/tsp/domain/entities/settings.tsp b/tsp/domain/entities/settings.tsp index c61aa9efe..bc4d1bce0 100644 --- a/tsp/domain/entities/settings.tsp +++ b/tsp/domain/entities/settings.tsp @@ -243,13 +243,6 @@ model EnvironmentConfig { */ @doc("System configuration") model SystemConfig { - /** - * Whether to check for and prompt for CLI updates. - * When false, users must manually upgrade. - */ - @doc("CLI auto-update preference") - autoUpdate: boolean = true; - /** * Log level for CLI output. * Valid values: debug, info, warn, error @@ -269,8 +262,7 @@ model SystemConfig { * Users select their preferred agent via `shep settings agent`. Twelve agents * are fully supported — Claude Code, Kimi Code, Codex CLI, Copilot CLI, Cursor, * Gemini CLI, Cline, OpenRouter, Together AI, Ollama, LLMProxy and the `dev` - * mock. Aider and Continue are listed for future extensibility and appear as - * "Coming Soon". `packages/core/src/domain/shared/agent-catalog.ts` is the + * mock. `packages/core/src/domain/shared/agent-catalog.ts` is the * single source of truth for each agent's binary, tool id and model list. * * ## Authentication @@ -1117,7 +1109,6 @@ model WorktreeConfig { * "terminalPreference": "system" * }, * "system": { - * "autoUpdate": true, * "logLevel": "info" * }, * "agent": { From ef9c9ae49772a3f8d55ef0d24f322c51886d4019 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 11:09:52 +0000 Subject: [PATCH 06/13] feat(web): add a flag per software-factory area and a feature-flags view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Specs 120–132 shipped with no flag. Each software-factory area now has one, default on so nothing changes for users: spaces, trackers, knowledge, signals, opportunities, feedback, discovery, incidents, outcomes, docsFirst, autopilot and factory (TypeSpec, migration 166 with DEFAULT 1, mapper, repository SQL, defaults, web flag state). Each flag gates its area: sidebar entries, pages (requireFeaturePage), POST /api/feedback and /api/alerts (404 while off), CLI groups (gateByFeatureFlag hides the group and says how to turn it on; shep aspm now uses it too), the daemon's sync, discovery, outcome and autopilot passes (checked on every pass), and the docs-first planning instructions and merge gate. A domain catalog gives every flag a group and a one-line description. ListFeatureFlagsUseCase and SetFeatureFlagUseCase serve both /settings/feature-flags (every flag, its default and a switch; linked from Settings) and `shep settings flags [enable|disable ]`. The Settings page's Feature Flags section renders the same catalog-driven list instead of hand-written rows, which also adds the missing githubImport switch. Claude-Session: https://claude.ai/code/session_01TxtN2VcWzg6NTCLj4DbmfL Co-Authored-By: Shep Bot --- .../mocks/app/actions/set-feature-flag.ts | 6 + LESSONS.md | 13 +- apis/json-schema/FeatureFlagGroup.yaml | 8 + apis/json-schema/FeatureFlags.yaml | 60 +++++ docs/FEATURES.md | 49 ++-- docs/api/domain-models.md | 14 + docs/cli/commands.md | 12 + .../docs-first/check-docs-gate.use-case.ts | 17 +- .../select-project-memory.use-case.ts | 13 +- .../application/use-cases/settings/index.ts | 4 + .../settings/list-feature-flags.use-case.ts | 42 +++ .../settings/set-feature-flag.use-case.ts | 48 ++++ .../factories/settings-defaults.factory.ts | 13 + packages/core/src/domain/generated/output.ts | 53 ++++ packages/core/src/domain/shared/docs-first.ts | 13 +- .../src/domain/shared/feature-flag-catalog.ts | 141 ++++++++++ .../di/modules/register-use-cases.ts | 10 + .../sqlite/mappers/settings.mapper.ts | 37 +++ .../166-add-software-factory-feature-flags.ts | 39 +++ .../sqlite-settings.repository.ts | 20 ++ src/presentation/cli/commands/aspm/index.ts | 40 +-- .../cli/commands/background-sync.ts | 34 ++- .../cli/commands/feature-flag-gate.ts | 40 +++ .../cli/commands/settings/flags.command.ts | 96 +++++++ .../cli/commands/settings/index.ts | 5 +- src/presentation/cli/index.ts | 25 +- .../web/app/actions/set-feature-flag.ts | 36 +++ src/presentation/web/app/api/alerts/route.ts | 6 +- .../web/app/api/feedback/route.ts | 6 +- src/presentation/web/app/connections/page.tsx | 2 + src/presentation/web/app/factory/page.tsx | 2 + src/presentation/web/app/incidents/page.tsx | 2 + .../web/app/opportunities/page.tsx | 2 + .../web/app/settings/feature-flags/page.tsx | 21 ++ src/presentation/web/app/spaces/page.tsx | 2 + .../repository-drawer.stories.tsx | 12 + .../features/factory/factory-page-client.tsx | 29 ++- .../feature-flags-page-client.stories.tsx | 43 +++ .../feature-flags-page-client.tsx | 86 ++++++ .../opportunities-page-client.tsx | 45 ++-- .../settings/feature-flags-list.stories.tsx | 80 ++++++ .../features/settings/feature-flags-list.tsx | 85 ++++++ .../settings/settings-page-client.stories.tsx | 12 + .../settings/settings-page-client.tsx | 244 ++---------------- .../settings/settings-rows.stories.tsx | 50 ++++ .../features/settings/settings-rows.tsx | 71 +++++ .../spaces/space-agent-settings-form.tsx | 42 +-- .../app-sidebar/app-sidebar.stories.tsx | 12 + .../layouts/app-sidebar/sidebar-links.ts | 35 ++- .../web/hooks/feature-flags-context.tsx | 12 + src/presentation/web/lib/feature-flags.ts | 37 +++ src/presentation/web/lib/intake-route.ts | 5 + .../web/lib/require-feature-page.ts | 11 + tests/e2e/web/ui-experience.spec.ts | 1 + .../di/container-bootstrap.test.ts | 2 + ...gration-166-software-factory-flags.test.ts | 57 ++++ .../sqlite-settings.repository.test.ts | 114 ++++++++ .../docs-first/check-docs-gate.test.ts | 17 +- .../settings/feature-flags.use-case.test.ts | 84 ++++++ .../settings-defaults.factory.test.ts | 13 + .../shared/feature-flag-catalog.test.ts | 53 ++++ .../sqlite/mappers/settings.mapper.test.ts | 36 +++ .../services/settings-service-update.test.ts | 12 + .../cli/commands/aspm/aspm-command.test.ts | 2 +- .../cli/commands/background-sync.test.ts | 15 ++ .../cli/commands/feature-flag-gate.test.ts | 92 +++++++ .../commands/settings/flags.command.test.ts | 103 ++++++++ .../web/actions/set-feature-flag.test.ts | 50 ++++ .../presentation/web/api/alerts-route.test.ts | 10 + .../web/api/feedback-route.test.ts | 10 + .../add-repository-button.test.tsx | 60 +++++ .../control-center-inner.test.tsx | 12 + .../feature-flags-page-client.test.tsx | 66 +++++ .../layouts/app-shell-overlay-errors.test.tsx | 12 + .../web/layouts/app-shell-skip-link.test.tsx | 12 + .../web/layouts/app-shell-variant.test.tsx | 12 + .../web/layouts/app-shell.test.tsx | 12 + .../web/layouts/app-sidebar.test.tsx | 12 + .../web/layouts/sidebar-links.test.ts | 29 +++ .../web/lib/require-feature-page.test.ts | 32 +++ .../select-project-memory.use-case.test.ts | 27 +- translations/ar/web.json | 43 ++- translations/de/web.json | 43 ++- translations/en/web.json | 43 ++- translations/es/web.json | 43 ++- translations/fr/web.json | 43 ++- translations/he/web.json | 43 ++- translations/pt/web.json | 43 ++- translations/ru/web.json | 43 ++- translations/uk/web.json | 43 ++- tsp/common/enums/feature-flag.tsp | 17 ++ tsp/common/enums/index.tsp | 1 + tsp/domain/entities/settings.tsp | 36 +++ 93 files changed, 2848 insertions(+), 392 deletions(-) create mode 100644 .storybook/mocks/app/actions/set-feature-flag.ts create mode 100644 apis/json-schema/FeatureFlagGroup.yaml create mode 100644 packages/core/src/application/use-cases/settings/list-feature-flags.use-case.ts create mode 100644 packages/core/src/application/use-cases/settings/set-feature-flag.use-case.ts create mode 100644 packages/core/src/domain/shared/feature-flag-catalog.ts create mode 100644 packages/core/src/infrastructure/persistence/sqlite/migrations/166-add-software-factory-feature-flags.ts create mode 100644 src/presentation/cli/commands/feature-flag-gate.ts create mode 100644 src/presentation/cli/commands/settings/flags.command.ts create mode 100644 src/presentation/web/app/actions/set-feature-flag.ts create mode 100644 src/presentation/web/app/settings/feature-flags/page.tsx create mode 100644 src/presentation/web/components/features/feature-flags/feature-flags-page-client.stories.tsx create mode 100644 src/presentation/web/components/features/feature-flags/feature-flags-page-client.tsx create mode 100644 src/presentation/web/components/features/settings/feature-flags-list.stories.tsx create mode 100644 src/presentation/web/components/features/settings/feature-flags-list.tsx create mode 100644 src/presentation/web/components/features/settings/settings-rows.stories.tsx create mode 100644 src/presentation/web/components/features/settings/settings-rows.tsx create mode 100644 src/presentation/web/lib/require-feature-page.ts create mode 100644 tests/integration/infrastructure/persistence/sqlite/migration-166-software-factory-flags.test.ts create mode 100644 tests/unit/application/use-cases/settings/feature-flags.use-case.test.ts create mode 100644 tests/unit/domain/shared/feature-flag-catalog.test.ts create mode 100644 tests/unit/presentation/cli/commands/feature-flag-gate.test.ts create mode 100644 tests/unit/presentation/cli/commands/settings/flags.command.test.ts create mode 100644 tests/unit/presentation/web/actions/set-feature-flag.test.ts create mode 100644 tests/unit/presentation/web/features/feature-flags/feature-flags-page-client.test.tsx create mode 100644 tests/unit/presentation/web/lib/require-feature-page.test.ts create mode 100644 tsp/common/enums/feature-flag.tsp 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/LESSONS.md b/LESSONS.md index 0560b72da..54a5b32f9 100644 --- a/LESSONS.md +++ b/LESSONS.md @@ -797,9 +797,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 133) — 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) @@ -3016,3 +3016,10 @@ that a squash merge would invalidate. `relative(ROOT, file)` returns backslashes on Windows, so a test comparing it with `'packages/core/src/...'` passed on Linux and failed on `windows-latest`. Always `.replace(/\\/g, '/')` a computed path before comparing or printing it in a test. + +## 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. 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 f8c0ef68d..cfe02b427 100644 --- a/apis/json-schema/FeatureFlags.yaml +++ b/apis/json-schema/FeatureFlags.yaml @@ -54,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 @@ -68,4 +116,16 @@ required: - 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/docs/FEATURES.md b/docs/FEATURES.md index cfe6ce7f7..86034f4b0 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -357,22 +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`, `/aspm`, `shep security enforce` — off by default | -| `clusters` | `shep cluster` | -| `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 99b1256d4..19ef70cb8 100644 --- a/docs/api/domain-models.md +++ b/docs/api/domain-models.md @@ -394,6 +394,20 @@ export type FeatureFlags = { clusters: boolean; scheduledWorkflows: boolean; githubImport: boolean; + queryAwareHarness: boolean; + // Software-factory areas (spec 133), 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; }; ``` diff --git a/docs/cli/commands.md b/docs/cli/commands.md index 9bf81304a..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` | 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..4a0cdd243 --- /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 133) + * + * 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..c7e8c159d --- /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 133) + * + * 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/factories/settings-defaults.factory.ts b/packages/core/src/domain/factories/settings-defaults.factory.ts index b74ea7591..f4f950130 100644 --- a/packages/core/src/domain/factories/settings-defaults.factory.ts +++ b/packages/core/src/domain/factories/settings-defaults.factory.ts @@ -244,6 +244,19 @@ export function createDefaultSettings(): Settings { scheduledWorkflows: false, githubImport: true, queryAwareHarness: false, + // Software-factory areas (spec 133): 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 7403f8ed3..caebf12f0 100644 --- a/packages/core/src/domain/generated/output.ts +++ b/packages/core/src/domain/generated/output.ts @@ -769,6 +769,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', @@ -9242,6 +9290,11 @@ export enum WhatsAppThreadTargetKind { Feature = 'feature', Application = 'application', } +export enum FeatureFlagGroup { + Platform = 'platform', + SoftwareFactory = 'software-factory', + Experimental = 'experimental', +} export enum SpaceResolutionSource { Assignment = 'Assignment', Rule = 'Rule', diff --git a/packages/core/src/domain/shared/docs-first.ts b/packages/core/src/domain/shared/docs-first.ts index 040e6354b..e280f38ab 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 133). 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..0513638f2 --- /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 133). 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/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/settings.mapper.ts b/packages/core/src/infrastructure/persistence/sqlite/mappers/settings.mapper.ts index 07f94a0ca..f30251223 100644 --- a/packages/core/src/infrastructure/persistence/sqlite/mappers/settings.mapper.ts +++ b/packages/core/src/infrastructure/persistence/sqlite/mappers/settings.mapper.ts @@ -174,6 +174,19 @@ export interface SettingsRow { feature_flag_github_import?: number; // Query-aware harness flag (migration 151) feature_flag_query_aware_harness?: number; + // Software-factory area flags (spec 133, 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; @@ -366,6 +379,18 @@ export function toDatabase(settings: Settings): SettingsRow { 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, @@ -829,6 +854,18 @@ export function fromDatabase(row: SettingsRow): Settings { // 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/166-add-software-factory-feature-flags.ts b/packages/core/src/infrastructure/persistence/sqlite/migrations/166-add-software-factory-feature-flags.ts new file mode 100644 index 000000000..01e6018b6 --- /dev/null +++ b/packages/core/src/infrastructure/persistence/sqlite/migrations/166-add-software-factory-feature-flags.ts @@ -0,0 +1,39 @@ +/** + * Migration 166: one feature flag per software-factory area (spec 133). + * + * 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-settings.repository.ts b/packages/core/src/infrastructure/repositories/sqlite-settings.repository.ts index 0c80544fb..ffef5f3c7 100644 --- a/packages/core/src/infrastructure/repositories/sqlite-settings.repository.ts +++ b/packages/core/src/infrastructure/repositories/sqlite-settings.repository.ts @@ -87,6 +87,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, @@ -145,6 +149,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, @@ -303,6 +311,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/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 4b2027368..15d513d7d 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) @@ -38,6 +40,19 @@ import type { INotificationService } from '@/application/ports/output/services/n import type { IGitPrService } from '@/application/ports/output/services/git-pr-service.interface.js'; 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 { 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 133). + */ +function whenOn( + flag: FeatureFlagKey, + job: (...args: Args) => Promise +): (...args: Args) => Promise { + return (...args) => (isFeatureFlagOn(flag) ? job(...args) : Promise.resolve()); +} export interface BackgroundSync { stop(): void; @@ -53,16 +68,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( @@ -70,11 +90,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') ); initializePrSyncWatcher( 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..2b75c43cf --- /dev/null +++ b/src/presentation/cli/commands/feature-flag-gate.ts @@ -0,0 +1,40 @@ +/** + * Gates a command group behind feature flags (spec 133). + * + * 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/settings/flags.command.ts b/src/presentation/cli/commands/settings/flags.command.ts new file mode 100644 index 000000000..f2c5c6253 --- /dev/null +++ b/src/presentation/cli/commands/settings/flags.command.ts @@ -0,0 +1,96 @@ +/** + * Feature Flags Command (spec 133) + * + * 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/index.ts b/src/presentation/cli/index.ts index 9bdcdce1f..36d7ff1a5 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'; @@ -208,18 +209,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/actions/set-feature-flag.ts b/src/presentation/web/app/actions/set-feature-flag.ts new file mode 100644 index 000000000..a6f5d0e56 --- /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 133). + * + * 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/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/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/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..87bf119b9 --- /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 133). */ +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/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/factory/factory-page-client.tsx b/src/presentation/web/components/features/factory/factory-page-client.tsx index c26bbd9d9..bd986c132 100644 --- a/src/presentation/web/components/features/factory/factory-page-client.tsx +++ b/src/presentation/web/components/features/factory/factory-page-client.tsx @@ -15,6 +15,7 @@ import { Factory } from 'lucide-react'; import type { AutopilotRun } from '@shepai/core/domain/generated/output'; import type { FactoryStatus } from '@shepai/core/application/use-cases/autopilot/get-factory-status.use-case'; import { useRunAction } from '@/hooks/use-run-action'; +import { useFeatureFlags } from '@/hooks/feature-flags-context'; import { FactoryStatusCards } from './factory-status-cards'; import { AutopilotForm } from './autopilot-form'; import { AutopilotRuns } from './autopilot-runs'; @@ -37,6 +38,8 @@ export function FactoryPageClient({ loadError, }: FactoryPageClientProps) { const { t } = useTranslation('web'); + // The autopilot policy and passes have their own flag (spec 133). + const flags = useFeatureFlags(); const { run, error } = useRunAction({ fallbackError: t('factory.errors.actionFailed'), ...(loadError ? { initialError: loadError } : {}), @@ -77,17 +80,21 @@ export function FactoryPageClient({ {status ? ( <> - -
-

{t('factory.runs.title')}

- -
+ {flags.autopilot ? ( + <> + +
+

{t('factory.runs.title')}

+ +
+ + ) : null} ) : null}
diff --git a/src/presentation/web/components/features/feature-flags/feature-flags-page-client.stories.tsx b/src/presentation/web/components/features/feature-flags/feature-flags-page-client.stories.tsx new file mode 100644 index 000000000..b209deb05 --- /dev/null +++ b/src/presentation/web/components/features/feature-flags/feature-flags-page-client.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { createDefaultSettings } from '@shepai/core/domain/factories/settings-defaults.factory'; +import { listFeatureFlagDescriptors } from '@shepai/core/domain/shared/feature-flag-catalog'; +import { FeatureFlagGroup } from '@shepai/core/domain/generated/output'; +import type { FeatureFlagState } from '@shepai/core/application/use-cases/settings/list-feature-flags.use-case'; +import { FeatureFlagsPageClient } from './feature-flags-page-client'; + +const DEFAULTS = createDefaultSettings().featureFlags!; + +const AT_DEFAULTS: FeatureFlagState[] = listFeatureFlagDescriptors().map((descriptor) => ({ + ...descriptor, + enabled: DEFAULTS[descriptor.key], + defaultEnabled: DEFAULTS[descriptor.key], +})); + +const meta: Meta = { + title: 'Features/FeatureFlagsPageClient', + component: FeatureFlagsPageClient, + tags: ['autodocs'], + parameters: { layout: 'fullscreen' }, +}; + +export default meta; +type Story = StoryObj; + +/** A fresh install: every flag at its default. */ +export const Default: Story = { args: { initialFlags: AT_DEFAULTS } }; + +/** The owner has turned ASPM on and the software factory off. */ +export const Customized: Story = { + args: { + initialFlags: AT_DEFAULTS.map((flag) => + flag.key === 'aspm' + ? { ...flag, enabled: true } + : flag.group === FeatureFlagGroup.SoftwareFactory + ? { ...flag, enabled: false } + : flag + ), + }, +}; + +/** Nothing to show (the use case returned no flags). */ +export const Empty: Story = { args: { initialFlags: [] } }; diff --git a/src/presentation/web/components/features/feature-flags/feature-flags-page-client.tsx b/src/presentation/web/components/features/feature-flags/feature-flags-page-client.tsx new file mode 100644 index 000000000..53a58e990 --- /dev/null +++ b/src/presentation/web/components/features/feature-flags/feature-flags-page-client.tsx @@ -0,0 +1,86 @@ +'use client'; + +/** + * The feature-flags view (spec 133): every flag Shep has, what it turns on, + * its default and a switch. Changes save immediately. + */ + +import { useState, useTransition } from 'react'; +import Link from 'next/link'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import { ArrowLeft, Check, Flag } from 'lucide-react'; +import type { FeatureFlagState } from '@shepai/core/application/use-cases/settings/list-feature-flags.use-case'; +import type { FeatureFlagKey } from '@shepai/core/domain/shared/feature-flag-catalog'; +import { setFeatureFlag } from '@/app/actions/set-feature-flag'; +import { FeatureFlagsList } from '@/components/features/settings/feature-flags-list'; + +export interface FeatureFlagsPageClientProps { + initialFlags: FeatureFlagState[]; +} + +export function FeatureFlagsPageClient({ initialFlags }: FeatureFlagsPageClientProps) { + const { t } = useTranslation('web'); + const [flags, setFlags] = useState(initialFlags); + const [saved, setSaved] = useState(false); + const [pending, startTransition] = useTransition(); + + const setEnabled = (key: FeatureFlagKey, enabled: boolean) => + setFlags((current) => current.map((flag) => (flag.key === key ? { ...flag, enabled } : flag))); + + const toggle = (key: FeatureFlagKey, enabled: boolean) => { + setEnabled(key, enabled); + setSaved(false); + startTransition(async () => { + const result = await setFeatureFlag(key, enabled); + if (result.ok) { + setSaved(true); + return; + } + setEnabled(key, !enabled); + toast.error(result.error ?? t('settings.failedToSave')); + }); + }; + + const onCount = flags.filter((flag) => flag.enabled).length; + + return ( +
+ +
+ ); +} diff --git a/src/presentation/web/components/features/opportunities/opportunities-page-client.tsx b/src/presentation/web/components/features/opportunities/opportunities-page-client.tsx index 17bcc8390..436edff7c 100644 --- a/src/presentation/web/components/features/opportunities/opportunities-page-client.tsx +++ b/src/presentation/web/components/features/opportunities/opportunities-page-client.tsx @@ -29,6 +29,7 @@ import { FeedbackThemes } from './feedback-themes'; import { FeedbackKeysPanel } from './feedback-keys-panel'; import { DiscoveryPanel } from './discovery-panel'; import { OutcomesPanel } from './outcomes-panel'; +import { useFeatureFlags } from '@/hooks/feature-flags-context'; import type { OpportunityPageOptions } from './opportunities-types'; import type { FeedbackTheme } from '@shepai/core/domain/shared/feedback-themes'; import type { FeedbackKeyView } from '@shepai/core/application/use-cases/feedback/manage-feedback-keys.use-case'; @@ -60,6 +61,8 @@ export function OpportunitiesPageClient({ loadError, }: OpportunitiesPageClientProps) { const { t } = useTranslation('web'); + // Signals, feedback, discovery and outcomes each have their own flag (spec 133). + const flags = useFeatureFlags(); const { run, error } = useRunAction({ fallbackError: t('opportunities.errors.actionFailed'), ...(loadError ? { initialError: loadError } : {}), @@ -132,31 +135,39 @@ export function OpportunitiesPageClient({ )} - {outcomes ? ( + {flags.outcomes && outcomes ? ( ) : null} - - -
- - opportunity)} + {flags.discovery ? ( + -
- + ) : null} + {flags.feedback ? ( + + ) : null} + {flags.signals ? ( +
+ + opportunity)} + run={run} + /> +
+ ) : null} + {flags.feedback ? ( + + ) : null} ({ + key, + group, + enabled: DEFAULTS[key], + defaultEnabled: DEFAULTS[key], +})); + +const meta: Meta = { + title: 'Settings/FeatureFlagsList', + component: FeatureFlagsList, + tags: ['autodocs'], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +function Interactive({ + initial, + showDefaults, +}: { + initial: FeatureFlagListItem[]; + showDefaults: boolean; +}) { + const [items, setItems] = useState(initial); + return ( + + showDefaults ? item : { key: item.key, group: item.group, enabled: item.enabled } + )} + onToggle={(key, enabled) => + setItems((current) => + current.map((item) => (item.key === key ? { ...item, enabled } : item)) + ) + } + /> + ); +} + +/** Every flag at its default, with the "default on/off" badge (the dedicated view). */ +export const Default: Story = { + render: () => , +}; + +/** Without default badges, as the Settings page section shows it. */ +export const WithoutDefaults: Story = { + render: () => , +}; + +/** Every software-factory area turned off. */ +export const SoftwareFactoryOff: Story = { + render: () => ( + + item.group === FeatureFlagGroup.SoftwareFactory ? { ...item, enabled: false } : item + )} + showDefaults + /> + ), +}; + +/** Read-only while a save is in flight. */ +export const Disabled: Story = { + args: { items: ITEMS, onToggle: () => undefined, disabled: true }, +}; diff --git a/src/presentation/web/components/features/settings/feature-flags-list.tsx b/src/presentation/web/components/features/settings/feature-flags-list.tsx new file mode 100644 index 000000000..08a6fc51f --- /dev/null +++ b/src/presentation/web/components/features/settings/feature-flags-list.tsx @@ -0,0 +1,85 @@ +'use client'; + +/** + * Every feature flag as a switch, grouped by section (spec 133). Used by the + * Feature Flags section of the Settings page and by /settings/feature-flags. + * The list and its order come from the domain catalog, so a new flag shows up + * here without touching this component. + */ + +import { useTranslation } from 'react-i18next'; +import { FeatureFlagGroup } from '@shepai/core/domain/generated/output'; +import type { FeatureFlagKey } from '@shepai/core/domain/shared/feature-flag-catalog'; +import { Badge } from '@/components/ui/badge'; +import { Switch } from '@/components/ui/switch'; +import { SettingsRow } from './settings-rows'; + +export interface FeatureFlagListItem { + key: FeatureFlagKey; + group: FeatureFlagGroup; + enabled: boolean; + /** Shown as a "default on/off" badge when given. */ + defaultEnabled?: boolean; +} + +export interface FeatureFlagsListProps { + items: readonly FeatureFlagListItem[]; + onToggle: (key: FeatureFlagKey, enabled: boolean) => void; + disabled?: boolean; +} + +const GROUP_TITLE_KEYS: Record = { + [FeatureFlagGroup.Platform]: 'settings.featureFlags.groupPlatform', + [FeatureFlagGroup.SoftwareFactory]: 'settings.featureFlags.groupSoftwareFactory', + [FeatureFlagGroup.Experimental]: 'settings.featureFlags.groupExperimental', +}; + +export function FeatureFlagsList({ items, onToggle, disabled = false }: FeatureFlagsListProps) { + const { t } = useTranslation('web'); + return ( +
+ {Object.values(FeatureFlagGroup).map((group) => { + const inGroup = items.filter((item) => item.group === group); + if (inGroup.length === 0) return null; + return ( +
+

+ {t(GROUP_TITLE_KEYS[group])} +

+ {inGroup.map((item) => ( + + {item.defaultEnabled === undefined ? null : ( + + {item.defaultEnabled + ? t('settings.featureFlags.defaultOn') + : t('settings.featureFlags.defaultOff')} + + )} + onToggle(item.key, enabled)} + disabled={disabled} + className="cursor-pointer disabled:cursor-not-allowed disabled:opacity-50" + /> + + ))} +
+ ); + })} +
+ ); +} diff --git a/src/presentation/web/components/features/settings/settings-page-client.stories.tsx b/src/presentation/web/components/features/settings/settings-page-client.stories.tsx index 49775f219..a7d93de42 100644 --- a/src/presentation/web/components/features/settings/settings-page-client.stories.tsx +++ b/src/presentation/web/components/features/settings/settings-page-client.stories.tsx @@ -47,6 +47,18 @@ export const AllSections: Story = { scheduledWorkflows: false, 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, }, }, shepHome: '/opt/shep', diff --git a/src/presentation/web/components/features/settings/settings-page-client.tsx b/src/presentation/web/components/features/settings/settings-page-client.tsx index 4de73067f..941c4f9c9 100644 --- a/src/presentation/web/components/features/settings/settings-page-client.tsx +++ b/src/presentation/web/components/features/settings/settings-page-client.tsx @@ -30,11 +30,13 @@ import { Plug, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { cn } from '@/lib/utils'; -import { Label } from '@/components/ui/label'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; -import { Switch } from '@/components/ui/switch'; +import Link from 'next/link'; +import { SettingsRow, SwitchRow } from './settings-rows'; +import { FeatureFlagsList } from './feature-flags-list'; +import { listFeatureFlagDescriptors } from '@shepai/core/domain/shared/feature-flag-catalog'; +import { createDefaultSettings } from '@shepai/core/domain/factories/settings-defaults.factory'; import { Select, SelectContent, @@ -155,69 +157,6 @@ export interface SettingsPageClientProps { availableTerminals?: AvailableTerminal[]; } -/* ── Reusable row components ── */ - -function SettingsRow({ - label, - description, - htmlFor, - children, -}: { - label: string; - description?: string; - htmlFor?: string; - children: React.ReactNode; -}) { - return ( -
-
- - {description ? ( -

{description}

- ) : null} -
-
{children}
-
- ); -} - -function SwitchRow({ - label, - description, - id, - testId, - checked, - onChange, - disabled, -}: { - label: string; - description?: string; - id: string; - testId: string; - checked: boolean; - onChange: (value: boolean) => void; - disabled?: boolean; -}) { - return ( - - - - ); -} - /* ── Section card wrapper ── */ function SettingsSection({ @@ -390,21 +329,7 @@ export function SettingsPageClient({ const { t, i18n: i18nInstance } = useTranslation('web'); const { showSaving, showSaved, save } = useSettingsSave(); const hydrated = useHydrated(); - const featureFlags = settings.featureFlags ?? { - envDeploy: false, - debug: false, - reactFileManager: false, - projects: false, - codeReview: false, - collaboration: false, - aspm: false, - bedrockIntegration: true, - whatsappDispatch: false, - clusters: false, - scheduledWorkflows: false, - githubImport: true, - queryAwareHarness: false, - }; + const featureFlags = settings.featureFlags ?? createDefaultSettings().featureFlags!; // Language state const [language, setLanguage] = useState(settings.user?.preferredLanguage ?? Language.English); @@ -1912,150 +1837,27 @@ export function SettingsPageClient({ badge={t('settings.featureFlags.badge')} testId="feature-flags-settings-section" > - { - const newFlags = { ...flags, envDeploy: v }; - setFlags(newFlags); - save({ featureFlags: newFlags }); - }} - /> - { - const newFlags = { ...flags, debug: v }; - setFlags(newFlags); - save({ featureFlags: newFlags }); - }} - /> - { - const newFlags = { ...flags, reactFileManager: v }; - setFlags(newFlags); - save({ featureFlags: newFlags }); - }} - /> - { - const newFlags = { ...flags, projects: v }; - setFlags(newFlags); - save({ featureFlags: newFlags }); - }} - /> - { - const newFlags = { ...flags, codeReview: v }; - setFlags(newFlags); - save({ featureFlags: newFlags }); - }} - /> - { - const newFlags = { ...flags, collaboration: v }; - setFlags(newFlags); - save({ featureFlags: newFlags }); - }} - /> - { - const newFlags = { ...flags, aspm: v }; - setFlags(newFlags); - save({ featureFlags: newFlags }); - }} - /> - { - const newFlags = { ...flags, bedrockIntegration: v }; - setFlags(newFlags); - save({ featureFlags: newFlags }); - }} - /> - { - const newFlags = { ...flags, whatsappDispatch: v }; - setFlags(newFlags); - save({ featureFlags: newFlags }); - }} - /> - { - const newFlags = { ...flags, clusters: v }; - setFlags(newFlags); - save({ featureFlags: newFlags }); - }} - /> - { - const newFlags = { ...flags, scheduledWorkflows: v }; - setFlags(newFlags); - save({ featureFlags: newFlags }); - }} - /> - { - const newFlags = { ...flags, queryAwareHarness: v }; + ({ + key, + group, + enabled: flags[key], + }))} + onToggle={(key, enabled) => { + const newFlags = { ...flags, [key]: enabled }; setFlags(newFlags); save({ featureFlags: newFlags }); }} /> +
+ + {t('settings.featureFlags.openView')} + +
{t('settings.featureFlags.hint')} diff --git a/src/presentation/web/components/features/settings/settings-rows.stories.tsx b/src/presentation/web/components/features/settings/settings-rows.stories.tsx new file mode 100644 index 000000000..50c1066e0 --- /dev/null +++ b/src/presentation/web/components/features/settings/settings-rows.stories.tsx @@ -0,0 +1,50 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { Input } from '@/components/ui/input'; +import { SettingsRow, SwitchRow } from './settings-rows'; + +const meta: Meta = { + title: 'Settings/SettingsRows', + component: SwitchRow, + tags: ['autodocs'], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +function ToggleDemo({ disabled = false }: { disabled?: boolean }) { + const [checked, setChecked] = useState(true); + return ( + + ); +} + +/** A switch row the user can toggle. */ +export const Default: Story = { render: () => }; + +/** A disabled switch row. */ +export const Disabled: Story = { render: () => }; + +/** A plain row wrapping any control. */ +export const WithInput: Story = { + render: () => ( + + + + ), +}; diff --git a/src/presentation/web/components/features/settings/settings-rows.tsx b/src/presentation/web/components/features/settings/settings-rows.tsx new file mode 100644 index 000000000..03193239d --- /dev/null +++ b/src/presentation/web/components/features/settings/settings-rows.tsx @@ -0,0 +1,71 @@ +'use client'; + +/** + * The label + control rows every Settings section is built from. + */ + +import { cn } from '@/lib/utils'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; + +export function SettingsRow({ + label, + description, + htmlFor, + children, +}: { + label: string; + description?: string; + htmlFor?: string; + children: React.ReactNode; +}) { + return ( +
+ {/* Grows and shrinks so the controls stay beside a long description; wraps below 16rem. */} +
+ + {description ? ( +

{description}

+ ) : null} +
+
{children}
+
+ ); +} + +export function SwitchRow({ + label, + description, + id, + testId, + checked, + onChange, + disabled, +}: { + label: string; + description?: string; + id: string; + testId: string; + checked: boolean; + onChange: (value: boolean) => void; + disabled?: boolean; +}) { + return ( + + + + ); +} diff --git a/src/presentation/web/components/features/spaces/space-agent-settings-form.tsx b/src/presentation/web/components/features/spaces/space-agent-settings-form.tsx index 0f952478d..e9106f320 100644 --- a/src/presentation/web/components/features/spaces/space-agent-settings-form.tsx +++ b/src/presentation/web/components/features/spaces/space-agent-settings-form.tsx @@ -32,6 +32,7 @@ import { NATIVE_SELECT_CLASS } from '@/lib/native-select-class'; import type { RunSpaceAction } from './spaces-types'; import { CheckboxFieldset, toggled } from './checkbox-fieldset'; import { DocsFirstFieldset } from './docs-first-fieldset'; +import { useFeatureFlags } from '@/hooks/feature-flags-context'; type TextField = | 'claudeConfigDir' @@ -104,6 +105,7 @@ export interface SpaceAgentSettingsFormProps { export function SpaceAgentSettingsForm({ space, run }: SpaceAgentSettingsFormProps) { const { t } = useTranslation('web'); + const flags = useFeatureFlags(); const settings: SpaceAgentSettings = space.agentSettings ?? {}; const [open, setOpen] = useState(false); const [text, setText] = useState>(() => ({ @@ -237,25 +239,29 @@ export function SpaceAgentSettingsForm({ space, run }: SpaceAgentSettingsFormPro - ({ - value: kind, - label: t(`incidents.actionKind.${kind}`), - }))} - selected={autoActions} - onToggle={(kind) => setAutoActions((current) => toggled(current, kind))} - testIdPrefix="agent-settings-auto" - /> + {flags.incidents ? ( + ({ + value: kind, + label: t(`incidents.actionKind.${kind}`), + }))} + selected={autoActions} + onToggle={(kind) => setAutoActions((current) => toggled(current, kind))} + testIdPrefix="agent-settings-auto" + /> + ) : null} - + {flags.docsFirst ? ( + + ) : null} ; 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 ? (