diff --git a/.doc/system-design/module-boundaries.yaml b/.doc/system-design/module-boundaries.yaml index b9b7c460..e81d9e5f 100644 --- a/.doc/system-design/module-boundaries.yaml +++ b/.doc/system-design/module-boundaries.yaml @@ -193,6 +193,7 @@ imports: - squad - stream - stream/transport + - swboot - taskrun - upload - workflowdef @@ -209,6 +210,7 @@ imports: stream: [] stream/transport: - stream + swboot: [] taskrun: - app/uow - artifactvault diff --git a/internal/projectworkflow/catalog.go b/internal/projectworkflow/catalog.go index ec42b490..b6960948 100644 --- a/internal/projectworkflow/catalog.go +++ b/internal/projectworkflow/catalog.go @@ -28,11 +28,11 @@ type memoryCatalog struct { order []string } -// NewDefaultCatalog 创建默认工作流目录(内建 main、product_manager、test_manager) +// NewDefaultCatalog 创建默认工作流目录(医疗三 + 软件自举/交付) func NewDefaultCatalog() Catalog { c := &memoryCatalog{ definitions: make(map[string]*Definition), - order: []string{"main", "product_manager", "test_manager"}, + order: []string{"main", "product_manager", "test_manager", "software_bootstrap", "software_delivery"}, } c.definitions["main"] = &Definition{ @@ -69,6 +69,28 @@ func NewDefaultCatalog() Catalog { Enabled: true, } + c.definitions["software_bootstrap"] = &Definition{ + Key: "software_bootstrap", + Name: "软件项目自举流程", + Description: "S0–S4:章程、现状、需求、架构、风险、路线与就绪评审", + StageKey: "S0_boundary", + TaskKey: "software_charter", + OutputDocType: "software_charter", + OutputDocTitle: "项目章程", + Enabled: true, + } + + c.definitions["software_delivery"] = &Definition{ + Key: "software_delivery", + Name: "软件交付迭代流程", + Description: "D0–D4:变更定义、实施证据、验证、发布与复盘", + StageKey: "D0_change_definition", + TaskKey: "change_brief", + OutputDocType: "change_brief", + OutputDocTitle: "变更简报", + Enabled: true, + } + return c } diff --git a/internal/projectworkflow/service.go b/internal/projectworkflow/service.go index 3f79091e..a5e24a40 100644 --- a/internal/projectworkflow/service.go +++ b/internal/projectworkflow/service.go @@ -61,6 +61,14 @@ func (s *Service) CreateProject(ctx context.Context, user *auth.UserAccount, in } } + stage := "V0.1_triage" + if strings.TrimSpace(in.ProductDomain) == "non_medical_software" { + stage = "S0_boundary" + if strings.TrimSpace(in.ExecutionMode) == "controlled_patch" { + return nil, fmt.Errorf("%w: controlled_patch requires SWBOOT-10 executor", ErrInvalidInput) + } + } + p := &Project{ Code: code, Name: name, @@ -68,7 +76,20 @@ func (s *Service) CreateProject(ctx context.Context, user *auth.UserAccount, in DeviceType: in.DeviceType, IntendedUse: in.IntendedUse, TargetMarket: in.TargetMarket, - CurrentStage: "V0.1_triage", + ProductDomain: strings.TrimSpace(in.ProductDomain), + ProjectTypeKey: strings.TrimSpace(in.ProjectTypeKey), + ComplianceProfileKey: strings.TrimSpace(in.ComplianceProfileKey), + GovernanceProfile: strings.TrimSpace(in.GovernanceProfile), + TeamMode: strings.TrimSpace(in.TeamMode), + ExecutionMode: strings.TrimSpace(in.ExecutionMode), + DataSensitivity: strings.TrimSpace(in.DataSensitivity), + NetworkPolicy: strings.TrimSpace(in.NetworkPolicy), + BudgetClass: strings.TrimSpace(in.BudgetClass), + ProblemStatement: strings.TrimSpace(in.ProblemStatement), + TargetUsers: strings.TrimSpace(in.TargetUsers), + TargetOutcome: strings.TrimSpace(in.TargetOutcome), + ProductShape: strings.TrimSpace(in.ProductShape), + CurrentStage: stage, Environment: "dev", Status: "active", CreatorUserID: user.ID, diff --git a/internal/projectworkflow/service_test.go b/internal/projectworkflow/service_test.go index 266f8f0f..81a3d48a 100644 --- a/internal/projectworkflow/service_test.go +++ b/internal/projectworkflow/service_test.go @@ -72,7 +72,7 @@ func TestProjectWorkflowService_CreateAndCreatorACL(t *testing.T) { if err != nil { t.Fatalf("list keyed workflows: %v", err) } - if len(keyedWFs) != 3 { + if len(keyedWFs) != 5 { t.Fatalf("catalog must still mount after keyed create, got %d", len(keyedWFs)) } @@ -81,8 +81,8 @@ func TestProjectWorkflowService_CreateAndCreatorACL(t *testing.T) { if err != nil { t.Fatalf("ListProjectWorkflows failed: %v", err) } - if len(wfs) != 3 { - t.Fatalf("expected 3 default workflows, got %d", len(wfs)) + if len(wfs) != 5 { + t.Fatalf("expected 5 default workflows, got %d", len(wfs)) } // 4. 非创建人尝试切换工作流 -> 必须被拒绝 @@ -230,3 +230,48 @@ func TestProjectWorkflowService_LineageAndStale(t *testing.T) { t.Errorf("expected downstream 'doc_test_plan_001', got '%s'", staleUpdates[0].DownstreamDocID) } } + +func TestCreateSoftwareProject_NoMedicalDefaults(t *testing.T) { + ctx := context.Background() + svc := NewService(NewStore(nil), NewDefaultCatalog()) + creator := &auth.UserAccount{ID: 201, Username: "dev", Name: "开发者"} + + proj, err := svc.CreateProject(ctx, creator, CreateProjectInput{ + Name: "Agent-System 自举", + ProductDomain: "non_medical_software", + ProjectTypeKey: "existing_self_bootstrap", + ComplianceProfileKey: "internal_control+security_baseline", + GovernanceProfile: "standard", + TeamMode: "solo", + ExecutionMode: "docs_only", + ProblemStatement: "建立可信路线", + TargetUsers: "内部团队", + TargetOutcome: "可执行发布路线", + InitialWorkflowKey: "software_bootstrap", + }) + if err != nil { + t.Fatalf("CreateProject: %v", err) + } + if proj.ProductDomain != "non_medical_software" || proj.ProjectTypeKey != "existing_self_bootstrap" { + t.Fatalf("domain/type not persisted: %+v", proj) + } + if proj.CurrentStage != "S0_boundary" { + t.Fatalf("expected S0_boundary stage, got %s", proj.CurrentStage) + } + if proj.DeviceType != "" || proj.IntendedUse != "" { + t.Fatalf("software create must not invent medical device fields: device=%q use=%q", proj.DeviceType, proj.IntendedUse) + } + if proj.ActiveWorkflowKey != "" { + t.Fatal("must remain planning without activation") + } + + _, err = svc.CreateProject(ctx, creator, CreateProjectInput{ + Name: "bad", + ProductDomain: "non_medical_software", + ExecutionMode: "controlled_patch", + }) + if err == nil || !errors.Is(err, ErrInvalidInput) { + t.Fatalf("expected controlled_patch rejection, got %v", err) + } +} + diff --git a/internal/projectworkflow/store.go b/internal/projectworkflow/store.go index 81f6731f..a2387e54 100644 --- a/internal/projectworkflow/store.go +++ b/internal/projectworkflow/store.go @@ -73,15 +73,29 @@ func (s *pgStore) CreateProject(ctx context.Context, p *Project) (*Project, erro query := ` INSERT INTO projects ( code, name, customer_name, device_type, intended_use, target_market, + product_domain, project_type_key, compliance_profile_key, governance_profile, + team_mode, execution_mode, data_sensitivity, network_policy, budget_class, + problem_statement, target_users, target_outcome, product_shape, current_stage, environment, status, creator_user_id, lifecycle_status, active_workflow_key, workflow_selection_version, workflow_plan_revision, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + ) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, $10, + $11, $12, $13, $14, $15, + $16, $17, $18, $19, + $20, $21, $22, $23, $24, + $25, $26, $27, + $28, $29 + ) RETURNING id ` err := s.pool.QueryRow( ctx, query, p.Code, p.Name, p.CustomerName, p.DeviceType, p.IntendedUse, p.TargetMarket, + p.ProductDomain, p.ProjectTypeKey, p.ComplianceProfileKey, p.GovernanceProfile, + p.TeamMode, p.ExecutionMode, p.DataSensitivity, p.NetworkPolicy, p.BudgetClass, + p.ProblemStatement, p.TargetUsers, p.TargetOutcome, p.ProductShape, p.CurrentStage, p.Environment, p.Status, p.CreatorUserID, p.LifecycleStatus, p.ActiveWorkflowKey, p.WorkflowSelectionVersion, p.WorkflowPlanRevision, p.CreatedAt, p.UpdatedAt, @@ -96,6 +110,11 @@ func (s *pgStore) CreateProject(ctx context.Context, p *Project) (*Project, erro func (s *pgStore) GetProjectByID(ctx context.Context, id int64) (*Project, error) { query := ` SELECT id, code, name, customer_name, device_type, intended_use, target_market, + COALESCE(product_domain, ''), COALESCE(project_type_key, ''), COALESCE(compliance_profile_key, ''), + COALESCE(governance_profile, ''), COALESCE(team_mode, ''), COALESCE(execution_mode, ''), + COALESCE(data_sensitivity, ''), COALESCE(network_policy, ''), COALESCE(budget_class, ''), + COALESCE(problem_statement, ''), COALESCE(target_users, ''), COALESCE(target_outcome, ''), + COALESCE(product_shape, ''), current_stage, environment, status, COALESCE(creator_user_id, 1), COALESCE(lifecycle_status, 'planning'), COALESCE(active_workflow_key, ''), COALESCE(workflow_selection_version, 1), COALESCE(workflow_plan_revision, 0), @@ -106,6 +125,10 @@ func (s *pgStore) GetProjectByID(ctx context.Context, id int64) (*Project, error p := &Project{} err := s.pool.QueryRow(ctx, query, id).Scan( &p.ID, &p.Code, &p.Name, &p.CustomerName, &p.DeviceType, &p.IntendedUse, &p.TargetMarket, + &p.ProductDomain, &p.ProjectTypeKey, &p.ComplianceProfileKey, + &p.GovernanceProfile, &p.TeamMode, &p.ExecutionMode, + &p.DataSensitivity, &p.NetworkPolicy, &p.BudgetClass, + &p.ProblemStatement, &p.TargetUsers, &p.TargetOutcome, &p.ProductShape, &p.CurrentStage, &p.Environment, &p.Status, &p.CreatorUserID, &p.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision, &p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt, diff --git a/internal/projectworkflow/types.go b/internal/projectworkflow/types.go index e1d2e866..18275e41 100644 --- a/internal/projectworkflow/types.go +++ b/internal/projectworkflow/types.go @@ -13,6 +13,19 @@ type Project struct { DeviceType string `json:"device_type"` IntendedUse string `json:"intended_use"` TargetMarket string `json:"target_market"` + ProductDomain string `json:"product_domain,omitempty"` + ProjectTypeKey string `json:"project_type_key,omitempty"` + ComplianceProfileKey string `json:"compliance_profile_key,omitempty"` + GovernanceProfile string `json:"governance_profile,omitempty"` + TeamMode string `json:"team_mode,omitempty"` + ExecutionMode string `json:"execution_mode,omitempty"` + DataSensitivity string `json:"data_sensitivity,omitempty"` + NetworkPolicy string `json:"network_policy,omitempty"` + BudgetClass string `json:"budget_class,omitempty"` + ProblemStatement string `json:"problem_statement,omitempty"` + TargetUsers string `json:"target_users,omitempty"` + TargetOutcome string `json:"target_outcome,omitempty"` + ProductShape string `json:"product_shape,omitempty"` CurrentStage string `json:"current_stage"` Environment string `json:"environment"` Status string `json:"status"` @@ -85,7 +98,7 @@ type DocumentLineage struct { CreatedAt time.Time `json:"created_at"` } -// CreateProjectInput 创建项目输入参数 +// CreateProjectInput 创建项目输入参数(兼容医疗字段 + 软件向导钩子)。 type CreateProjectInput struct { Name string `json:"name"` Code string `json:"code,omitempty"` @@ -93,6 +106,21 @@ type CreateProjectInput struct { DeviceType string `json:"device_type,omitempty"` IntendedUse string `json:"intended_use,omitempty"` TargetMarket string `json:"target_market,omitempty"` + ProductDomain string `json:"product_domain,omitempty"` + ProjectTypeKey string `json:"project_type_key,omitempty"` + ComplianceProfileKey string `json:"compliance_profile_key,omitempty"` + GovernanceProfile string `json:"governance_profile,omitempty"` + TeamMode string `json:"team_mode,omitempty"` + ExecutionMode string `json:"execution_mode,omitempty"` + DataSensitivity string `json:"data_sensitivity,omitempty"` + NetworkPolicy string `json:"network_policy,omitempty"` + BudgetClass string `json:"budget_class,omitempty"` + ProblemStatement string `json:"problem_statement,omitempty"` + TargetUsers string `json:"target_users,omitempty"` + TargetOutcome string `json:"target_outcome,omitempty"` + ProductShape string `json:"product_shape,omitempty"` + CreationToken string `json:"creation_token,omitempty"` + PlanHash string `json:"plan_hash,omitempty"` // Deprecated: accepted for catalog existence checks only; never activates the project. InitialWorkflowKey string `json:"initial_workflow_key,omitempty"` } diff --git a/internal/server/server.go b/internal/server/server.go index a459929b..903a6b16 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -166,6 +166,9 @@ func (s *Server) Routes() http.Handler { // 2. 项目与多工作流控制面 mux.HandleFunc("POST /api/v1/projects", s.handleCreateProject) + mux.HandleFunc("POST /api/v1/projects/creation-preview", s.handleProjectCreationPreview) + mux.HandleFunc("GET /api/v1/project-types/catalog", s.handleProjectTypesCatalog) + mux.HandleFunc("GET /api/v1/compliance-profiles/catalog", s.handleComplianceProfilesCatalog) mux.HandleFunc("GET /api/v1/projects", s.handleListProjects) mux.HandleFunc("GET /api/v1/projects/{id}", s.handleGetProjectByID) mux.HandleFunc("GET /api/v1/projects/{id}/members", s.handleProjectMembers) diff --git a/internal/server/software_project_handlers.go b/internal/server/software_project_handlers.go new file mode 100644 index 00000000..35d0667f --- /dev/null +++ b/internal/server/software_project_handlers.go @@ -0,0 +1,60 @@ +package server + +import ( + "encoding/json" + "net/http" + "time" + + "chat-agent-system/internal/swboot" +) + +// handleProjectTypesCatalog GET /api/v1/project-types/catalog +func (s *Server) handleProjectTypesCatalog(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requirePrincipal(w, r); !ok { + return + } + writeJSONResponse(w, http.StatusOK, map[string]any{ + "project_types": swboot.ProjectTypesCatalog(), + }) +} + +// handleComplianceProfilesCatalog GET /api/v1/compliance-profiles/catalog +func (s *Server) handleComplianceProfilesCatalog(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requirePrincipal(w, r); !ok { + return + } + writeJSONResponse(w, http.StatusOK, map[string]any{ + "compliance_profiles": swboot.ComplianceProfilesCatalog(), + }) +} + +// handleProjectCreationPreview POST /api/v1/projects/creation-preview +func (s *Server) handleProjectCreationPreview(w http.ResponseWriter, r *http.Request) { + user, err := s.authenticateUser(r) + if err != nil || user == nil { + writeSafeError(w, r, http.StatusUnauthorized, `{"error":"ERR_UNAUTHENTICATED","message":"请先登录"}`) + return + } + if ok, permErr := s.userHasSystemPermission(r.Context(), user, "project:create"); permErr != nil { + writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+permErr.Error()+`"}`) + return + } else if !ok { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "ERR_FORBIDDEN", + "message": "无权创建项目", + }) + return + } + + var req swboot.CreationPreviewInput + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`) + return + } + res := swboot.BuildCreationPreview(req, time.Now().UTC()) + status := http.StatusOK + if !res.Valid { + status = http.StatusBadRequest + } + writeJSONResponse(w, status, res) +} diff --git a/internal/swboot/catalog.go b/internal/swboot/catalog.go new file mode 100644 index 00000000..c06cde09 --- /dev/null +++ b/internal/swboot/catalog.go @@ -0,0 +1,256 @@ +package swboot + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + "time" +) + +// Product domains and enumerations (SWBOOT-00 contract). +const ( + DomainNonMedicalSoftware = "non_medical_software" + DomainMedicalDevice = "medical_device" + + ProjectTypeGreenfield = "greenfield" + ProjectTypeExistingProduct = "existing_product" + ProjectTypeLegacyModernization = "legacy_modernization" + ProjectTypeExistingSelfBootstrap = "existing_self_bootstrap" + + GovernanceLean = "lean" + GovernanceStandard = "standard" + GovernanceControlled = "controlled" + + TeamModeSolo = "solo" + TeamModeCollaborative = "collaborative" + + ExecutionDocsOnly = "docs_only" + ExecutionReadOnlyRepo = "read_only_repo" + ExecutionControlledPatch = "controlled_patch" + + ApprovalIndependentThreeDuties = "independent_three_duties" + ApprovalCreatorAcknowledgement = "creator_acknowledgement" +) + +// ProjectTypeCatalogItem is a create-wizard selectable project type. +type ProjectTypeCatalogItem struct { + Key string `json:"key"` + Name string `json:"name"` + ProductDomain string `json:"product_domain"` + Description string `json:"description"` + DefaultGovernance string `json:"default_governance"` + DefaultWorkflowKey string `json:"default_workflow_key"` + RecommendedWorkflows []string `json:"recommended_workflows"` +} + +// ComplianceProfileCatalogItem is a create-wizard compliance option. +type ComplianceProfileCatalogItem struct { + Key string `json:"key"` + Name string `json:"name"` + Description string `json:"description"` + RequiresIndependentReview bool `json:"requires_independent_review"` +} + +// CreationPreviewInput is the seven-step wizard confirmation payload. +type CreationPreviewInput struct { + Name string `json:"name"` + Code string `json:"code,omitempty"` + ProductDomain string `json:"product_domain"` + ProjectTypeKey string `json:"project_type_key"` + ProductShape string `json:"product_shape,omitempty"` + ProblemStatement string `json:"problem_statement"` + TargetUsers string `json:"target_users"` + TargetOutcome string `json:"target_outcome"` + GovernanceProfile string `json:"governance_profile"` + TeamMode string `json:"team_mode"` + ComplianceProfileKey string `json:"compliance_profile_key"` + DataSensitivity string `json:"data_sensitivity,omitempty"` + NetworkPolicy string `json:"network_policy,omitempty"` + ExecutionMode string `json:"execution_mode"` + BudgetClass string `json:"budget_class,omitempty"` + InitialWorkflowKey string `json:"initial_workflow_key,omitempty"` + CatalogVersionHint string `json:"expected_catalog_version,omitempty"` +} + +// CreationPreviewResult is returned by POST /projects/creation-preview. +type CreationPreviewResult struct { + CreationToken string `json:"creation_token"` + PlanHash string `json:"plan_hash"` + SourceHash string `json:"source_hash"` + ExpectedCatalogVersion string `json:"expected_catalog_version"` + ExpiresAt time.Time `json:"expires_at"` + ApprovalPolicy string `json:"approval_policy"` + RecommendedWorkflow string `json:"recommended_workflow"` + Warnings []string `json:"warnings,omitempty"` + Issues []string `json:"issues,omitempty"` + Valid bool `json:"valid"` +} + +// ProjectTypesCatalog returns seeded software project types. +func ProjectTypesCatalog() []ProjectTypeCatalogItem { + return []ProjectTypeCatalogItem{ + { + Key: ProjectTypeGreenfield, Name: "绿地立项", ProductDomain: DomainNonMedicalSoftware, + Description: "从零规划新软件", DefaultGovernance: GovernanceStandard, + DefaultWorkflowKey: "software_bootstrap", RecommendedWorkflows: []string{"software_bootstrap", "software_delivery"}, + }, + { + Key: ProjectTypeExistingProduct, Name: "既有产品梳理", ProductDomain: DomainNonMedicalSoftware, + Description: "梳理已有产品与仓库", DefaultGovernance: GovernanceStandard, + DefaultWorkflowKey: "software_bootstrap", RecommendedWorkflows: []string{"software_bootstrap", "software_delivery"}, + }, + { + Key: ProjectTypeLegacyModernization, Name: "遗留现代化", ProductDomain: DomainNonMedicalSoftware, + Description: "遗留系统改造与迁移", DefaultGovernance: GovernanceStandard, + DefaultWorkflowKey: "software_bootstrap", RecommendedWorkflows: []string{"software_bootstrap", "software_delivery"}, + }, + { + Key: ProjectTypeExistingSelfBootstrap, Name: "系统自举", ProductDomain: DomainNonMedicalSoftware, + Description: "用 Agent-System 梳理并建设自身", DefaultGovernance: GovernanceStandard, + DefaultWorkflowKey: "software_bootstrap", RecommendedWorkflows: []string{"software_bootstrap", "software_delivery"}, + }, + } +} + +// ComplianceProfilesCatalog returns seeded compliance profiles. +func ComplianceProfilesCatalog() []ComplianceProfileCatalogItem { + return []ComplianceProfileCatalogItem{ + {Key: "none", Name: "无外部合规预设", Description: "不预设外部法规步骤;鉴权/审计/安全基线仍生效"}, + {Key: "internal_control", Name: "内部控制", Description: "内部流程、权限与审计约束"}, + {Key: "security_baseline", Name: "安全基线", Description: "威胁建模、依赖与 secret 边界"}, + {Key: "privacy", Name: "隐私保护", Description: "数据分类与最小权限", RequiresIndependentReview: true}, + {Key: "custom", Name: "自定义合规", Description: "项目自定义合规包", RequiresIndependentReview: true}, + {Key: "internal_control+security_baseline", Name: "内部控制+安全基线", Description: "首期推荐组合"}, + } +} + +// BuildCreationPreview validates wizard input and issues a short-lived token. +func BuildCreationPreview(in CreationPreviewInput, now time.Time) CreationPreviewResult { + res := CreationPreviewResult{ + Valid: true, + ExpectedCatalogVersion: strings.TrimSpace(in.CatalogVersionHint), + ExpiresAt: now.Add(15 * time.Minute), + SourceHash: "sha256:empty", + } + if res.ExpectedCatalogVersion == "" { + res.ExpectedCatalogVersion = "software-v1" + } + + name := strings.TrimSpace(in.Name) + if name == "" { + res.Valid = false + res.Issues = append(res.Issues, "name is required") + } + domain := strings.TrimSpace(in.ProductDomain) + if domain == "" { + res.Valid = false + res.Issues = append(res.Issues, "product_domain is required") + } + if domain != "" && domain != DomainNonMedicalSoftware && domain != DomainMedicalDevice { + res.Valid = false + res.Issues = append(res.Issues, "unsupported product_domain") + } + ptype := strings.TrimSpace(in.ProjectTypeKey) + if domain == DomainNonMedicalSoftware { + if !isKnownProjectType(ptype) { + res.Valid = false + res.Issues = append(res.Issues, "project_type_key is required for non_medical_software") + } + if strings.TrimSpace(in.ProblemStatement) == "" || strings.TrimSpace(in.TargetUsers) == "" || strings.TrimSpace(in.TargetOutcome) == "" { + res.Valid = false + res.Issues = append(res.Issues, "problem_statement, target_users and target_outcome are required") + } + } + + gov := strings.TrimSpace(in.GovernanceProfile) + if gov == "" { + gov = GovernanceStandard + } + if gov != GovernanceLean && gov != GovernanceStandard && gov != GovernanceControlled { + res.Valid = false + res.Issues = append(res.Issues, "invalid governance_profile") + } + + team := strings.TrimSpace(in.TeamMode) + if team == "" { + team = TeamModeCollaborative + } + compliance := strings.TrimSpace(in.ComplianceProfileKey) + if compliance == "" { + compliance = "internal_control+security_baseline" + } + if requiresIndependentReview(compliance) && team == TeamModeSolo { + res.Valid = false + res.Issues = append(res.Issues, "compliance profile requiring independent review forbids solo") + } + if gov == GovernanceControlled && team == TeamModeSolo { + res.Valid = false + res.Issues = append(res.Issues, "controlled governance forbids solo") + } + + execMode := strings.TrimSpace(in.ExecutionMode) + if execMode == "" { + execMode = ExecutionDocsOnly + } + switch execMode { + case ExecutionDocsOnly, ExecutionReadOnlyRepo: + // ok for SWBOOT-00..09 + case ExecutionControlledPatch: + res.Valid = false + res.Issues = append(res.Issues, "ERR_EXECUTION_MODE_DISABLED: controlled_patch requires SWBOOT-10 executor") + default: + res.Valid = false + res.Issues = append(res.Issues, "invalid execution_mode") + } + + wf := strings.TrimSpace(in.InitialWorkflowKey) + if wf == "" && domain == DomainNonMedicalSoftware { + wf = "software_bootstrap" + } + if domain == DomainNonMedicalSoftware && wf != "" && wf != "software_bootstrap" && wf != "software_delivery" { + res.Warnings = append(res.Warnings, "non-software workflow selected for non_medical_software project") + } + res.RecommendedWorkflow = wf + if team == TeamModeSolo { + res.ApprovalPolicy = ApprovalCreatorAcknowledgement + res.Warnings = append(res.Warnings, "未独立评审:solo 使用 creator_acknowledgement") + } else { + res.ApprovalPolicy = ApprovalIndependentThreeDuties + } + + payload := map[string]any{ + "name": name, "code": strings.TrimSpace(in.Code), + "product_domain": domain, "project_type_key": ptype, + "governance_profile": gov, "team_mode": team, + "compliance_profile_key": compliance, "execution_mode": execMode, + "workflow": wf, "catalog": res.ExpectedCatalogVersion, + "problem_statement": strings.TrimSpace(in.ProblemStatement), + "target_users": strings.TrimSpace(in.TargetUsers), + "target_outcome": strings.TrimSpace(in.TargetOutcome), + } + raw, _ := json.Marshal(payload) + sum := sha256.Sum256(raw) + res.PlanHash = "sha256:" + hex.EncodeToString(sum[:]) + res.CreationToken = fmt.Sprintf("crt_%s", hex.EncodeToString(sum[:8])) + if !res.Valid { + res.CreationToken = "" + res.PlanHash = "" + } + return res +} + +func isKnownProjectType(key string) bool { + switch key { + case ProjectTypeGreenfield, ProjectTypeExistingProduct, ProjectTypeLegacyModernization, ProjectTypeExistingSelfBootstrap: + return true + default: + return false + } +} + +func requiresIndependentReview(profile string) bool { + p := strings.ToLower(profile) + return strings.Contains(p, "privacy") || strings.Contains(p, "custom") || p == "controlled" +} diff --git a/internal/swboot/catalog_test.go b/internal/swboot/catalog_test.go new file mode 100644 index 00000000..da5e6926 --- /dev/null +++ b/internal/swboot/catalog_test.go @@ -0,0 +1,92 @@ +package swboot + +import ( + "errors" + "strings" + "testing" + "time" +) + +func TestProjectTypesCatalog(t *testing.T) { + items := ProjectTypesCatalog() + if len(items) != 4 { + t.Fatalf("expected 4 project types, got %d", len(items)) + } + for _, it := range items { + if it.ProductDomain != DomainNonMedicalSoftware { + t.Fatalf("project type %s polluted domain %q", it.Key, it.ProductDomain) + } + if it.DefaultWorkflowKey != "software_bootstrap" { + t.Fatalf("expected software_bootstrap default for %s", it.Key) + } + blob := strings.ToLower(it.Name + " " + it.Description) + for _, bad := range []string{"临床", "nmpa", "cdmo"} { + if strings.Contains(blob, bad) { + t.Fatalf("medical term in project type %s", it.Key) + } + } + } +} + +func TestBuildCreationPreview_SelfBootstrapSolo(t *testing.T) { + res := BuildCreationPreview(CreationPreviewInput{ + Name: "Agent-System 自举", ProductDomain: DomainNonMedicalSoftware, + ProjectTypeKey: ProjectTypeExistingSelfBootstrap, + ProblemStatement: "建立可信路线", TargetUsers: "内部团队", TargetOutcome: "可执行发布路线", + GovernanceProfile: GovernanceStandard, TeamMode: TeamModeSolo, + ComplianceProfileKey: "internal_control+security_baseline", + ExecutionMode: ExecutionDocsOnly, + }, time.Date(2026, 9, 16, 12, 0, 0, 0, time.UTC)) + if !res.Valid { + t.Fatalf("expected valid preview, issues=%v", res.Issues) + } + if res.ApprovalPolicy != ApprovalCreatorAcknowledgement { + t.Fatalf("expected creator acknowledgement, got %s", res.ApprovalPolicy) + } + if res.RecommendedWorkflow != "software_bootstrap" { + t.Fatalf("expected software_bootstrap, got %s", res.RecommendedWorkflow) + } + if res.CreationToken == "" || res.PlanHash == "" { + t.Fatal("expected token and plan hash") + } +} + +func TestBuildCreationPreview_RejectsControlledPatchAndSoloPrivacy(t *testing.T) { + res := BuildCreationPreview(CreationPreviewInput{ + Name: "x", ProductDomain: DomainNonMedicalSoftware, + ProjectTypeKey: ProjectTypeGreenfield, + ProblemStatement: "p", TargetUsers: "u", TargetOutcome: "o", + TeamMode: TeamModeSolo, ComplianceProfileKey: "privacy", + ExecutionMode: ExecutionControlledPatch, + }, time.Now().UTC()) + if res.Valid { + t.Fatal("expected invalid") + } + joined := strings.Join(res.Issues, ";") + if !strings.Contains(joined, "solo") { + t.Fatalf("expected solo/privacy issue, got %v", res.Issues) + } + if !strings.Contains(joined, "ERR_EXECUTION_MODE_DISABLED") { + t.Fatalf("expected controlled_patch disabled, got %v", res.Issues) + } +} + +func TestRepoAllowlistStub(t *testing.T) { + al := NewRepoAllowlist([]string{"/home/weichen/projects/Agent-System"}) + if err := al.CheckReadOnlyAccess("/home/weichen/projects/Agent-System/cmd"); err != nil { + t.Fatalf("expected allowlisted path ok: %v", err) + } + if err := al.CheckReadOnlyAccess("/etc/passwd"); !errors.Is(err, ErrRepoPathNotAllowlisted) { + t.Fatalf("expected not allowlisted, got %v", err) + } + if err := al.ExecuteControlledPatch("/home/weichen/projects/Agent-System", nil); !errors.Is(err, ErrPatchExecutionUnimplemented) { + t.Fatalf("expected stub rejection, got %v", err) + } + if err := ApproveMerge("feature/x"); !errors.Is(err, ErrPatchExecutionUnimplemented) { + t.Fatalf("expected merge stub rejection, got %v", err) + } + empty := DefaultRepoAllowlist() + if empty.IsAllowlisted("/home/weichen/projects/Agent-System") { + t.Fatal("empty allowlist must deny all") + } +} diff --git a/internal/swboot/repo_allowlist.go b/internal/swboot/repo_allowlist.go new file mode 100644 index 00000000..998a6821 --- /dev/null +++ b/internal/swboot/repo_allowlist.go @@ -0,0 +1,76 @@ +package swboot + +import ( + "errors" + "fmt" + "path/filepath" + "strings" +) + +// SWBOOT-10 stub errors — real patch/merge is intentionally unimplemented. +var ( + ErrRepoPathNotAllowlisted = errors.New("ERR_REPO_PATH_NOT_ALLOWLISTED") + ErrControlledPatchDisabled = errors.New("ERR_EXECUTION_MODE_DISABLED") + ErrPatchExecutionUnimplemented = errors.New("ERR_SWBOOT10_STUB_ONLY") +) + +// RepoAllowlist is an in-memory / admin-seeded path prefix allowlist stub. +type RepoAllowlist struct { + prefixes []string +} + +// NewRepoAllowlist builds a stub allowlist from configured path prefixes. +func NewRepoAllowlist(prefixes []string) *RepoAllowlist { + out := make([]string, 0, len(prefixes)) + for _, p := range prefixes { + p = strings.TrimSpace(p) + if p == "" { + continue + } + out = append(out, filepath.Clean(p)) + } + return &RepoAllowlist{prefixes: out} +} + +// DefaultRepoAllowlist returns an empty allowlist (deny-all until admin seeds paths). +func DefaultRepoAllowlist() *RepoAllowlist { + return NewRepoAllowlist(nil) +} + +// IsAllowlisted reports whether absPath is under an enabled prefix. +func (a *RepoAllowlist) IsAllowlisted(absPath string) bool { + if a == nil || len(a.prefixes) == 0 { + return false + } + clean := filepath.Clean(strings.TrimSpace(absPath)) + if !filepath.IsAbs(clean) { + return false + } + for _, prefix := range a.prefixes { + if clean == prefix || strings.HasPrefix(clean, prefix+string(filepath.Separator)) { + return true + } + } + return false +} + +// CheckReadOnlyAccess validates a path for docs_only / read_only_repo modes. +func (a *RepoAllowlist) CheckReadOnlyAccess(absPath string) error { + if !a.IsAllowlisted(absPath) { + return fmt.Errorf("%w: path %q not in admin allowlist", ErrRepoPathNotAllowlisted, absPath) + } + return nil +} + +// ExecuteControlledPatch is the SWBOOT-10 stub: always rejects real patch/merge. +func (a *RepoAllowlist) ExecuteControlledPatch(absPath string, _ []string) error { + if err := a.CheckReadOnlyAccess(absPath); err != nil { + return err + } + return fmt.Errorf("%w: controlled patch/merge not implemented; docs_only/read_only_repo only", ErrPatchExecutionUnimplemented) +} + +// ApproveMerge is the SWBOOT-10 stub: approve-execution never implies merge. +func ApproveMerge(_ string) error { + return fmt.Errorf("%w: merge/push/deploy require a separate future approval path", ErrPatchExecutionUnimplemented) +}