diff --git a/.gitignore b/.gitignore index 8c82e4c..5d7978f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ /target .idea .claude +.ai-factory +.ai-factory.json Cargo.lock .DS_Store diff --git a/README.md b/README.md index a46dcf9..9984a58 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ $ hlv check | **Test specs** | `derived_from` refs, unique IDs, gate coverage | | **Traceability** | REQ → CTR → TST → GATE chains, no dangling refs | | **Plan** | DAG without cycles, contract coverage | -| **Code traceability** | `@hlv` markers in code match contract rules | +| **Code traceability** | `@hlv` markers in code match contract rules (skipped when `features.hlv_markers: false`) | | **LLM map** | every `map.yaml` entry exists on disk | | **Constraints** | rule IDs, severity validation | @@ -79,7 +79,7 @@ Phase-aware: checks expected at the current phase are automatically downgraded t | Command | What it does | |---------|-------------| -| `hlv init` | Scaffold the full HLV directory structure in seconds | +| `hlv init` | Scaffold the full HLV directory structure (asks for feature flags) | | `hlv check` | Run the full validation suite — specs, gates, deps, coverage | | `hlv milestone` | Track progress across milestones | | `hlv workflow` | See where you are and what the next step is | @@ -140,6 +140,18 @@ Each layer catches a different class of drift: > The compiler doesn't care that the LLM was pretty sure. Neither does hlv. +## Feature flags + +HLV ships with opinionated defaults, but you can opt out via `project.yaml`: + +```yaml +features: + linear_architecture: true # flat module structure, no layered arch + hlv_markers: true # @hlv code traceability markers + CTR-010/CTR-001 +``` + +Both default to `true`. Set to `false` to use your preferred architecture style or skip `@hlv` markers entirely. `hlv init` asks about these during project setup. + ## Your best practices are LLM anti-patterns | Developer pattern | LLM problem | HLV alternative | diff --git a/docs/STRUCTURE.md b/docs/STRUCTURE.md index fcdeea4..79c4cf9 100644 --- a/docs/STRUCTURE.md +++ b/docs/STRUCTURE.md @@ -4,7 +4,7 @@ Canonical structure created by `hlv init` + `hlv milestone new`. Source of truth for fixture validation and legacy detection. ``` -project.yaml # entry point - status, paths, stack, constraints +project.yaml # entry point - status, paths, stack, constraints, features milestones.yaml # current milestone, stages, history HLV.md # methodology rules (auto-generated) AGENTS.md # project-specific rules (user-editable) diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md index 416e4d9..215b8b2 100644 --- a/docs/WORKFLOW.md +++ b/docs/WORKFLOW.md @@ -33,10 +33,15 @@ hlv init -> hlv milestone new "feature" ```bash hlv init --project payments --owner backend-team -# init asks for the gate profile (minimal/standard/full) and the first milestone name +# init asks for: +# - gate profile (minimal/standard/full) +# - first milestone name +# - feature flags: linear architecture (Y/n), @hlv markers (Y/n) # --profile minimal|standard|full can be passed explicitly ``` +Feature flags (`features.linear_architecture`, `features.hlv_markers`) control whether HLV's opinionated code style and `@hlv` marker system are enforced. Both default to `true`. Set to `false` in `project.yaml` to opt out. + ### 2. Fill in the context ```bash diff --git a/schema/project-schema.json b/schema/project-schema.json index 0822ec2..67a48fd 100644 --- a/schema/project-schema.json +++ b/schema/project-schema.json @@ -63,6 +63,9 @@ }, "git": { "$ref": "#/$defs/GitPolicy" + }, + "features": { + "$ref": "#/$defs/Features" } }, "additionalProperties": false, @@ -223,6 +226,23 @@ }, "additionalProperties": false }, + "Features": { + "type": "object", + "description": "Optional feature flags controlling HLV's opinionated conventions", + "properties": { + "linear_architecture": { + "type": "boolean", + "default": true, + "description": "Enable linear architecture style in skills" + }, + "hlv_markers": { + "type": "boolean", + "default": true, + "description": "Enable @hlv code traceability markers and CTR-010/CTR-001 checks" + } + }, + "additionalProperties": false + }, "GitPolicy": { "type": "object", "required": ["commit_convention", "merge_strategy"], diff --git a/skills/implement/SKILL.md b/skills/implement/SKILL.md index bc90f43..0ff3f2f 100644 --- a/skills/implement/SKILL.md +++ b/skills/implement/SKILL.md @@ -12,8 +12,19 @@ metadata: Execute the implementation plan: agents perform tasks from milestone stage files in parallel, generating code and tests from contracts. +## Step 0: Read Configuration + +Before proceeding, read `project.yaml → features` and note the flag values: +- `features.linear_architecture` (default: `true`) +- `features.hlv_markers` (default: `true`) + +These flags control which sections below are active. If `project.yaml` has no `features` section, treat both as `true`. + ## CRITICAL: Code Architecture Philosophy +> **Conditional: `features.linear_architecture: true`** +> If `linear_architecture` is `false` in project.yaml, skip this entire section. Use your preferred architecture style instead. + > **The human DOES NOT read the generated code. The code is written FOR machines — LLM agents read it, LLM agents modify it, automated gates validate it.** This changes everything about how code is structured: @@ -136,13 +147,13 @@ Each agent when executing a task: - Test spec (`{MID}/test-specs/.md`) - Dependent code (output of previous tasks) 4. **Generate (linear, inline, TDD)**: - - **Code structure**: write linearly — input → validation → logic → output → errors. No layers (controller/service/repository). One file per logical unit. File names are arbitrary (e.g., `01.rs`, `create.rs`) — describe each file in `llm/map.yaml`. + - **Code structure** *(when `features.linear_architecture: true`)*: write linearly — input → validation → logic → output → errors. No layers (controller/service/repository). One file per logical unit. File names are arbitrary (e.g., `01.rs`, `create.rs`) — describe each file in `llm/map.yaml`. *(When `false`: use your preferred architecture style — layered, hexagonal, etc.)* - **Tests inline**: unit tests go in the same file as code (`#[cfg(test)] mod tests`). Separate `tests/` only for integration tests. - **`@ctx` comments**: add LLM navigation markers — `// @ctx: stock check for order.create`. Not human docs, but LLM orientation. - **Tests first**: write unit tests from contract test spec and property-based tests from invariants BEFORE implementation code. Tests must compile (with stubs/unimplemented markers) and clearly fail. - - **Then implement**: write implementation code to make the failing tests pass. No layered abstractions — write the simplest linear code. + - **Then implement**: write implementation code to make the failing tests pass. *(When `features.linear_architecture: true`)* No layered abstractions — write the simplest linear code. - **Then refine**: once tests are green, refactor if needed while keeping tests green. Duplication across features is OK — don't extract until it hurts. - - **`@hlv` markers (MANDATORY)**: every test MUST carry an `@hlv ` comment linking it to a contract validation or constraint. See "Code Traceability Markers" below. + - **`@hlv` markers** *(when `features.hlv_markers: true`, MANDATORY)*: every test MUST carry an `@hlv ` comment linking it to a contract validation or constraint. See "Code Traceability Markers" below. *(When `false`: skip `@hlv` markers entirely.)* 5. **Validate locally**: - `cargo check` / `npm run build` / equivalent - Unit tests pass @@ -293,6 +304,9 @@ milestones.yaml # updated stage status ## Code Traceability Markers (`@hlv`) +> **Conditional: `features.hlv_markers: true`** +> If `hlv_markers` is `false` in project.yaml, skip this entire section. No `@hlv` markers are required and `hlv check` will not run CTR-010/CTR-001 checks. + Every contract validation and constraint rule MUST be traceable to test code. `hlv check` enforces this automatically. ### What gets tracked diff --git a/skills/validate/SKILL.md b/skills/validate/SKILL.md index 2a255c4..f69f936 100644 --- a/skills/validate/SKILL.md +++ b/skills/validate/SKILL.md @@ -12,6 +12,13 @@ metadata: Execute all mandatory validation gates defined in `gates-policy.yaml`. Collect results, update project status, produce release decision. The gate set depends on the project profile — do NOT assume a fixed number of gates. +## Step 0: Read Configuration + +Before proceeding, read `project.yaml → features` and note the flag values: +- `features.hlv_markers` (default: `true`) + +This flag controls whether marker-related validation (Step 3b) is active. If `project.yaml` has no `features` section, treat as `true`. + ## Prerequisites - All tasks in current stage completed @@ -135,6 +142,9 @@ gate_results: ### Step 3b: Constraint rule coverage +> **Conditional: `features.hlv_markers: true`** +> If `hlv_markers` is `false` in project.yaml, skip the `@hlv` marker check below. `hlv check` will not produce CTR-010 diagnostics. Still run `check_command`-based rules (CST-050/CST-060) as those are independent of markers. + Check that every rule in rule-based constraint files (`human/constraints/*.yaml`) has a corresponding `@hlv ` marker in `llm/src/` or `tests/`. Rules with `check_command` are exempt — they are verified programmatically. Run `hlv check` and review CTR-010 diagnostics for missing constraint markers. `hlv check` also executes `check_command` for rules that define one (CST-050/CST-060). Review diagnostics: rules with `error_level: error` (or `critical`/`high` severity without an override) block release. Add failing checks to the remediation plan (Step 4a). diff --git a/src/check/code_trace.rs b/src/check/code_trace.rs index d09349f..decdbdc 100644 --- a/src/check/code_trace.rs +++ b/src/check/code_trace.rs @@ -21,7 +21,13 @@ pub fn check_code_trace( constraints: &[ConstraintEntry], src_path: &str, tests_path: Option<&str>, + markers_enabled: bool, ) -> Vec { + if !markers_enabled { + tracing::debug!("Skipping code trace check — hlv_markers disabled"); + return Vec::new(); + } + let mut diags = Vec::new(); // 1. Collect expected markers from contracts @@ -204,13 +210,35 @@ def test_sql(): assert!(markers.contains("prepared_statements_only")); } + #[test] + fn check_code_trace_disabled_returns_empty() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + fs::create_dir_all(root.join("llm/src")).unwrap(); + + // Even with contracts that would produce diagnostics, disabled markers = empty + let contracts = vec![ContractEntry { + id: "test.contract".to_string(), + version: "1.0.0".to_string(), + path: "contracts/test.md".to_string(), + yaml_path: Some("contracts/test.yaml".to_string()), + owner: None, + status: crate::model::project::ContractStatus::Implemented, + test_spec: None, + depends_on: vec![], + artifacts: vec![], + }]; + let diags = check_code_trace(root, &contracts, &[], "llm/src", None, false); + assert!(diags.is_empty(), "markers disabled = no diagnostics"); + } + #[test] fn check_code_trace_empty_contracts() { let dir = tempfile::tempdir().unwrap(); let root = dir.path(); fs::create_dir_all(root.join("llm/src")).unwrap(); - let diags = check_code_trace(root, &[], &[], "llm/src", None); + let diags = check_code_trace(root, &[], &[], "llm/src", None, true); assert!(diags.is_empty(), "no contracts = no expected markers"); } @@ -267,7 +295,7 @@ fn test_atomicity() {} artifacts: vec![], }]; - let diags = check_code_trace(root, &contracts, &[], "llm/src", None); + let diags = check_code_trace(root, &contracts, &[], "llm/src", None, true); // Should have CTR-001 summary but no CTR-010 warnings assert!( @@ -319,7 +347,7 @@ outputs_schema: artifacts: vec![], }]; - let diags = check_code_trace(root, &contracts, &[], "llm/src", None); + let diags = check_code_trace(root, &contracts, &[], "llm/src", None, true); let warnings: Vec<_> = diags.iter().filter(|d| d.code == "CTR-010").collect(); assert_eq!(warnings.len(), 2, "2 missing markers: {:?}", warnings); assert!(diags.iter().any(|d| d.code == "CTR-001")); @@ -361,7 +389,7 @@ rules: applies_to: Some("all".to_string()), }]; - let diags = check_code_trace(root, &[], &constraints, "llm/src", None); + let diags = check_code_trace(root, &[], &constraints, "llm/src", None, true); // One marker found, one missing let warnings: Vec<_> = diags.iter().filter(|d| d.code == "CTR-010").collect(); assert_eq!( @@ -416,7 +444,7 @@ output: artifacts: vec![], }]; - let diags = check_code_trace(root, &contracts, &[], "llm/src", Some("llm/tests")); + let diags = check_code_trace(root, &contracts, &[], "llm/src", Some("llm/tests"), true); assert!( !diags.iter().any(|d| d.code == "CTR-010"), "marker found in tests dir should count: {:?}", @@ -443,7 +471,7 @@ output: artifacts: vec![], }]; - let diags = check_code_trace(root, &contracts, &[], "llm/src", None); + let diags = check_code_trace(root, &contracts, &[], "llm/src", None, true); assert!(diags.is_empty(), "no yaml = no expected markers"); } } diff --git a/src/cmd/check.rs b/src/cmd/check.rs index 7f588fd..8317e67 100644 --- a/src/cmd/check.rs +++ b/src/cmd/check.rs @@ -129,6 +129,7 @@ pub fn get_check_diagnostics(root: &Path) -> Result<(Vec, i32)> { &project.constraints, &project.paths.llm.src, tests_path, + project.features.hlv_markers, )); } @@ -330,6 +331,7 @@ fn run_checks(root: &Path) -> Result { &project.constraints, &project.paths.llm.src, tests_path, + project.features.hlv_markers, ); print_diags(&code_diags); all_diags.extend(code_diags); diff --git a/src/cmd/init.rs b/src/cmd/init.rs index eba3bb1..422abd4 100644 --- a/src/cmd/init.rs +++ b/src/cmd/init.rs @@ -207,6 +207,15 @@ pub fn run_with_milestone( None => prompt_with_default("First milestone name", "init")?, }; + let linear_arch = prompt_yes_no("Enable linear architecture style?", true)?; + let hlv_markers = prompt_yes_no("Enable @hlv code traceability markers?", true)?; + + tracing::debug!( + linear_architecture = linear_arch, + hlv_markers = hlv_markers, + "Feature flags selected" + ); + let agent_dir = format!(".{agent_name}"); let skills_dir = format!("{agent_dir}/skills"); @@ -287,7 +296,7 @@ pub fn run_with_milestone( write_template( root, "project.yaml", - &project_template(&project_name, &owner_name), + &project_template(&project_name, &owner_name, linear_arch, hlv_markers), )?; write_template(root, "milestones.yaml", &milestones_template(&project_name))?; write_template( @@ -351,6 +360,28 @@ fn prompt_with_default(label: &str, default: &str) -> Result { } } +/// Prompt user for a yes/no answer with a default. +fn prompt_yes_no(label: &str, default: bool) -> Result { + let hint = if default { "Y/n" } else { "y/N" }; + print!(" {} {} [{}]: ", "?".cyan().bold(), label, hint); + io::stdout().flush()?; + let mut line = String::new(); + io::stdin() + .lock() + .read_line(&mut line) + .context("failed to read input")?; + let value = line.trim().to_lowercase(); + if value.is_empty() { + Ok(default) + } else { + match value.as_str() { + "y" | "yes" => Ok(true), + "n" | "no" => Ok(false), + _ => anyhow::bail!("Expected y/n, got '{}'", line.trim()), + } + } +} + /// Detect agent name from existing `.{agent}/skills/` directory. fn detect_agent(root: &Path) -> Result { for entry in fs::read_dir(root)? { @@ -635,7 +666,12 @@ exceptions: ) } -fn project_template(project: &str, _owner: &str) -> String { +fn project_template( + project: &str, + _owner: &str, + linear_architecture: bool, + hlv_markers: bool, +) -> String { format!( r#"# yaml-language-server: $schema=schema/project-schema.json # HLV Project Map @@ -670,6 +706,10 @@ constraints: path: human/constraints/observability.yaml applies_to: all +features: + linear_architecture: {linear_architecture} + hlv_markers: {hlv_markers} + git: branch_per_milestone: false commit_convention: conventional diff --git a/src/model/project.rs b/src/model/project.rs index 76918c8..372d7ba 100644 --- a/src/model/project.rs +++ b/src/model/project.rs @@ -27,6 +27,28 @@ pub struct ProjectMap { pub stack: Option, #[serde(default)] pub git: GitPolicy, + #[serde(default)] + pub features: Features, +} + +// ── Features ──────────────────────────────────────────── + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(deny_unknown_fields)] +pub struct Features { + #[serde(default = "default_true")] + pub linear_architecture: bool, + #[serde(default = "default_true")] + pub hlv_markers: bool, +} + +impl Default for Features { + fn default() -> Self { + Self { + linear_architecture: true, + hlv_markers: true, + } + } } // ── Git Policy ────────────────────────────────────────── @@ -513,6 +535,7 @@ type: some_new_type validation: None, stack: None, git: GitPolicy::default(), + features: Features::default(), }; pm.save(&path).unwrap(); @@ -556,6 +579,7 @@ type: some_new_type validation: None, stack: None, git: GitPolicy::default(), + features: Features::default(), }; pm.add_constraint(ConstraintEntry { @@ -642,4 +666,95 @@ custom_field: hello assert!(comp.extra.contains_key("engine")); assert!(comp.extra.contains_key("custom_field")); } + + #[test] + fn features_default_both_true() { + let f = Features::default(); + assert!(f.linear_architecture); + assert!(f.hlv_markers); + } + + #[test] + fn features_deserialize_explicit_false() { + let yaml = "linear_architecture: false\nhlv_markers: false\n"; + let f: Features = serde_yaml::from_str(yaml).unwrap(); + assert!(!f.linear_architecture); + assert!(!f.hlv_markers); + } + + #[test] + fn features_deserialize_empty_defaults_true() { + let yaml = "{}\n"; + let f: Features = serde_yaml::from_str(yaml).unwrap(); + assert!(f.linear_architecture); + assert!(f.hlv_markers); + } + + #[test] + fn features_deserialize_partial() { + let yaml = "hlv_markers: false\n"; + let f: Features = serde_yaml::from_str(yaml).unwrap(); + assert!(f.linear_architecture); // default true + assert!(!f.hlv_markers); + } + + #[test] + fn project_map_without_features_defaults() { + // A project.yaml without features section should deserialize with defaults + let path = std::path::Path::new("tests/fixtures/example-project/project.yaml"); + let pm = ProjectMap::load(path).unwrap(); + // Fixture may or may not have features; either way it should load + // and features should have sensible values + let _ = pm.features.linear_architecture; + let _ = pm.features.hlv_markers; + } + + #[test] + fn project_map_roundtrip_with_features() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("project.yaml"); + + let mut pm = ProjectMap { + schema_version: 1, + project: "test-features".to_string(), + spec: None, + updated_at: None, + status: ProjectStatus::Draft, + last_skill: None, + last_skill_result: None, + paths: ProjectPaths { + human: HumanPaths { + glossary: "g.yaml".to_string(), + constraints: "c/".to_string(), + artifacts: None, + }, + validation: ValidationPaths { + gates_policy: "v/g.yaml".to_string(), + scenarios: "v/s/".to_string(), + test_specs: None, + traceability: None, + verify_report: None, + }, + llm: LlmPaths { + src: "llm/".to_string(), + tests: None, + map: None, + }, + }, + glossary_types: vec![], + constraints: vec![], + validation: None, + stack: None, + git: GitPolicy::default(), + features: Features { + linear_architecture: true, + hlv_markers: false, + }, + }; + + pm.save(&path).unwrap(); + let loaded = ProjectMap::load(&path).unwrap(); + assert!(loaded.features.linear_architecture); + assert!(!loaded.features.hlv_markers); + } } diff --git a/tests/check_tests.rs b/tests/check_tests.rs index 0ca7654..d2082fa 100644 --- a/tests/check_tests.rs +++ b/tests/check_tests.rs @@ -2844,7 +2844,7 @@ outputs_schema: "contracts/order.md", "contracts/order.yaml", )]; - let diags = check_code_trace(root, &contracts, &[], "llm/src", None); + let diags = check_code_trace(root, &contracts, &[], "llm/src", None, true); assert!( !diags.iter().any(|d| d.code == "CTR-010"), "all markers present: {:?}", @@ -2882,7 +2882,7 @@ outputs_schema: "contracts/order.md", "contracts/order.yaml", )]; - let diags = check_code_trace(root, &contracts, &[], "llm/src", None); + let diags = check_code_trace(root, &contracts, &[], "llm/src", None, true); assert!( diags .iter() @@ -2918,7 +2918,7 @@ rules: path: "human/constraints/security.yaml".to_string(), applies_to: Some("all".to_string()), }]; - let diags = check_code_trace(root, &[], &constraints, "llm/src", None); + let diags = check_code_trace(root, &[], &constraints, "llm/src", None, true); assert!( !diags.iter().any(|d| d.code == "CTR-010"), "constraint marker found: {:?}", @@ -3773,6 +3773,7 @@ fn minimal_project_with_constraints( validation: None, stack: None, git: Default::default(), + features: Default::default(), } } diff --git a/tests/fixtures/example-project/project.yaml b/tests/fixtures/example-project/project.yaml index 7475ab4..2d68c86 100644 --- a/tests/fixtures/example-project/project.yaml +++ b/tests/fixtures/example-project/project.yaml @@ -95,6 +95,10 @@ git: - chore merge_strategy: manual +features: + linear_architecture: true + hlv_markers: true + # --- Validation State --- validation: diff --git a/tests/fixtures/milestone-project/project.yaml b/tests/fixtures/milestone-project/project.yaml index f5fe743..c37bd25 100644 --- a/tests/fixtures/milestone-project/project.yaml +++ b/tests/fixtures/milestone-project/project.yaml @@ -36,3 +36,7 @@ git: - feat - fix merge_strategy: manual + +features: + linear_architecture: true + hlv_markers: true