diff --git a/.gitignore b/.gitignore index e4840f8..0960260 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,4 @@ hashnode/ !.fuseraft/context/ !.fuseraft/context/** temp/TestMetadata/ +CHECKLIST.md diff --git a/docs/cli-reference.md b/docs/cli-reference.md index d9e075b..38011b6 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1072,6 +1072,266 @@ Validate: fuseraft validate .fuseraft/config/orchestration.yaml Run: fuseraft run --config .fuseraft/config/orchestration.yaml "Your task" ``` +`init` also scaffolds the knowledge directory tree and writes default config files the first time it is run in a directory: + +| File created | Purpose | +|---|---| +| `.fuseraft/architecture.yaml` | Architecture layer manifest for `fuseraft arch check` | +| `.fuseraft/knowledge/lifecycle.yaml` | Retention policy for `fuseraft knowledge gc` | +| `.fuseraft/knowledge/decisions/` | Architecture decision records (ADRs) | +| `.fuseraft/knowledge/repository/` | Cross-session repository memory patterns | +| `.fuseraft/knowledge/objectives/` | Long-horizon objective tracking | + +These files are skipped if they already exist. + +--- + +## `fuseraft graph` + +Repository semantic graph — index and query symbols across the codebase. + +### `fuseraft graph build` + +Scan all `.cs` source files under the project root and write (or overwrite) the repository semantic graph to `.fuseraft/state/repository.graph`. The graph records every file, namespace, type, interface, method, property, field, and ADR as a node; edges express structural relationships (`defines`, `imports`, `inherits`, `implements`, `references`, `adr_governs`). + +Agents use the graph via the `graph_search`, `graph_refs`, and `graph_dependents` plugin tools. The graph is also updated incrementally by the harness whenever an agent writes a `.cs` file. + +``` +fuseraft graph build [options] +``` + +**Options** + +| Flag | Default | Description | +|------|---------|-------------| +| `-d, --dir ` | current directory | Root directory to scan. | +| `-o, --output ` | `.fuseraft/state/repository.graph` | Output path for the graph file. | + +**Examples** + +```bash +# Build the graph for the current project +fuseraft graph build + +# Scan only a subdirectory +fuseraft graph build --dir src/ + +# Write to a custom location +fuseraft graph build --output /tmp/my-project.graph +``` + +--- + +## `fuseraft arch` + +Architecture drift detection — check that source files respect the layer boundaries defined in `.fuseraft/architecture.yaml`. + +### `fuseraft arch check` + +Parse `using` directives in all `.cs` files under the project root and compare them against the layer manifest. Exits `0` when no violations are found, `1` when at least one violation is detected. + +`fuseraft init` writes a default `.fuseraft/architecture.yaml` on first run. Edit its `Layers` and `MayDependOn` lists to match your project's actual layer structure. + +``` +fuseraft arch check [options] +``` + +**Options** + +| Flag | Default | Description | +|------|---------|-------------| +| `-m, --manifest ` | `.fuseraft/architecture.yaml` | Path to the architecture manifest. | +| `-d, --dir ` | current directory | Root directory to scan. | + +**Examples** + +```bash +# Check against the default manifest +fuseraft arch check + +# Use a custom manifest +fuseraft arch check --manifest config/arch.yaml + +# Scan only the src/ subtree +fuseraft arch check --dir src/ +``` + +**Output** + +When violations are found the command prints a table: + +``` +File Line Source Layer Target Layer Namespace +src/Cli/FooCommand.cs 12 Cli Core fuseraft.Infrastructure.Bar +``` + +Each row identifies the offending file, the line number of the illegal `using` directive, the layer that owns the source file, the layer that owns the imported namespace, and the namespace itself. + +--- + +## `fuseraft knowledge` + +Knowledge lifecycle management — archive superseded ADRs, demote stale repository memories, decay old provenance claims, prune orphaned graph nodes, and compact the provenance registry. + +### `fuseraft knowledge gc` + +Run all lifecycle policies configured in `.fuseraft/knowledge/lifecycle.yaml`. **Dry-run by default** — pass `--apply` to commit changes to disk. + +``` +fuseraft knowledge gc [options] +``` + +**Options** + +| Flag | Default | Description | +|------|---------|-------------| +| `--apply` | off | Commit lifecycle changes to disk. Without this flag the command reports what would change without touching any files. | +| `-l, --lifecycle ` | `.fuseraft/knowledge/lifecycle.yaml` | Path to the lifecycle policy file. | +| `--graph ` | `.fuseraft/state/repository.graph` | Override the repository graph path. | + +**Policy fields** (in `lifecycle.yaml`) + +| Field | Default | Effect | +|-------|---------|--------| +| `AdrRetentionDays` | `0` | Days after a decision reaches `Superseded` status before it is archived. `0` = archive immediately on the next gc run. | +| `MemoryReinforceWindowDays` | `90` | Demote `Approved` repository memories to `Candidate` when they have not been reinforced for this many days. | +| `ConfidenceDecayDays` | `30` | Downgrade `Verified` provenance claims to `Inferred` when their `VerifiedAt` is older than this many days and no `ExpiresAt` is set. `0` = disable decay. | +| `OrphanedNodeGracePeriodDays` | `7` | Prune graph nodes with no edges and no recent file touch after this many days. `0` = disable. | +| `MaxProvenanceAgeDays` | `0` | Archive provenance records past `ExpiresAt` after this many additional days. `0` = archive immediately. | + +**Examples** + +```bash +# Preview what would be archived/demoted/decayed (dry-run) +fuseraft knowledge gc + +# Apply all lifecycle policies +fuseraft knowledge gc --apply + +# Use a custom lifecycle config +fuseraft knowledge gc --apply --lifecycle custom/lifecycle.yaml +``` + +Archived ADRs are moved to `.fuseraft/knowledge/decisions/archive/` and remain queryable via `decision_search`. Archived provenance records are appended to `.fuseraft/state/provenance.archive.json`. + +--- + +## `fuseraft memory` + +Repository memory — cross-session patterns extracted from the evidence graph after each session closes. Candidates must be approved before they are injected into agent prompts. + +### `fuseraft memory review` + +Interactively review candidate repository memories and approve or reject them. Approved memories are injected into the system prompt of every subsequent agent session; rejected memories are suppressed. + +``` +fuseraft memory review [options] +``` + +**Options** + +| Flag | Default | Description | +|------|---------|-------------| +| `--dir ` | `.fuseraft/knowledge/repository` | Repository memory directory. | +| `--all` | off | Show all entries including `Approved` and `Rejected`, not just `Candidate` entries. | + +**Examples** + +```bash +# Review pending candidates (interactive) +fuseraft memory review + +# Browse all entries including already-decided ones +fuseraft memory review --all +``` + +For each candidate you are prompted to **Approve**, **Reject**, or **Skip**. The decision is written to disk immediately; the command can be interrupted and re-run. + +--- + +## `fuseraft objective` + +Long-horizon objective tracking — create and monitor objectives that span multiple sessions. + +Active objectives are summarised in the system prompt of every agent session and in compaction summaries so the team never loses sight of the big picture. + +### `fuseraft objective create` + +Create a new long-horizon objective. + +``` +fuseraft objective create [options] +``` + +**Options** + +| Flag | Default | Description | +|------|---------|-------------| +| `-t, --title ` | interactive | Short title for the objective. | +| `-d, --description ` | — | What the objective achieves and why it matters. | +| `--tasks ` | — | Comma-separated initial remaining tasks. | + +**Examples** + +```bash +# Interactive (prompts for title) +fuseraft objective create + +# Non-interactive +fuseraft objective create --title "Ship auth refactor" --description "Replace session tokens with JWTs" --tasks "Design,Implement,Test,Deploy" +``` + +--- + +### `fuseraft objective list` + +List all objectives, optionally filtered by status. + +``` +fuseraft objective list [options] +``` + +**Options** + +| Flag | Default | Description | +|------|---------|-------------| +| `-s, --status ` | — | Filter: `Active`, `Paused`, `Completed`, `Abandoned`. | +| `-a, --all` | off | Show all objectives regardless of status. | + +**Examples** + +```bash +# Show all objectives +fuseraft objective list + +# Show only active objectives +fuseraft objective list --status Active +``` + +--- + +### `fuseraft objective status` + +Show detailed status and progress for a single objective. + +``` +fuseraft objective status +``` + +**Arguments** + +| Argument | Description | +|----------|-------------| +| `` | Objective ID (e.g. `OBJ-0001`). | + +**Examples** + +```bash +fuseraft objective status OBJ-0001 +``` + +Output includes the title, description, status, computed completion percentage, completed and remaining task lists, and all session IDs that contributed work. + --- ## `fuseraft context` diff --git a/docs/index.md b/docs/index.md index f24df4b..1a97e17 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,6 +17,7 @@ fuseraft-cli is actively maintained and in production use. New features ship reg - Auto-curates reusable skills from completed sessions and injects relevant ones at session start via a SQLite FTS5 index - Schedules recurring sessions via cron expressions (`fuseraft schedule add/list/run`) - Rotates API keys automatically on 429 rate-limit responses when a key pool is configured +- Accumulates durable cross-session knowledge: architecture decisions, repository graph, provenance claims, repository memory patterns, and long-horizon objectives — all queryable by agents via the adaptive context broker ## Guides @@ -39,6 +40,7 @@ fuseraft-cli is actively maintained and in production use. New features ship reg | [Context Management](context-management.md) | How fuseraft manages context across a long session | | [Context Store](context-store.md) | Importing reference material for agents | | [Skills](skills.md) | Portable skill packages, skill curation, and the cross-session skill index | +| [Knowledge Layer](knowledge.md) | ADR registry, repository graph, provenance tracking, repository memory, objectives, context broker, and lifecycle GC | | [Examples](examples.md) | Ready-to-use config examples | ## VS Code Extension diff --git a/docs/knowledge.md b/docs/knowledge.md new file mode 100644 index 0000000..7dcba3e --- /dev/null +++ b/docs/knowledge.md @@ -0,0 +1,235 @@ +# Knowledge Layer + +The knowledge layer is a set of persistent, cross-session subsystems that let agents accumulate and query durable knowledge about a codebase — architectural decisions, structural symbols, verified claims, recurring patterns, and long-horizon objectives. All subsystems share a single `IKnowledgeLayer` interface and are wired together through the `ContextBroker` at session start. + +## Overview + +``` +Session input (task/brief) + │ + ▼ + IntentAnalyzer → extract keywords, symbols, failure patterns + │ + ▼ + KnowledgeRetriever → query ADR registry, repository graph, repository memory + │ + ▼ + ContextBudgeter → rank by confidence tier, trim to token budget + │ + ▼ + ContextBroker → assemble formatted context block → agent system prompt +``` + +Agents interact with the knowledge layer through plugin tools (`decision_*`, `graph_*`, `objective_*`). Validators write provenance claims after successful checks. The lifecycle manager (`fuseraft knowledge gc`) periodically archives stale artifacts. + +--- + +## Subsystems + +### Architecture Decision Registry (ADR) + +Stores and indexes architecture decision records (ADRs) as JSON files under `.fuseraft/knowledge/decisions/`. Each ADR records the context, decision text, alternatives, consequences, and the symbols or files it governs. + +Agents use the `decision_search`, `decision_read`, `decision_create`, and `decision_supersede` plugin tools to interact with ADRs. ADRs are automatically linked into the repository semantic graph via `adr_governs` edges when their `Governs` list is populated. + +**Lifecycle:** Superseded ADRs are archived to `.fuseraft/knowledge/decisions/archive/` by `fuseraft knowledge gc`. They remain queryable via `decision_search` but are excluded from default injection. + +--- + +### Repository Semantic Graph + +A structural index of every file, namespace, type, interface, method, property, and field in the project, plus ADR nodes linked via `adr_governs` edges. Persisted as a single JSON file at `.fuseraft/state/repository.graph`. + +Build the graph with: + +```bash +fuseraft graph build +``` + +The harness rebuilds affected nodes incrementally after every `FileWrite` tool call. Agents query the graph via `graph_search` (find nodes by name/type), `graph_refs` (what references this symbol), and `graph_dependents` (transitive dependents). + +**SymbolId scheme** — node identities are stable, fully-qualified strings: + +| Prefix | Example | +|--------|---------| +| `file:` | `file:src/Core/Models/AdrEntry.cs` | +| `namespace:` | `namespace:fuseraft.Core.Models` | +| `type:` | `type:fuseraft.Core.Models.AdrEntry` | +| `interface:` | `interface:fuseraft.Core.IKnowledgeLayer` | +| `method:` | `method:fuseraft.Core.Models.AdrEntry.SomeMethod` | +| `property:` | `property:fuseraft.Core.Models.AdrEntry.Title` | +| `adr:` | `adr:ADR-0042` | + +**Edge types:** `defines`, `imports`, `inherits`, `implements`, `references`, `depends_on`, `adr_governs`. + +--- + +### Provenance and Confidence Tracking + +Every verifiable claim made during a session can be recorded with supporting evidence in the provenance registry (`.fuseraft/state/provenance.json`). Validators emit `ClaimRecord` entries when they pass; downstream agents and the Context Broker use the registry to determine whether evidence supports a given artifact. + +**Confidence tiers** are computed mechanically from the evidence composition — never from API response text: + +| Tier | Evidence required | +|------|-------------------| +| `Verified` | Two or more of: `TestResult`, `ExitCode`, `Validator`, `GitHistory` | +| `Inferred` | One hard evidence source, or `ADR`/`RepositoryMemory` backing | +| `Assumed` | `AgentAssertion` only, no corroborating hard evidence | +| `Guessed` | No support at all | + +Claims carry an optional `ExpiresAt` timestamp set by the caller based on the volatility of the claim. Claims past their `ExpiresAt` are excluded from broker output and archived by `fuseraft knowledge gc`. + +--- + +### Repository Memory + +Cross-session patterns extracted from the evidence graph and change log after each session closes. Entries start as `Candidate` and are never injected into agent prompts until a human approves them via `fuseraft memory review` or an automated reviewer agent promotes them. + +Once approved, repository memories are prepended to every agent session's system prompt. When the same pattern recurs across sessions, its `ReinforcementCount` is incremented and its confidence tier is recomputed. + +```bash +# Review pending candidates +fuseraft memory review + +# Browse all entries +fuseraft memory review --all +``` + +**Lifecycle:** Approved memories not reinforced within the `MemoryReinforceWindowDays` window (default 90 days) are demoted back to `Candidate` by `fuseraft knowledge gc`. + +--- + +### Architecture Drift Detection + +Compares `using` directives in every `.cs` source file against the layer manifest in `.fuseraft/architecture.yaml` and reports violations. A violation is a source file in one layer importing a namespace owned by a layer it is not permitted to depend on. + +`fuseraft init` writes a default `architecture.yaml` on first run. Edit its `Layers` and `MayDependOn` lists to match your project structure. + +```yaml +# .fuseraft/architecture.yaml +Layers: + - Name: Core + Paths: [src/Core/] + MayDependOn: [] + + - Name: Infrastructure + Paths: [src/Infrastructure/] + MayDependOn: [Core] + + - Name: Orchestration + Paths: [src/Orchestration/] + MayDependOn: [Core, Infrastructure] + + - Name: Cli + Paths: [src/Cli/] + MayDependOn: [Core, Infrastructure, Orchestration] +``` + +```bash +fuseraft arch check # exits 0 if clean, 1 if violations found +``` + +Violations are also emitted as `Violation` nodes in the evidence graph so they carry provenance and are queryable. + +--- + +### Dependency Planner + +When agents declare `Produces` and `Requires` tokens in their `AgentConfig`, the `DependencyPlanner` builds an execution DAG, detects cycles (reported as config errors at startup), and schedules agents in parallel whenever their dependencies are already fulfilled. This is activated automatically when any agent in the config declares `Produces` or `Requires`. + +```yaml +Produces: + - artifact:session-persistence + - file:src/SessionManager.cs +Requires: + - symbol:ISessionStore + - artifact:repository-graph +``` + +--- + +### Objective Tracking + +Long-horizon objectives span multiple sessions. Active objectives are summarised in every agent system prompt and in compaction summaries so the team always has the big picture in view. + +```bash +fuseraft objective create --title "Ship auth refactor" --tasks "Design,Implement,Test" +fuseraft objective list +fuseraft objective status OBJ-0001 +``` + +Progress is computed on demand from `CompletedTasks.Count / (CompletedTasks.Count + RemainingTasks.Count)`. The `objective_link_task` plugin tool lets agents update task status within a session. + +--- + +### Adaptive Context Broker + +The broker ties all subsystems together. When an agent config declares a `broker:*` context source, the broker runs before each turn: + +1. **IntentAnalyzer** — extracts keywords, PascalCase symbols, and failure patterns from the task description. +2. **KnowledgeRetriever** — queries the ADR registry, repository graph, and approved repository memories for each signal. +3. **ContextBudgeter** — ranks results by confidence tier (`Verified` > `Inferred` > `Assumed` > `Guessed`), excludes expired claims, and trims to the configured token budget. +4. **Prompt assembly** — formats the surviving items into a labelled context block injected into the agent system prompt. + +When the broker produces no results it falls back gracefully to static context assembly. + +--- + +### Knowledge Lifecycle Management + +Without periodic maintenance, every knowledge subsystem accumulates stale data. The lifecycle manager runs all retention policies in one command: + +```bash +fuseraft knowledge gc # dry-run: shows what would change +fuseraft knowledge gc --apply # applies all policies +``` + +| Policy | What it does | +|--------|-------------| +| Archive superseded ADRs | Moves `Superseded` ADRs to `.fuseraft/knowledge/decisions/archive/` | +| Demote aged memories | Demotes `Approved` memories not reinforced within the window back to `Candidate` | +| Decay provenance confidence | Downgrades `Verified` claims older than `ConfidenceDecayDays` to `Inferred` | +| Prune orphaned graph nodes | Removes nodes with no edges and no recent file touch | +| Compact provenance registry | Archives expired `ClaimRecord` entries to `.fuseraft/state/provenance.archive.json` | + +Configure retention windows in `.fuseraft/knowledge/lifecycle.yaml` (created by `fuseraft init`). + +--- + +## Directory Layout + +``` +.fuseraft/ +├── architecture.yaml ← layer manifest (user-authored) +├── knowledge/ +│ ├── lifecycle.yaml ← lifecycle policy +│ ├── decisions/ +│ │ ├── ADR-0001.json ← architecture decision records +│ │ └── archive/ ← superseded ADRs (still queryable) +│ ├── repository/ +│ │ ├── .json ← repository memory entries +│ │ └── MEMORY.md ← human-readable index +│ └── objectives/ +│ └── OBJ-0001.yaml ← long-horizon objectives +└── state/ + ├── repository.graph ← repository semantic graph + ├── provenance.json ← active claim records + └── provenance.archive.json ← archived (expired) claim records +``` + +## Agent Plugin Tools + +| Tool | Plugin | Description | +|------|--------|-------------| +| `decision_search` | Decision | Search ADRs by keyword, tag, or status | +| `decision_read` | Decision | Read a specific ADR by ID | +| `decision_create` | Decision | Create a new ADR (requires write capability) | +| `decision_supersede` | Decision | Mark an ADR as superseded by a newer one | +| `graph_search` | Graph | Find graph nodes by name or type | +| `graph_refs` | Graph | What symbols reference a given node | +| `graph_dependents` | Graph | Transitive dependents of a node | +| `objective_create` | Objective | Create a new objective | +| `objective_read` | Objective | Read an objective by ID | +| `objective_update` | Objective | Update objective status or task lists | +| `objective_list` | Objective | List objectives | +| `objective_link_task` | Objective | Mark a task complete or add a remaining task | diff --git a/src/Cli/Commands/Arch/ArchCheckCommand.cs b/src/Cli/Commands/Arch/ArchCheckCommand.cs new file mode 100644 index 0000000..3820319 --- /dev/null +++ b/src/Cli/Commands/Arch/ArchCheckCommand.cs @@ -0,0 +1,78 @@ +using System.ComponentModel; +using Spectre.Console; +using Spectre.Console.Cli; +using fuseraft.Core; +using fuseraft.Infrastructure; + +namespace fuseraft.Cli.Commands.Arch; + +// fuseraft arch check + +public sealed class ArchCheckSettings : CommandSettings +{ + [CommandOption("--manifest|-m ")] + [Description("Path to the architecture manifest. Defaults to .fuseraft/architecture.yaml.")] + public string? ManifestPath { get; init; } + + [CommandOption("--dir|-d ")] + [Description("Root directory to scan. Defaults to the current working directory.")] + public string? Directory { get; init; } +} + +public sealed class ArchCheckCommand : AsyncCommand +{ + protected override async Task ExecuteAsync( + CommandContext context, + ArchCheckSettings settings, + CancellationToken cancellationToken) + { + var manifestPath = settings.ManifestPath ?? FuseraftPaths.LocalArchitectureManifest; + var projectRoot = settings.Directory is not null + ? Path.GetFullPath(settings.Directory) + : System.IO.Directory.GetCurrentDirectory(); + + var manifest = ArchitectureScanner.TryLoadManifest(manifestPath); + if (manifest is null) + { + AnsiConsole.MarkupLine($"[yellow]No manifest found at[/] [dim]{Markup.Escape(manifestPath)}[/]"); + AnsiConsole.MarkupLine("[grey]Create .fuseraft/architecture.yaml to enable drift detection.[/]"); + return 0; + } + + AnsiConsole.MarkupLine($"[bold]Architecture check[/] manifest: [dim]{Markup.Escape(manifestPath)}[/]"); + AnsiConsole.MarkupLine($" Root: [dim]{Markup.Escape(projectRoot)}[/]"); + AnsiConsole.WriteLine(); + + var violations = await ArchitectureScanner.ScanAsync(manifest, projectRoot, cancellationToken); + + if (violations.Count == 0) + { + AnsiConsole.MarkupLine("[green]No violations found.[/]"); + return 0; + } + + AnsiConsole.MarkupLine($"[red bold]{violations.Count} violation(s) found:[/]"); + AnsiConsole.WriteLine(); + + var table = new Table() + .Border(TableBorder.Rounded) + .AddColumn("[bold]File[/]") + .AddColumn("[bold]Line[/]") + .AddColumn("[bold]Source Layer[/]") + .AddColumn("[bold]Target Layer[/]") + .AddColumn("[bold]Namespace[/]"); + + foreach (var v in violations) + { + table.AddRow( + Markup.Escape(v.File), + v.Line.ToString(), + $"[yellow]{Markup.Escape(v.SourceLayer)}[/]", + $"[red]{Markup.Escape(v.TargetLayer)}[/]", + Markup.Escape(v.Namespace)); + } + + AnsiConsole.Write(table); + return 1; + } +} diff --git a/src/Cli/Commands/Graph/GraphBuildCommand.cs b/src/Cli/Commands/Graph/GraphBuildCommand.cs new file mode 100644 index 0000000..655d734 --- /dev/null +++ b/src/Cli/Commands/Graph/GraphBuildCommand.cs @@ -0,0 +1,53 @@ +using System.ComponentModel; +using Spectre.Console; +using Spectre.Console.Cli; +using fuseraft.Core; +using fuseraft.Infrastructure; + +namespace fuseraft.Cli.Commands.Graph; + +// fuseraft graph build + +public sealed class GraphBuildSettings : CommandSettings +{ + [CommandOption("--dir|-d ")] + [Description("Root directory to scan. Defaults to the current working directory.")] + public string? Directory { get; init; } + + [CommandOption("--output|-o ")] + [Description("Output path for the graph file. Defaults to .fuseraft/state/repository.graph.")] + public string? OutputPath { get; init; } +} + +public sealed class GraphBuildCommand : AsyncCommand +{ + protected override async Task ExecuteAsync( + CommandContext context, + GraphBuildSettings settings, + CancellationToken cancellationToken) + { + var root = settings.Directory is not null + ? Path.GetFullPath(settings.Directory) + : Directory.GetCurrentDirectory(); + + var outputPath = settings.OutputPath ?? FuseraftPaths.LocalRepositoryGraph; + var store = new RepositoryGraphStore(outputPath); + var builder = new RepositoryGraphBuilder(store, root); + + AnsiConsole.MarkupLine($"[bold]Building repository graph[/] from [dim]{Markup.Escape(root)}[/]"); + AnsiConsole.MarkupLine($" Output: [dim]{Markup.Escape(outputPath)}[/]"); + AnsiConsole.WriteLine(); + + (int nodes, int edges) = (0, 0); + await AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .StartAsync("Scanning source files…", async ctx => + { + (nodes, edges) = await builder.BuildAllAsync(root, cancellationToken); + ctx.Status($"Saving graph ({nodes:N0} nodes, {edges:N0} edges)…"); + }); + + AnsiConsole.MarkupLine($"[green]Done.[/] {nodes:N0} nodes · {edges:N0} edges written to [dim]{Markup.Escape(outputPath)}[/]"); + return 0; + } +} diff --git a/src/Cli/Commands/InitCommand.cs b/src/Cli/Commands/InitCommand.cs index 9c06406..9a0ef0d 100644 --- a/src/Cli/Commands/InitCommand.cs +++ b/src/Cli/Commands/InitCommand.cs @@ -112,12 +112,19 @@ protected override async Task ExecuteAsync( } await EnsureGitignoreEntryAsync(cancellationToken); + var knowledgeScaffold = await ScaffoldKnowledgeAsync(cancellationToken); var selected = Array.Find(Templates, t => t.Key == templateKey)!; var endpointDisplay = string.IsNullOrWhiteSpace(endpoint) ? "[dim](default)[/]" : Markup.Escape(endpoint); AnsiConsole.MarkupLine($"[green]✓[/] Config written → [bold]{Markup.Escape(output)}[/]"); foreach (var (relativePath, _) in generated.AgentFiles) AnsiConsole.MarkupLine($" [green]↳[/] {Markup.Escape(Path.Combine(configDir, relativePath))}"); + foreach (var (path, created) in knowledgeScaffold) + { + var icon = created ? "[green]✓[/]" : "[dim]·[/]"; + var label = created ? string.Empty : " [dim](already exists)[/]"; + AnsiConsole.MarkupLine($"{icon} {Markup.Escape(path)}{label}"); + } AnsiConsole.MarkupLine($"[dim]Template:[/] {selected.Label} [dim]Model:[/] {model} [dim]Endpoint:[/] {endpointDisplay}"); AnsiConsole.WriteLine(); @@ -201,6 +208,104 @@ private static string ResolveOutputPath(InitSettings settings) return string.IsNullOrWhiteSpace(path) ? defaultPath : path; } + private static async Task> ScaffoldKnowledgeAsync( + CancellationToken cancellationToken) + { + var result = new List<(string, bool)>(); + + // Directories — always created (idempotent). + var dirs = new[] + { + ".fuseraft/knowledge/decisions/archive", + ".fuseraft/knowledge/repository", + ".fuseraft/knowledge/objectives", + }; + foreach (var d in dirs) + Directory.CreateDirectory(d); + + // architecture.yaml — only if absent. + const string archPath = ".fuseraft/architecture.yaml"; + if (!File.Exists(archPath)) + { + await File.WriteAllTextAsync(archPath, DefaultArchitectureYaml, cancellationToken); + result.Add((archPath, true)); + } + else + { + result.Add((archPath, false)); + } + + // lifecycle.yaml — only if absent. + const string lcPath = ".fuseraft/knowledge/lifecycle.yaml"; + if (!File.Exists(lcPath)) + { + await File.WriteAllTextAsync(lcPath, DefaultLifecycleYaml, cancellationToken); + result.Add((lcPath, true)); + } + else + { + result.Add((lcPath, false)); + } + + return result; + } + + private const string DefaultArchitectureYaml = """ + # Architecture layer manifest — fuseraft arch check reads this file. + # Edit Paths and MayDependOn to match your project structure. + # Run: fuseraft arch check + Layers: + - Name: Core + Paths: + - src/Core/ + MayDependOn: [] + + - Name: Infrastructure + Paths: + - src/Infrastructure/ + MayDependOn: + - Core + + - Name: Orchestration + Paths: + - src/Orchestration/ + MayDependOn: + - Core + - Infrastructure + + - Name: Cli + Paths: + - src/Cli/ + MayDependOn: + - Core + - Infrastructure + - Orchestration + """; + + private const string DefaultLifecycleYaml = """ + # Knowledge lifecycle policy — fuseraft knowledge gc reads this file. + # All values are in days. Run: fuseraft knowledge gc + # + # AdrRetentionDays: days after Superseded status before archiving (0 = immediate). + AdrRetentionDays: 0 + # + # MemoryReinforceWindowDays: Approved memories not reinforced within this window + # are demoted back to Candidate for re-review. + MemoryReinforceWindowDays: 90 + # + # ConfidenceDecayDays: Verified provenance claims older than this (with no ExpiresAt) + # decay to Inferred. Set to 0 to disable decay. + ConfidenceDecayDays: 30 + # + # OrphanedNodeGracePeriodDays: graph nodes with no edges and no recent file touch + # are pruned after this many days. Set to 0 to disable. + OrphanedNodeGracePeriodDays: 7 + # + # MaxProvenanceAgeDays: expired provenance records (past ExpiresAt) are archived + # after this many additional days. 0 = archive immediately. + MaxProvenanceAgeDays: 0 + """; + private static async Task EnsureGitignoreEntryAsync(CancellationToken cancellationToken) { var gitignorePath = Path.Combine(Directory.GetCurrentDirectory(), ".gitignore"); diff --git a/src/Cli/Commands/Knowledge/KnowledgeGcCommand.cs b/src/Cli/Commands/Knowledge/KnowledgeGcCommand.cs new file mode 100644 index 0000000..564cb20 --- /dev/null +++ b/src/Cli/Commands/Knowledge/KnowledgeGcCommand.cs @@ -0,0 +1,131 @@ +using System.ComponentModel; +using Spectre.Console; +using Spectre.Console.Cli; +using fuseraft.Core; +using fuseraft.Core.Models; +using fuseraft.Infrastructure; + +namespace fuseraft.Cli.Commands.Knowledge; + +// fuseraft knowledge gc + +public sealed class KnowledgeGcSettings : CommandSettings +{ + [CommandOption("--apply")] + [Description("Commit all lifecycle changes to disk. Without this flag the command runs as a dry-run and prints what would change.")] + public bool Apply { get; init; } + + [CommandOption("--lifecycle|-l ")] + [Description("Path to lifecycle.yaml (default: .fuseraft/knowledge/lifecycle.yaml).")] + public string? LifecyclePath { get; init; } + + [CommandOption("--graph ")] + [Description("Override the repository graph path (default: .fuseraft/state/repository.graph).")] + public string? GraphPath { get; init; } +} + +public sealed class KnowledgeGcCommand : AsyncCommand +{ + protected override async Task ExecuteAsync( + CommandContext context, + KnowledgeGcSettings settings, + CancellationToken cancellationToken) + { + var policy = KnowledgeLifecycleManager.LoadPolicy(settings.LifecyclePath); + + var graphPath = settings.GraphPath + ?? Path.Combine(Directory.GetCurrentDirectory(), FuseraftPaths.LocalRepositoryGraph); + + var manager = new KnowledgeLifecycleManager( + new AdrStore(FuseraftPaths.LocalDecisions), + new RepositoryMemoryStore(FuseraftPaths.LocalRepositoryMemory), + new RepositoryGraphStore(graphPath), + new ProvenanceRegistry(FuseraftPaths.LocalProvenance)); + + if (!settings.Apply) + { + AnsiConsole.MarkupLine("[bold yellow]Dry-run mode[/] — pass [bold]--apply[/] to commit changes.\n"); + } + + GcReport report; + try + { + report = await AnsiConsole + .Status() + .StartAsync("Running knowledge lifecycle policies…", async _ => + await manager.RunAsync(policy, settings.Apply, cancellationToken)); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]GC failed:[/] {Markup.Escape(ex.Message)}"); + return 1; + } + + PrintReport(report, settings.Apply); + return 0; + } + + private static void PrintReport(GcReport report, bool applied) + { + var verb = applied ? "archived" : "would archive"; + + if (report.IsEmpty) + { + AnsiConsole.MarkupLine("[green]Nothing to do — all knowledge artifacts are within policy.[/]"); + return; + } + + AnsiConsole.WriteLine(); + + if (report.ArchivedDecisionIds.Count > 0) + { + AnsiConsole.MarkupLine($"[bold]Superseded ADRs[/] {verb} ({report.ArchivedDecisionIds.Count}):"); + foreach (var id in report.ArchivedDecisionIds) + AnsiConsole.MarkupLine($" [dim]→[/] {Markup.Escape(id)} [dim](.fuseraft/knowledge/decisions/archive/)[/]"); + AnsiConsole.WriteLine(); + } + + if (report.DemotedMemoryIds.Count > 0) + { + var v2 = applied ? "demoted" : "would demote"; + AnsiConsole.MarkupLine($"[bold]Repository memories[/] {v2} Approved → Candidate ({report.DemotedMemoryIds.Count}):"); + foreach (var id in report.DemotedMemoryIds) + AnsiConsole.MarkupLine($" [dim]→[/] {Markup.Escape(id)} [dim](not reinforced within window)[/]"); + AnsiConsole.WriteLine(); + } + + if (report.DecayedClaimIds.Count > 0) + { + var v2 = applied ? "decayed" : "would decay"; + AnsiConsole.MarkupLine($"[bold]Provenance claims[/] {v2} Verified → Inferred ({report.DecayedClaimIds.Count}):"); + foreach (var id in report.DecayedClaimIds) + AnsiConsole.MarkupLine($" [dim]→[/] {Markup.Escape(id)}"); + AnsiConsole.WriteLine(); + } + + if (report.PrunedNodeIds.Count > 0) + { + var v2 = applied ? "pruned" : "would prune"; + AnsiConsole.MarkupLine($"[bold]Orphaned graph nodes[/] {v2} ({report.PrunedNodeIds.Count}):"); + foreach (var id in report.PrunedNodeIds) + AnsiConsole.MarkupLine($" [dim]→[/] {Markup.Escape(id)}"); + AnsiConsole.WriteLine(); + } + + if (report.ArchivedProvenanceIds.Count > 0) + { + AnsiConsole.MarkupLine($"[bold]Provenance records[/] {verb} ({report.ArchivedProvenanceIds.Count}):"); + AnsiConsole.MarkupLine($" [dim]→ .fuseraft/state/provenance.archive.json[/]"); + AnsiConsole.WriteLine(); + } + + if (applied) + { + AnsiConsole.MarkupLine("[green]Knowledge GC complete.[/]"); + } + else + { + AnsiConsole.MarkupLine("[yellow]Dry-run complete — no changes written.[/] Re-run with [bold]--apply[/] to commit."); + } + } +} diff --git a/src/Cli/Commands/Memory/MemoryReviewCommand.cs b/src/Cli/Commands/Memory/MemoryReviewCommand.cs new file mode 100644 index 0000000..dac4249 --- /dev/null +++ b/src/Cli/Commands/Memory/MemoryReviewCommand.cs @@ -0,0 +1,96 @@ +using System.ComponentModel; +using Spectre.Console; +using Spectre.Console.Cli; +using fuseraft.Core; +using fuseraft.Infrastructure; + +namespace fuseraft.Cli.Commands.Memory; + +// fuseraft memory review + +public sealed class MemoryReviewSettings : CommandSettings +{ + [CommandOption("--dir ")] + [Description("Repository memory directory (default: .fuseraft/knowledge/repository).")] + public string? Directory { get; init; } + + [CommandOption("--all")] + [Description("Show all entries including Approved and Rejected, not just Candidates.")] + public bool All { get; init; } +} + +public sealed class MemoryReviewCommand : AsyncCommand +{ + protected override async Task ExecuteAsync( + CommandContext context, + MemoryReviewSettings settings, + CancellationToken cancellationToken) + { + var dir = settings.Directory ?? FuseraftPaths.LocalRepositoryMemory; + var store = new RepositoryMemoryStore(dir); + + var entries = settings.All + ? await store.LoadAllAsync(cancellationToken) + : await store.LoadCandidatesAsync(cancellationToken); + + if (entries.Count == 0) + { + AnsiConsole.MarkupLine(settings.All + ? "[dim]No repository memory entries found.[/]" + : "[dim]No candidate entries to review. Run a session first, or use [bold]--all[/] to view all entries.[/]"); + return 0; + } + + AnsiConsole.MarkupLine($"[bold]Repository Memory Review[/] — {entries.Count} entry/entries\n"); + + int approved = 0, rejected = 0, skipped = 0; + + foreach (var entry in entries) + { + AnsiConsole.Write(new Rule()); + AnsiConsole.MarkupLine($"[bold]Pattern:[/] {Markup.Escape(entry.Pattern)}"); + AnsiConsole.MarkupLine($"[dim]Status:[/] {entry.Status} [dim]Confidence:[/] {entry.Confidence} [dim]Reinforced:[/] ×{entry.ReinforcementCount}"); + if (entry.Evidence.Count > 0) + AnsiConsole.MarkupLine($"[dim]Evidence:[/] {string.Join(", ", entry.Evidence)}"); + AnsiConsole.WriteLine(); + + if (!settings.All || entry.Status.Equals("Candidate", StringComparison.OrdinalIgnoreCase)) + { + var choice = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Action?") + .AddChoices("Approve", "Reject", "Skip")); + + switch (choice) + { + case "Approve": + await store.SaveAsync(entry with { Status = "Approved" }, cancellationToken); + AnsiConsole.MarkupLine("[green]✓ Approved[/]"); + approved++; + break; + case "Reject": + await store.SaveAsync(entry with { Status = "Rejected" }, cancellationToken); + AnsiConsole.MarkupLine("[red]✗ Rejected[/]"); + rejected++; + break; + default: + AnsiConsole.MarkupLine("[dim]Skipped[/]"); + skipped++; + break; + } + } + else + { + AnsiConsole.MarkupLine($"[dim]({entry.Status} — no action needed)[/]"); + } + + AnsiConsole.WriteLine(); + } + + AnsiConsole.Write(new Rule()); + AnsiConsole.MarkupLine( + $"Review complete: [green]{approved} approved[/] [red]{rejected} rejected[/] [dim]{skipped} skipped[/]"); + + return 0; + } +} diff --git a/src/Cli/Commands/Objective/ObjectiveCommands.cs b/src/Cli/Commands/Objective/ObjectiveCommands.cs new file mode 100644 index 0000000..d95aa34 --- /dev/null +++ b/src/Cli/Commands/Objective/ObjectiveCommands.cs @@ -0,0 +1,186 @@ +using System.ComponentModel; +using Spectre.Console; +using Spectre.Console.Cli; +using fuseraft.Core; +using fuseraft.Infrastructure; + +namespace fuseraft.Cli.Commands.Objective; + +// ── fuseraft objective create ──────────────────────────────────────────────── + +public sealed class ObjectiveCreateSettings : CommandSettings +{ + [CommandOption("--title|-t ")] + [Description("Short title for the objective.")] + public string? Title { get; init; } + + [CommandOption("--description|-d <desc>")] + [Description("What this objective achieves and why it matters.")] + public string Description { get; init; } = ""; + + [CommandOption("--tasks <tasks>")] + [Description("Comma-separated list of initial remaining tasks.")] + public string? Tasks { get; init; } +} + +public sealed class ObjectiveCreateCommand : AsyncCommand<ObjectiveCreateSettings> +{ + protected override async Task<int> ExecuteAsync( + CommandContext context, + ObjectiveCreateSettings settings, + CancellationToken cancellationToken) + { + var title = settings.Title; + if (string.IsNullOrWhiteSpace(title)) + { + title = AnsiConsole.Ask<string>("[bold]Title:[/]"); + if (string.IsNullOrWhiteSpace(title)) + { + AnsiConsole.MarkupLine("[red]Title is required.[/]"); + return 1; + } + } + + var tasks = string.IsNullOrWhiteSpace(settings.Tasks) + ? null + : settings.Tasks.Split(',').Select(t => t.Trim()).Where(t => t.Length > 0); + + var store = new ObjectiveStore(FuseraftPaths.LocalObjectives); + var manager = new ObjectiveManager(store); + var obj = await manager.CreateAsync(title, settings.Description, tasks, cancellationToken); + + AnsiConsole.MarkupLine($"[green]Created[/] [bold]{Markup.Escape(obj.Id)}[/]: {Markup.Escape(obj.Title)}"); + return 0; + } +} + +// ── fuseraft objective list ────────────────────────────────────────────────── + +public sealed class ObjectiveListSettings : CommandSettings +{ + [CommandOption("--status|-s <status>")] + [Description("Filter by status: Active, Paused, Completed, Abandoned.")] + public string? Status { get; init; } + + [CommandOption("--all|-a")] + [Description("Show all objectives regardless of status (same as omitting --status).")] + public bool All { get; init; } +} + +public sealed class ObjectiveListCommand : AsyncCommand<ObjectiveListSettings> +{ + protected override async Task<int> ExecuteAsync( + CommandContext context, + ObjectiveListSettings settings, + CancellationToken cancellationToken) + { + var store = new ObjectiveStore(FuseraftPaths.LocalObjectives); + var manager = new ObjectiveManager(store); + var all = await manager.ListAllAsync(cancellationToken); + + var filtered = settings.All || string.IsNullOrWhiteSpace(settings.Status) + ? all + : all.Where(o => o.Status.Equals(settings.Status, StringComparison.OrdinalIgnoreCase)).ToList(); + + if (filtered.Count == 0) + { + AnsiConsole.MarkupLine("[grey]No objectives found.[/]"); + return 0; + } + + var table = new Table() + .Border(TableBorder.Rounded) + .AddColumn("[bold]ID[/]") + .AddColumn("[bold]Title[/]") + .AddColumn("[bold]Status[/]") + .AddColumn("[bold]Progress[/]"); + + foreach (var o in filtered) + { + var total = o.CompletedTasks.Count + o.RemainingTasks.Count; + var prog = total > 0 ? $"{o.PercentComplete:F0}% ({o.CompletedTasks.Count}/{total})" : "—"; + var statusColor = o.Status switch + { + "Active" => "green", + "Paused" => "yellow", + "Completed" => "blue", + _ => "grey" + }; + table.AddRow( + Markup.Escape(o.Id), + Markup.Escape(o.Title), + $"[{statusColor}]{Markup.Escape(o.Status)}[/]", + Markup.Escape(prog)); + } + + AnsiConsole.Write(table); + return 0; + } +} + +// ── fuseraft objective status ──────────────────────────────────────────────── + +public sealed class ObjectiveStatusSettings : CommandSettings +{ + [CommandArgument(0, "[id]")] + [Description("Objective ID to inspect (e.g. OBJ-0001).")] + public string? Id { get; init; } +} + +public sealed class ObjectiveStatusCommand : AsyncCommand<ObjectiveStatusSettings> +{ + protected override async Task<int> ExecuteAsync( + CommandContext context, + ObjectiveStatusSettings settings, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(settings.Id)) + { + AnsiConsole.MarkupLine("[red]Error:[/] Provide an objective ID, e.g. [bold]fuseraft objective status OBJ-0001[/]"); + return 1; + } + + var store = new ObjectiveStore(FuseraftPaths.LocalObjectives); + var manager = new ObjectiveManager(store); + var obj = await manager.GetAsync(settings.Id.Trim(), cancellationToken); + + if (obj is null) + { + AnsiConsole.MarkupLine($"[red]Not found:[/] No objective with ID '{Markup.Escape(settings.Id)}'."); + return 1; + } + + AnsiConsole.MarkupLine($"[bold]{Markup.Escape(obj.Id)}[/] — {Markup.Escape(obj.Title)}"); + AnsiConsole.MarkupLine($"Status: [bold]{Markup.Escape(obj.Status)}[/]"); + if (!string.IsNullOrWhiteSpace(obj.Description)) + AnsiConsole.MarkupLine($"Description: {Markup.Escape(obj.Description)}"); + + var total = obj.CompletedTasks.Count + obj.RemainingTasks.Count; + if (total > 0) + { + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"Progress: [bold]{obj.PercentComplete:F0}%[/] ({obj.CompletedTasks.Count}/{total} tasks)"); + + if (obj.CompletedTasks.Count > 0) + { + AnsiConsole.MarkupLine("[green]Completed:[/]"); + foreach (var t in obj.CompletedTasks) + AnsiConsole.MarkupLine($" [green]✓[/] {Markup.Escape(t)}"); + } + if (obj.RemainingTasks.Count > 0) + { + AnsiConsole.MarkupLine("[yellow]Remaining:[/]"); + foreach (var t in obj.RemainingTasks) + AnsiConsole.MarkupLine($" • {Markup.Escape(t)}"); + } + } + + if (obj.Sessions.Count > 0) + { + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"Sessions: {Markup.Escape(string.Join(", ", obj.Sessions))}"); + } + + return 0; + } +} diff --git a/src/Cli/Commands/RunCommand.cs b/src/Cli/Commands/RunCommand.cs index 1679d2a..942c1af 100644 --- a/src/Cli/Commands/RunCommand.cs +++ b/src/Cli/Commands/RunCommand.cs @@ -185,7 +185,7 @@ protected override async Task<int> ExecuteAsync(CommandContext context, RunSetti return 1; } - var (orchestrator, config, mcpManager, compactor, changeTracker, eventEmitter, governanceKernel, skillCurator) = built; + var (orchestrator, config, mcpManager, compactor, changeTracker, eventEmitter, governanceKernel, skillCurator, repoMemoryExtractor, _) = built; await using var _mcp = mcpManager; using var _governance = governanceKernel; @@ -497,6 +497,24 @@ protected override async Task<int> ExecuteAsync(CommandContext context, RunSetti } } + // Post-session repository memory extraction (best-effort — never fails the run). + if (repoMemoryExtractor is not null && result.Succeeded) + { + try + { + var candidates = await repoMemoryExtractor.ExtractAsync( + sessionId: checkpoint.SessionId, CancellationToken.None); + if (candidates.Count > 0) + AnsiConsole.MarkupLine( + $"[dim]Repository memory: {candidates.Count} new candidate(s) extracted. " + + $"Run [bold]fuseraft memory review[/] to approve.[/]"); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[dim yellow]Repository memory extraction failed:[/] {Markup.Escape(ex.Message)}"); + } + } + // Context window visualization — render after the run so all snapshot data is flushed. var ctxVizPath = Path.Combine(fuseraft.Core.FuseraftPaths.LocalLogs, $"ctx_viz_{checkpoint.SessionId}.html"); diff --git a/src/Cli/OrchestratorBuilder.cs b/src/Cli/OrchestratorBuilder.cs index e164e9f..21f6953 100644 --- a/src/Cli/OrchestratorBuilder.cs +++ b/src/Cli/OrchestratorBuilder.cs @@ -30,14 +30,16 @@ namespace fuseraft.Cli; /// together with all runtime components the session runner needs. /// </summary> public sealed record OrchestratorBuildResult( - IOrchestrator Orchestrator, - OrchestrationConfig Config, - McpSessionManager McpManager, - ConversationCompactor? Compactor, - ChangeTracker? ChangeTracker, - EventEmitter? EventEmitter, - GovernanceKernel GovernanceKernel, - SkillCurator? SkillCurator); + IOrchestrator Orchestrator, + OrchestrationConfig Config, + McpSessionManager McpManager, + ConversationCompactor? Compactor, + ChangeTracker? ChangeTracker, + EventEmitter? EventEmitter, + GovernanceKernel GovernanceKernel, + SkillCurator? SkillCurator, + RepositoryMemoryExtractor? RepositoryMemoryExtractor, + fuseraft.Orchestration.DependencyPlanner? DependencyPlanner = null); /// <summary> /// Builds a ready-to-use <see cref="IOrchestrator"/> directly from a config file path, @@ -418,6 +420,27 @@ public static async Task<OrchestratorBuildResult> BuildAsync( if (config.EvidenceStore is { } esCfg) evidenceStore = new EvidenceStore(esCfg.Path, loggerFactory.CreateLogger<EvidenceStore>()); + // Knowledge layer — single shared instance for the session. + // Wired here so the ChangeTracker (incremental graph rebuild) and ContextAssembler + // (adr_graph traversal) share the same underlying stores instead of creating + // independent instances that diverge mid-session. + var knowledgeSandbox = config.Security?.FileSystemSandboxPath is { Length: > 0 } ks + ? FuseraftPaths.ExpandPath(ks) + : Directory.GetCurrentDirectory(); + var knowledgeGraphPath = Path.Combine(knowledgeSandbox, FuseraftPaths.LocalRepositoryGraph); + var objectiveStore = new fuseraft.Infrastructure.ObjectiveStore(FuseraftPaths.LocalObjectives); + var objectiveManager = new fuseraft.Infrastructure.ObjectiveManager(objectiveStore); + + var knowledgeLayer = new fuseraft.Infrastructure.KnowledgeLayer( + new fuseraft.Infrastructure.AdrRegistry( + new fuseraft.Infrastructure.AdrStore(FuseraftPaths.LocalDecisions)), + new fuseraft.Infrastructure.RepositoryGraphStore(knowledgeGraphPath), + new fuseraft.Infrastructure.RepositoryGraphBuilder( + new fuseraft.Infrastructure.RepositoryGraphStore(knowledgeGraphPath), + knowledgeSandbox), + objectiveStore: objectiveStore); + pluginRegistry.ConfigureKnowledge(knowledgeLayer); + // Change tracking: hook a filter into every agent kernel that records tool results. // Pass eventEmitter, evidenceStore, and intentLog so tracked tool calls emit flat // entries, typed graph nodes, and pre-execution intent records. @@ -426,7 +449,7 @@ public static async Task<OrchestratorBuildResult> BuildAsync( if (config.ChangeTracking is { } ctConfig) { intentLog = new IntentLog(ctConfig.ResolveIntentLogPath(), loggerFactory.CreateLogger<IntentLog>()); - changeTracker = new ChangeTracker(ctConfig.Path, eventEmitter, evidenceStore, intentLog, loggerFactory.CreateLogger<ChangeTracker>()); + changeTracker = new ChangeTracker(ctConfig.Path, eventEmitter, evidenceStore, intentLog, loggerFactory.CreateLogger<ChangeTracker>(), knowledgeLayer.GraphBuilder); pluginRegistry.Register("Changes", () => new ChangesPlugin(ctConfig.Path)); } @@ -565,6 +588,24 @@ or GovernanceEventType.TrustFailed } } + // Dependency planner: validate Produces/Requires graph and detect cycles at startup. + // Active only when at least one agent declares a dependency token. + fuseraft.Orchestration.DependencyPlanner? dependencyPlanner = null; + if (config.Agents.Any(a => a.Produces.Count > 0 || a.Requires.Count > 0)) + { + // Constructor throws InvalidOperationException on cycles. + dependencyPlanner = new fuseraft.Orchestration.DependencyPlanner(config.Agents); + + if (dependencyPlanner.ExecutionLayers.Count > 0) + { + var layerSummary = string.Join(" → ", + dependencyPlanner.ExecutionLayers.Select(layer => $"[{string.Join(", ", layer)}]")); + loggerFactory.CreateLogger(nameof(OrchestratorBuilder)).LogInformation( + "DependencyPlanner active — {LayerCount} layer(s): {Layers}", + dependencyPlanner.ExecutionLayers.Count, layerSummary); + } + } + // Eagerly validate the adversarial config when that strategy is selected. if (config.Selection.Type.Equals("adversarial", StringComparison.OrdinalIgnoreCase)) { @@ -709,10 +750,22 @@ t.Pattern is not null || var resumptionNote = suppressResumptionNote ? null : ConversationCompactor.WorkflowResumptionNote; var changeLogPath = suppressResumptionNote ? null : (config.Validation?.ChangeLogPath ?? config.ChangeTracking?.Path); + + // Knowledge snapshot enricher: augments lossless/hybrid snapshots with ADR, + // objective, architecture-violation, memory, and provenance-expiry state. + var snapshotEnricher = new fuseraft.Infrastructure.KnowledgeSnapshotEnricher( + adrRegistry: knowledgeLayer.AdrRegistry, + objectiveManager: objectiveManager, + memoryStore: new fuseraft.Infrastructure.RepositoryMemoryStore(FuseraftPaths.LocalRepositoryMemory), + provenance: knowledgeLayer.ProvenanceRegistry, + manifestPath: FuseraftPaths.LocalArchitectureManifest, + projectRoot: knowledgeSandbox); + compactor = new ConversationCompactor( chatClientFactory.Create(summaryModel), compactionConfig, loggerFactory.CreateLogger<ConversationCompactor>(), - resumptionNote, changeLogPath, intentLog, config.Events?.Path, evidenceStore); + resumptionNote, changeLogPath, intentLog, config.Events?.Path, evidenceStore, + objectiveManager, snapshotEnricher); if ((compactionConfig.Mode ?? string.Empty).Equals("intent", StringComparison.OrdinalIgnoreCase) && intentLog is null) @@ -784,16 +837,29 @@ t.Pattern is not null || var resolvedSandbox = config.Security?.FileSystemSandboxPath is { Length: > 0 } sbx ? FuseraftPaths.ExpandPath(sbx) : null; + // Context Broker (Gap 8): adaptive context pipeline backed by the shared knowledge layer. + var brokerMemoryStore = new fuseraft.Infrastructure.RepositoryMemoryStore(FuseraftPaths.LocalRepositoryMemory); + var contextBroker = new fuseraft.Orchestration.ContextBroker( + knowledgeLayer, + brokerMemoryStore, + knowledgeLayer.ProvenanceRegistry); + // Shared assembler used by both the state machine (HandoffContext) and the // orchestrator (AgentConfig.Context). One instance so session ID updates propagate. + // Sources the graph store and ADR registry from the shared knowledge layer so + // adr_graph traversal sees the same state as the plugins and change tracker. var contextAssembler = new ContextAssembler( - sandboxRoot: resolvedSandbox, - changeLogPath: config.Validation?.ChangeLogPath, - briefPath: config.Validation?.BriefPath); + sandboxRoot: resolvedSandbox, + changeLogPath: config.Validation?.ChangeLogPath, + briefPath: config.Validation?.BriefPath, + graphStore: knowledgeLayer.GraphStore, + adrRegistry: knowledgeLayer.AdrRegistry, + objectiveManager: objectiveManager, + contextBroker: contextBroker); if (!string.IsNullOrEmpty(sessionId)) contextAssembler.SetSessionId(sessionId); - var strategyFactory = new StrategyFactory(chatClientFactory.Create, eventEmitter, loggerFactory, governanceKernel, humanApprovalService, evidenceStore, config.TestSelector, resolvedSandbox, contextAssembler); + var strategyFactory = new StrategyFactory(chatClientFactory.Create, eventEmitter, loggerFactory, governanceKernel, humanApprovalService, evidenceStore, knowledgeLayer.ProvenanceRegistry, config.TestSelector, resolvedSandbox, contextAssembler); // Validate verifier config: the named agent must exist in the agent pool. if (config.Verifier is { AgentName: { Length: > 0 } verifierAgentName }) @@ -957,7 +1023,24 @@ t.Pattern is not null || else { var memoryManager = MemoryManager.FromConfig(config.Memory); - orchestrator = new AgentOrchestrator(config, agentFactory, strategyFactory, aoLogger, changeTracker, eventEmitter, governanceKernel, memoryManager, contextAssembler); + + // Repository memory scope: inject Approved entries into every agent's system prompt. + var repoMemoryStore = new fuseraft.Infrastructure.RepositoryMemoryStore( + FuseraftPaths.LocalRepositoryMemory); + memoryManager?.AttachRepositoryMemory(repoMemoryStore); + + orchestrator = new AgentOrchestrator(config, agentFactory, strategyFactory, aoLogger, changeTracker, eventEmitter, governanceKernel, memoryManager, contextAssembler, dependencyPlanner); + } + + // Repository memory extractor — runs after the session to generate candidates. + // Requires an evidence store to query; skipped when evidence tracking is disabled. + fuseraft.Infrastructure.RepositoryMemoryExtractor? repoMemoryExtractor = null; + if (evidenceStore is not null) + { + var extractorStore = new fuseraft.Infrastructure.RepositoryMemoryStore( + FuseraftPaths.LocalRepositoryMemory); + repoMemoryExtractor = new fuseraft.Infrastructure.RepositoryMemoryExtractor( + evidenceStore, extractorStore); } // Wrap with SagaOrchestrator when the saga pattern is enabled. @@ -966,7 +1049,7 @@ t.Pattern is not null || if (config.Saga?.Enabled == true) orchestrator = new SagaOrchestrator(orchestrator, config.Saga, compensators: null, eventEmitter); - return new OrchestratorBuildResult(orchestrator, config, mcpManager, compactor, changeTracker, eventEmitter, governanceKernel, skillCurator); + return new OrchestratorBuildResult(orchestrator, config, mcpManager, compactor, changeTracker, eventEmitter, governanceKernel, skillCurator, repoMemoryExtractor, dependencyPlanner); } /// <summary> diff --git a/src/Core/FuseraftPaths.cs b/src/Core/FuseraftPaths.cs index 7315d84..b2973ef 100644 --- a/src/Core/FuseraftPaths.cs +++ b/src/Core/FuseraftPaths.cs @@ -71,6 +71,7 @@ public static string ExpandPath(string path) public const string LocalIntents = ".fuseraft/state/sessions/{session_id}/intents.json"; public const string LocalSessionContext = ".fuseraft/state/sessions/{session_id}/context_summary.md"; public const string LocalEvidence = ".fuseraft/state/evidence.json"; + public const string LocalProvenance = ".fuseraft/state/provenance.json"; public const string LocalFileVersions = ".fuseraft/state/file_versions.json"; // artifacts/ — structured agent-written documents read by validators @@ -94,6 +95,21 @@ public static string ExpandSessionId(string path, string sessionId) => // docs/ — agent-written markdown documents (research, reports, drafts, notes) public const string LocalDocs = ".fuseraft/docs"; + // knowledge/ — durable cross-session knowledge (ADRs, repository memory, objectives) + public const string LocalKnowledge = ".fuseraft/knowledge"; + public const string LocalDecisions = ".fuseraft/knowledge/decisions"; + public const string LocalDecisionsArchive = ".fuseraft/knowledge/decisions/archive"; + public const string LocalRepositoryMemory = ".fuseraft/knowledge/repository"; + public const string LocalObjectives = ".fuseraft/knowledge/objectives"; + public const string LocalLifecycleConfig = ".fuseraft/knowledge/lifecycle.yaml"; + public const string LocalProvenanceArchive = ".fuseraft/state/provenance.archive.json"; + + // Repository semantic graph — nodes + edges for all symbols in the project. + public const string LocalRepositoryGraph = ".fuseraft/state/repository.graph"; + + // Architecture drift detection — user-authored layer manifest. + public const string LocalArchitectureManifest = ".fuseraft/architecture.yaml"; + // checkpoints/ — session checkpoint files written when Checkpoint.Mode is set public const string LocalCheckpoints = ".fuseraft/checkpoints"; @@ -187,7 +203,9 @@ public static string BuildFolderOrientationBlock(bool includeLogs = true) sb.AppendLine(" .fuseraft/tests/ — write all test scripts and test support files here"); sb.AppendLine(" .fuseraft/tests/fixtures/ — seed data, stubs, and fixture files"); sb.AppendLine(" .fuseraft/context/ — injected reference documents (see .fuseraft/context/index.json)"); - sb.Append( " .fuseraft/summaries/ — compaction summaries"); + sb.AppendLine(" .fuseraft/summaries/ — compaction summaries"); + sb.AppendLine(" .fuseraft/knowledge/decisions/ — architecture decision records (use decision_search / decision_read)"); + sb.Append( " .fuseraft/state/repository.graph — repository semantic graph (use graph_search / graph_refs / graph_dependents)"); return sb.ToString(); } } diff --git a/src/Core/IKnowledgeLayer.cs b/src/Core/IKnowledgeLayer.cs new file mode 100644 index 0000000..b8693cb --- /dev/null +++ b/src/Core/IKnowledgeLayer.cs @@ -0,0 +1,65 @@ +using fuseraft.Core.Models; + +namespace fuseraft.Core; + +/// <summary> +/// Unified interface to the knowledge layer. +/// +/// <para> +/// All orchestrators share a single <see cref="IKnowledgeLayer"/> instance threaded through +/// <c>OrchestratorBuilder</c>. Subsystems (ADR, Graph, Memory, Provenance, Objectives) interact +/// with <em>each other</em> through this interface — they must not reference each other's concrete +/// types directly. +/// </para> +/// +/// <para> +/// Subsystems are added incrementally across gaps: +/// <list type="bullet"> +/// <item>Gap 1 — Architecture Decision Registry: <see cref="RecordDecisionAsync"/>, <see cref="SearchAsync"/> (decisions), <see cref="RetrieveAsync"/> (decisions)</item> +/// <item>Gap 2 — Repository Semantic Graph: <see cref="SearchAsync"/> (graph nodes), <see cref="RetrieveAsync"/> (graph nodes)</item> +/// <item>Gap 3 — Provenance: <see cref="RecordClaimAsync"/></item> +/// <item>Gap 7 — Objectives: <see cref="RecordObjectiveAsync"/></item> +/// </list> +/// </para> +/// </summary> +public interface IKnowledgeLayer +{ + /// <summary> + /// Searches across all registered knowledge subsystems. Results are ordered by relevance. + /// Pass <paramref name="kinds"/> to restrict to specific artifact types (e.g. only decisions). + /// </summary> + Task<IEnumerable<KnowledgeResult>> SearchAsync( + string query, + IReadOnlyList<KnowledgeKind>? kinds = null, + CancellationToken ct = default); + + /// <summary> + /// Retrieves a full artifact by its stable ID (e.g. <c>adr:ADR-0042</c>, <c>type:My.Ns.Foo</c>). + /// Returns <c>null</c> when no artifact matches. + /// </summary> + Task<KnowledgeArtifact?> RetrieveAsync(string id, CancellationToken ct = default); + + /// <summary> + /// Records a verifiable claim with supporting evidence. Confidence tier is computed + /// automatically from the <paramref name="support"/> composition by + /// <see cref="fuseraft.Infrastructure.ConfidenceComputer"/>. + /// </summary> + Task<ClaimRecord> RecordClaimAsync( + string claim, + IReadOnlyList<EvidenceClass> support, + string? artifactId = null, + DateTimeOffset? expiresAt = null, + CancellationToken ct = default); + + /// <summary> + /// Persists an architecture decision record and wires its graph node and <c>adr_governs</c> + /// edges so the decision is reachable via graph traversal. + /// </summary> + Task<AdrEntry> RecordDecisionAsync(AdrEntry entry, CancellationToken ct = default); + + /// <summary> + /// Records a long-horizon objective. + /// Implemented in Gap 7 (Long-Horizon Objective Tracking). + /// </summary> + Task<Objective> RecordObjectiveAsync(Objective objective, CancellationToken ct = default); +} diff --git a/src/Core/Models/AdrEntry.cs b/src/Core/Models/AdrEntry.cs new file mode 100644 index 0000000..47ed906 --- /dev/null +++ b/src/Core/Models/AdrEntry.cs @@ -0,0 +1,17 @@ +namespace fuseraft.Core.Models; + +public sealed record AdrEntry +{ + public string Id { get; init; } = string.Empty; + public string Title { get; init; } = string.Empty; + public string Status { get; init; } = "Proposed"; + public string Date { get; init; } = string.Empty; + public string Context { get; init; } = string.Empty; + public string Decision { get; init; } = string.Empty; + public List<string> Alternatives { get; init; } = []; + public List<string> Consequences { get; init; } = []; + public List<string> Supersedes { get; init; } = []; + public List<string> Tags { get; init; } = []; + /// <summary>File paths or SymbolId strings this decision governs; used to build adr_governs edges in the repository graph.</summary> + public List<string> Governs { get; init; } = []; +} diff --git a/src/Core/Models/AgentConfig.cs b/src/Core/Models/AgentConfig.cs index 03a1ee4..76c2b55 100644 --- a/src/Core/Models/AgentConfig.cs +++ b/src/Core/Models/AgentConfig.cs @@ -231,6 +231,21 @@ public record AgentConfig /// </summary> public int SubAgentMaxToolCalls { get; init; } = 0; + /// <summary> + /// Tokens produced by this agent when its turn completes successfully. + /// Used by <see cref="fuseraft.Orchestration.DependencyPlanner"/> to mark dependencies as fulfilled. + /// Supported token types: <c>artifact:<name></c>, <c>file:<path></c>, + /// <c>symbol:<name></c>, or plain coarse-capability strings (e.g. <c>analyzed_codebase</c>). + /// </summary> + public List<string> Produces { get; init; } = []; + + /// <summary> + /// Tokens that must be in the fulfilled set before this agent is eligible to run. + /// The orchestrator blocks this agent until all listed tokens are produced. + /// Token format mirrors <see cref="Produces"/>. + /// </summary> + public List<string> Requires { get; init; } = []; + /// <summary> /// When set, this agent is hosted remotely and accessed via the A2A protocol. /// <see cref="RemoteAgentConfig.Url"/> is the base URL of the remote agent; diff --git a/src/Core/Models/ArchitectureManifest.cs b/src/Core/Models/ArchitectureManifest.cs new file mode 100644 index 0000000..8825dec --- /dev/null +++ b/src/Core/Models/ArchitectureManifest.cs @@ -0,0 +1,53 @@ +namespace fuseraft.Core.Models; + +/// <summary> +/// Architecture manifest loaded from <c>.fuseraft/architecture.yaml</c>. +/// Defines project layers and their allowed dependency relationships. +/// </summary> +public sealed class ArchitectureManifest +{ + public List<ArchitectureLayer> Layers { get; set; } = []; +} + +/// <summary> +/// A single named layer in the architecture manifest. +/// </summary> +public sealed class ArchitectureLayer +{ + /// <summary>Display name (e.g. "Core", "Infrastructure").</summary> + public string Name { get; set; } = string.Empty; + + /// <summary>Source paths that belong to this layer, relative to project root (e.g. "src/Core/").</summary> + public List<string> Paths { get; set; } = []; + + /// <summary> + /// Namespace prefixes owned by this layer. + /// When empty, defaults to the root namespace + "." + Name (e.g. "fuseraft.Core"). + /// </summary> + public List<string> Namespaces { get; set; } = []; + + /// <summary>Names of other layers this layer is allowed to reference.</summary> + public List<string> MayDependOn { get; set; } = []; +} + +/// <summary> +/// A detected architecture violation: a source file in one layer importing +/// a namespace that belongs to a layer it is not permitted to reference. +/// </summary> +public sealed record ArchitectureViolation +{ + /// <summary>Layer that contains the violating source file.</summary> + public string SourceLayer { get; init; } = string.Empty; + + /// <summary>Layer that owns the illegally referenced namespace.</summary> + public string TargetLayer { get; init; } = string.Empty; + + /// <summary>Relative path of the violating source file.</summary> + public string File { get; init; } = string.Empty; + + /// <summary>1-based line number of the offending <c>using</c> directive.</summary> + public int Line { get; init; } + + /// <summary>The namespace being imported illegally.</summary> + public string Namespace { get; init; } = string.Empty; +} diff --git a/src/Core/Models/ClaimRecord.cs b/src/Core/Models/ClaimRecord.cs new file mode 100644 index 0000000..824ce7d --- /dev/null +++ b/src/Core/Models/ClaimRecord.cs @@ -0,0 +1,44 @@ +namespace fuseraft.Core.Models; + +/// <summary> +/// A verifiable claim with supporting evidence, computed confidence tier, and optional expiry. +/// +/// <para> +/// <c>Status</c> is never caller-supplied: it is always computed by +/// <see cref="fuseraft.Infrastructure.ConfidenceComputer.Compute"/> from the <see cref="Support"/> +/// composition. Callers set <see cref="ExpiresAt"/> based on the volatility of the claim — +/// a build-pass claim expires quickly; an ADR-backed architectural claim may never expire. +/// </para> +/// </summary> +public sealed record ClaimRecord +{ + public string Id { get; init; } = Guid.NewGuid().ToString("N"); + + /// <summary>The claim being made, in plain language.</summary> + public string Claim { get; init; } = string.Empty; + + /// <summary>The artifact or evidence-graph node this claim is about.</summary> + public string? ArtifactId { get; init; } + + /// <summary>Evidence classes backing this claim. Determines <see cref="Status"/> via ConfidenceComputer.</summary> + public List<EvidenceClass> Support { get; init; } = []; + + /// <summary>Computed confidence tier: Verified / Inferred / Assumed / Guessed.</summary> + public string Status { get; init; } = "Guessed"; + + /// <summary>Artifact IDs or node IDs that constitute the supporting evidence.</summary> + public List<string> ProvenanceSources { get; init; } = []; + + /// <summary>When this claim was first recorded.</summary> + public DateTimeOffset ObservedAt { get; init; } = DateTimeOffset.UtcNow; + + /// <summary>When supporting evidence was collected. Null until the claim is verified.</summary> + public DateTimeOffset? VerifiedAt { get; init; } + + /// <summary> + /// When this verification is no longer trusted. Null means the claim does not expire. + /// Callers set this based on claim volatility (e.g. a build-pass claim expires in hours; + /// an ADR-backed architectural claim may be indefinite). + /// </summary> + public DateTimeOffset? ExpiresAt { get; init; } +} diff --git a/src/Core/Models/ContextSnapshot.cs b/src/Core/Models/ContextSnapshot.cs index 9b7d9a1..eb215b6 100644 --- a/src/Core/Models/ContextSnapshot.cs +++ b/src/Core/Models/ContextSnapshot.cs @@ -5,6 +5,11 @@ namespace fuseraft.Core.Models; /// </summary> public sealed record ContractCheckResult(string Name, bool Passed, string? Error); +/// <summary> +/// Lightweight ADR summary carried in a <see cref="ContextSnapshot"/>. +/// </summary> +public sealed record AdrSummary(string Id, string Title, string Status); + /// <summary> /// A point-in-time snapshot of the orchestration state used for lossless context /// reconstruction. All fields are derived from durable disk artifacts so the snapshot @@ -35,4 +40,39 @@ public sealed record ContextSnapshot /// <summary>UTC time the snapshot was taken.</summary> public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow; + + // ── Knowledge layer fields (Gap 9 cross-cutting) ───────────────────────── + + /// <summary> + /// Active (Accepted-status) ADRs at snapshot time. Populated by + /// <see cref="fuseraft.Infrastructure.KnowledgeSnapshotEnricher"/> when an ADR registry + /// is available. Empty when knowledge enrichment is not configured. + /// </summary> + public IReadOnlyList<AdrSummary> ActiveAdrs { get; init; } = []; + + /// <summary> + /// Formatted summary of active long-horizon objectives at snapshot time, or <c>null</c> + /// when no objectives are active or the objective manager is unavailable. + /// </summary> + public string? ObjectiveState { get; init; } + + /// <summary> + /// Architecture layer violations found at snapshot time. Each entry is a short + /// human-readable description. Empty when no manifest is configured or no violations exist. + /// </summary> + public IReadOnlyList<string> ArchitectureViolations { get; init; } = []; + + /// <summary> + /// Patterns from the top approved repository memories (by reinforcement count). + /// Injected at snapshot time so agents resuming after compaction see stable cross-session + /// knowledge without relying on the pre-turn memory injection path. + /// </summary> + public IReadOnlyList<string> TopRepositoryMemories { get; init; } = []; + + /// <summary> + /// Human-readable summaries of provenance claims that have expired (past their + /// <c>ExpiresAt</c>). Agents should re-verify any artifact referenced in these warnings + /// before acting on it. + /// </summary> + public IReadOnlyList<string> ExpiredProvenanceWarnings { get; init; } = []; } diff --git a/src/Core/Models/EvidenceClass.cs b/src/Core/Models/EvidenceClass.cs new file mode 100644 index 0000000..372ac26 --- /dev/null +++ b/src/Core/Models/EvidenceClass.cs @@ -0,0 +1,17 @@ +namespace fuseraft.Core.Models; + +/// <summary> +/// Classifies the type of evidence backing a <see cref="ClaimRecord"/>. +/// Used by <see cref="fuseraft.Infrastructure.ConfidenceComputer"/> to compute confidence tier. +/// </summary> +public enum EvidenceClass +{ + GitHistory, + EvidenceGraph, + TestResult, + ExitCode, + Validator, + ADR, + RepositoryMemory, + AgentAssertion, +} diff --git a/src/Core/Models/EvidenceGraph.cs b/src/Core/Models/EvidenceGraph.cs index 390d108..0782961 100644 --- a/src/Core/Models/EvidenceGraph.cs +++ b/src/Core/Models/EvidenceGraph.cs @@ -45,6 +45,7 @@ public record EvidenceNode /// <item><c>TestResult</c> — a test result was recorded in the test report.</item> /// <item><c>SymbolDefinition</c> — a symbol was analyzed during recon (name, kind, file).</item> /// <item><c>SymbolReference</c> — a cross-file reference was mapped by the Archaeologist (source file, symbol name, target file).</item> + /// <item><c>Violation</c> — an architecture layer violation; <see cref="Path"/> is the offending file, <see cref="SymbolName"/> is the illegal namespace, <see cref="Evidence"/> is "SourceLayer → TargetLayer".</item> /// </list> /// </summary> public string NodeType { get; init; } = string.Empty; @@ -127,6 +128,13 @@ public record EvidenceNode /// <see cref="Path"/> carries the file where the reference occurs. /// </summary> public string? TargetFile { get; init; } + + /// <summary> + /// ID of the <see cref="ClaimRecord"/> in the provenance registry that verifies the + /// observable outcome represented by this node. Null until a validator or the provenance + /// registry explicitly associates a claim with this node. + /// </summary> + public string? ProvenanceRef { get; init; } } /// <summary> diff --git a/src/Core/Models/KnowledgeArtifact.cs b/src/Core/Models/KnowledgeArtifact.cs new file mode 100644 index 0000000..04b8164 --- /dev/null +++ b/src/Core/Models/KnowledgeArtifact.cs @@ -0,0 +1,10 @@ +namespace fuseraft.Core.Models; + +/// <summary>Full artifact returned by <see cref="IKnowledgeLayer.RetrieveAsync"/>.</summary> +public sealed record KnowledgeArtifact +{ + public string Id { get; init; } = string.Empty; + public KnowledgeKind Kind { get; init; } + public AdrEntry? Decision { get; init; } + public RepositoryGraphNode? GraphNode { get; init; } +} diff --git a/src/Core/Models/KnowledgeResult.cs b/src/Core/Models/KnowledgeResult.cs new file mode 100644 index 0000000..560b266 --- /dev/null +++ b/src/Core/Models/KnowledgeResult.cs @@ -0,0 +1,16 @@ +namespace fuseraft.Core.Models; + +/// <summary>Discriminates what kind of artifact a <see cref="KnowledgeResult"/> represents.</summary> +public enum KnowledgeKind { Decision, GraphNode, Memory, Claim, Objective } + +/// <summary>Lightweight search result returned by <see cref="IKnowledgeLayer.SearchAsync"/>.</summary> +public sealed record KnowledgeResult +{ + public string Id { get; init; } = string.Empty; + public KnowledgeKind Kind { get; init; } + public string Title { get; init; } = string.Empty; + public string? Summary { get; init; } + public string? FilePath { get; init; } + public string? Status { get; init; } + public IReadOnlyList<string>? Tags { get; init; } +} diff --git a/src/Core/Models/LifecycleConfig.cs b/src/Core/Models/LifecycleConfig.cs new file mode 100644 index 0000000..49a549a --- /dev/null +++ b/src/Core/Models/LifecycleConfig.cs @@ -0,0 +1,61 @@ +namespace fuseraft.Core.Models; + +/// <summary> +/// Configures how each knowledge artifact type ages, decays, and is pruned. +/// Loaded from <c>.fuseraft/knowledge/lifecycle.yaml</c>; defaults apply when the file is absent. +/// </summary> +public sealed record LifecyclePolicy +{ + /// <summary> + /// Archive superseded ADRs after they have been in Superseded status for at least this many days. + /// 0 = archive immediately on the next gc run (any superseded ADR is eligible). + /// Default: 0 (archive all superseded ADRs). + /// </summary> + public int AdrRetentionDays { get; init; } = 0; + + /// <summary> + /// Demote Approved repository memories to Candidate when they have not been reinforced + /// for at least this many days. Default: 90 days. + /// </summary> + public int MemoryReinforceWindowDays { get; init; } = 90; + + /// <summary> + /// Downgrade Verified provenance claims to Inferred when the claim has no explicit + /// <c>ExpiresAt</c> and its <c>VerifiedAt</c> is older than this many days. + /// 0 = disable decay. Default: 30 days. + /// </summary> + public int ConfidenceDecayDays { get; init; } = 30; + + /// <summary> + /// Remove graph nodes with no edges and no recent file touch after this many days. + /// 0 = disable orphan pruning. Default: 7 days. + /// </summary> + public int OrphanedNodeGracePeriodDays { get; init; } = 7; + + /// <summary> + /// Archive provenance records whose <c>ExpiresAt</c> has passed. + /// Records without <c>ExpiresAt</c> are governed by <see cref="ConfidenceDecayDays"/>. + /// Default: archive all expired records (any record past ExpiresAt is eligible). + /// </summary> + public int MaxProvenanceAgeDays { get; init; } = 0; +} + +/// <summary> +/// Report returned by <see cref="fuseraft.Infrastructure.KnowledgeLifecycleManager.RunAsync"/>. +/// Describes what was archived, demoted, decayed, or pruned. +/// </summary> +public sealed record GcReport +{ + public IReadOnlyList<string> ArchivedDecisionIds { get; init; } = []; + public IReadOnlyList<string> DemotedMemoryIds { get; init; } = []; + public IReadOnlyList<string> DecayedClaimIds { get; init; } = []; + public IReadOnlyList<string> PrunedNodeIds { get; init; } = []; + public IReadOnlyList<string> ArchivedProvenanceIds { get; init; } = []; + + public bool IsEmpty => + ArchivedDecisionIds.Count == 0 && + DemotedMemoryIds.Count == 0 && + DecayedClaimIds.Count == 0 && + PrunedNodeIds.Count == 0 && + ArchivedProvenanceIds.Count == 0; +} diff --git a/src/Core/Models/NodeType.cs b/src/Core/Models/NodeType.cs new file mode 100644 index 0000000..0c730ca --- /dev/null +++ b/src/Core/Models/NodeType.cs @@ -0,0 +1,20 @@ +namespace fuseraft.Core.Models; + +/// <summary> +/// Discriminates every kind of node in the repository semantic graph. +/// </summary> +public enum NodeType +{ + Namespace, + File, + Project, + Package, + Type, + Interface, + Method, + Property, + Field, + Adr, + /// <summary>An architecture layer violation detected by <c>ArchitectureValidator</c>.</summary> + Violation, +} diff --git a/src/Core/Models/Objective.cs b/src/Core/Models/Objective.cs new file mode 100644 index 0000000..2ac9eb5 --- /dev/null +++ b/src/Core/Models/Objective.cs @@ -0,0 +1,33 @@ +namespace fuseraft.Core.Models; + +/// <summary> +/// A long-horizon objective tracked across multiple sessions. +/// </summary> +public sealed record Objective +{ + public string Id { get; init; } = string.Empty; + public string Title { get; init; } = string.Empty; + public string Description { get; init; } = string.Empty; + + /// <summary>Active | Paused | Completed | Abandoned</summary> + public string Status { get; init; } = "Active"; + + public List<string> CompletedTasks { get; init; } = []; + public List<string> RemainingTasks { get; init; } = []; + public List<string> Sessions { get; init; } = []; + + public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; init; } = DateTimeOffset.UtcNow; + + /// <summary> + /// Computed on demand — never stored. Returns 0 when no tasks are declared. + /// </summary> + public double PercentComplete + { + get + { + var total = CompletedTasks.Count + RemainingTasks.Count; + return total == 0 ? 0.0 : (double)CompletedTasks.Count / total * 100.0; + } + } +} diff --git a/src/Core/Models/RepositoryGraph.cs b/src/Core/Models/RepositoryGraph.cs new file mode 100644 index 0000000..4270446 --- /dev/null +++ b/src/Core/Models/RepositoryGraph.cs @@ -0,0 +1,108 @@ +namespace fuseraft.Core.Models; + +/// <summary> +/// A single node in the repository semantic graph. +/// <para> +/// Identity is stable across rebuilds: <see cref="Id"/> is the fully-qualified +/// <c>SymbolId</c> string (e.g. <c>type:fuseraft.Core.Models.AdrEntry</c>). +/// Node IDs survive renames only when git-history correlation is applied; for the +/// initial implementation stable IDs are guaranteed within a session. +/// </para> +/// </summary> +public sealed record RepositoryGraphNode +{ + public string Id { get; init; } = string.Empty; + public NodeType Kind { get; init; } + public string? FilePath { get; init; } + public string? Name { get; init; } + public string? Namespace { get; init; } + public int? StartLine { get; init; } + public int? EndLine { get; init; } + public string? SessionId { get; init; } + public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow; +} + +/// <summary> +/// A directed edge between two graph nodes. +/// </summary> +public sealed record RepositoryGraphEdge +{ + /// <summary>Source node <see cref="RepositoryGraphNode.Id"/>.</summary> + public string From { get; init; } = string.Empty; + /// <summary>Target node <see cref="RepositoryGraphNode.Id"/>.</summary> + public string To { get; init; } = string.Empty; + /// <summary>Semantic relation. Use <see cref="EdgeType"/> constants.</summary> + public string Relation { get; init; } = string.Empty; +} + +/// <summary> +/// Well-known edge relation labels for the repository semantic graph. +/// </summary> +public static class EdgeType +{ + public const string Defines = "defines"; + public const string Imports = "imports"; + public const string Inherits = "inherits"; + public const string Implements = "implements"; + public const string References = "references"; + public const string DependsOn = "depends_on"; + public const string AdrGoverns = "adr_governs"; +} + +/// <summary> +/// The complete in-memory repository semantic graph (nodes + edges). +/// </summary> +public sealed class RepositoryGraph +{ + public List<RepositoryGraphNode> Nodes { get; set; } = []; + public List<RepositoryGraphEdge> Edges { get; set; } = []; + public DateTimeOffset LastUpdated { get; set; } = DateTimeOffset.UtcNow; + + // ── Lookup helpers ────────────────────────────────────────────────────── + + public RepositoryGraphNode? FindById(string id) => + Nodes.FirstOrDefault(n => string.Equals(n.Id, id, StringComparison.Ordinal)); + + /// <summary>Returns all nodes whose <c>Id</c> starts with the given SymbolId prefix.</summary> + public IEnumerable<RepositoryGraphNode> FindByFile(string filePath) => + Nodes.Where(n => string.Equals(n.FilePath, filePath, StringComparison.OrdinalIgnoreCase)); + + /// <summary>Returns all edges with the given relation type leaving <paramref name="fromId"/>.</summary> + public IEnumerable<RepositoryGraphEdge> EdgesFrom(string fromId, string? relation = null) => + Edges.Where(e => string.Equals(e.From, fromId, StringComparison.Ordinal) + && (relation is null || string.Equals(e.Relation, relation, StringComparison.Ordinal))); + + /// <summary>Returns all edges with the given relation type arriving at <paramref name="toId"/>.</summary> + public IEnumerable<RepositoryGraphEdge> EdgesTo(string toId, string? relation = null) => + Edges.Where(e => string.Equals(e.To, toId, StringComparison.Ordinal) + && (relation is null || string.Equals(e.Relation, relation, StringComparison.Ordinal))); + + // ── Mutation helpers ──────────────────────────────────────────────────── + + /// <summary>Removes all nodes and edges associated with <paramref name="filePath"/>.</summary> + public void RemoveFile(string filePath) + { + var ids = new HashSet<string>( + Nodes.Where(n => string.Equals(n.FilePath, filePath, StringComparison.OrdinalIgnoreCase)) + .Select(n => n.Id), + StringComparer.Ordinal); + + Nodes.RemoveAll(n => ids.Contains(n.Id)); + Edges.RemoveAll(e => ids.Contains(e.From) || ids.Contains(e.To)); + } + + public void AddNode(RepositoryGraphNode node) + { + Nodes.RemoveAll(n => string.Equals(n.Id, node.Id, StringComparison.Ordinal)); + Nodes.Add(node); + } + + public void AddEdge(RepositoryGraphEdge edge) + { + bool exists = Edges.Any(e => + string.Equals(e.From, edge.From, StringComparison.Ordinal) && + string.Equals(e.To, edge.To, StringComparison.Ordinal) && + string.Equals(e.Relation, edge.Relation, StringComparison.Ordinal)); + if (!exists) Edges.Add(edge); + } +} diff --git a/src/Core/Models/RepositoryMemoryEntry.cs b/src/Core/Models/RepositoryMemoryEntry.cs new file mode 100644 index 0000000..0d84581 --- /dev/null +++ b/src/Core/Models/RepositoryMemoryEntry.cs @@ -0,0 +1,40 @@ +namespace fuseraft.Core.Models; + +/// <summary> +/// A durable, cross-session pattern extracted from observable evidence. +/// +/// <para> +/// Entries start as <c>Candidate</c> after extraction and become <c>Approved</c> +/// only through human review (<c>fuseraft memory review</c>) or an automated +/// reviewer agent. Candidates are never injected into agent prompts. +/// When an approved pattern recurs across sessions, <see cref="ReinforcementCount"/> +/// is incremented and <see cref="Confidence"/> is recomputed by +/// <see cref="fuseraft.Infrastructure.ConfidenceComputer"/>. +/// </para> +/// </summary> +public sealed record RepositoryMemoryEntry +{ + public string Id { get; init; } = Guid.NewGuid().ToString("N"); + + /// <summary>The recurring pattern or fact observed across sessions.</summary> + public string Pattern { get; init; } = string.Empty; + + /// <summary>Computed confidence tier (Verified / Inferred / Assumed / Guessed).</summary> + public string Confidence { get; init; } = "Guessed"; + + /// <summary>Evidence classes backing this entry — drives the confidence computation.</summary> + public List<EvidenceClass> Evidence { get; init; } = []; + + public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; + + public DateTimeOffset LastReinforcedAt { get; init; } = DateTimeOffset.UtcNow; + + /// <summary>How many sessions have independently produced the same pattern.</summary> + public int ReinforcementCount { get; init; } + + /// <summary>Lifecycle state: Candidate, Approved, or Rejected.</summary> + public string Status { get; init; } = "Candidate"; + + /// <summary>Session ID that first produced this entry.</summary> + public string? SourceSessionId { get; init; } +} diff --git a/src/Infrastructure/AdrRegistry.cs b/src/Infrastructure/AdrRegistry.cs new file mode 100644 index 0000000..1acb756 --- /dev/null +++ b/src/Infrastructure/AdrRegistry.cs @@ -0,0 +1,104 @@ +using fuseraft.Core.Models; + +namespace fuseraft.Infrastructure; + +/// <summary> +/// Index and query layer over <see cref="AdrStore"/>. +/// +/// Provides keyword search, status/tag filtering, supersession chain traversal, +/// and ID allocation. All reads go through the store; the registry adds no +/// in-memory cache — correctness over speed for a human-scale ADR corpus. +/// </summary> +public sealed class AdrRegistry +{ + private readonly AdrStore _store; + + public AdrRegistry(AdrStore store) => _store = store; + + // Search + + /// <summary> + /// Returns ADRs matching all supplied filters. Passing empty/null values skips that filter. + /// Query is checked against ID, title, context, decision text, and tags. + /// </summary> + public async Task<List<AdrEntry>> SearchAsync( + string? query = null, + string? status = null, + string? tag = null, + CancellationToken ct = default) + { + var all = await _store.LoadAllAsync(ct); + return all.Where(e => Matches(e, query, status, tag)).ToList(); + } + + // Lookup + + public Task<AdrEntry?> GetByIdAsync(string id, CancellationToken ct = default) => + _store.LoadAsync(id, ct); + + public async Task<List<AdrEntry>> GetActiveAsync(CancellationToken ct = default) + { + var all = await _store.LoadAllAsync(ct); + return all.Where(e => e.Status.Equals("Accepted", StringComparison.OrdinalIgnoreCase)).ToList(); + } + + /// <summary> + /// Walks the <c>Supersedes</c> chain starting from <paramref name="id"/>, returning + /// entries in order from newest to oldest. Stops at the first entry with no + /// <c>Supersedes</c> or at a cycle. + /// </summary> + public async Task<List<AdrEntry>> GetSupersessionChainAsync(string id, CancellationToken ct = default) + { + var chain = new List<AdrEntry>(); + var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + var current = await _store.LoadAsync(id, ct); + + while (current is not null && visited.Add(current.Id)) + { + chain.Add(current); + if (current.Supersedes.Count == 0) break; + current = await _store.LoadAsync(current.Supersedes[0], ct); + } + + return chain; + } + + // Write + + public async Task<AdrEntry> SaveAsync(AdrEntry entry, CancellationToken ct = default) + { + await _store.SaveAsync(entry, ct); + return entry; + } + + public async Task<bool> DeleteAsync(string id, CancellationToken ct = default) => + await _store.DeleteAsync(id, ct); + + // ID allocation + + public string NextId() => _store.NextId(); + + // Helpers + + private static bool Matches(AdrEntry e, string? query, string? status, string? tag) + { + if (status is not null && !e.Status.Equals(status, StringComparison.OrdinalIgnoreCase)) + return false; + + if (tag is not null && !e.Tags.Any(t => t.Equals(tag, StringComparison.OrdinalIgnoreCase))) + return false; + + if (!string.IsNullOrWhiteSpace(query)) + { + var q = query.Trim(); + var hit = e.Id.Contains(q, StringComparison.OrdinalIgnoreCase) + || e.Title.Contains(q, StringComparison.OrdinalIgnoreCase) + || e.Context.Contains(q, StringComparison.OrdinalIgnoreCase) + || e.Decision.Contains(q, StringComparison.OrdinalIgnoreCase) + || e.Tags.Any(t => t.Contains(q, StringComparison.OrdinalIgnoreCase)); + if (!hit) return false; + } + + return true; + } +} diff --git a/src/Infrastructure/AdrStore.cs b/src/Infrastructure/AdrStore.cs new file mode 100644 index 0000000..20f0deb --- /dev/null +++ b/src/Infrastructure/AdrStore.cs @@ -0,0 +1,151 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using fuseraft.Core.Models; + +namespace fuseraft.Infrastructure; + +/// <summary> +/// File-backed store for architecture decision records (ADRs). +/// +/// Each entry is persisted as an indented JSON file named after its ID +/// (e.g. <c>ADR-0042.json</c>) under the configured decisions directory. +/// Writes are atomic (write-to-temp then rename) and protected by a semaphore. +/// </summary> +public sealed class AdrStore +{ + private readonly string _dir; + private readonly SemaphoreSlim _lock = new(1, 1); + + private static readonly JsonSerializerOptions JsonOpts = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + public AdrStore(string directory) => _dir = Path.GetFullPath(directory); + + // Read + + public async Task<List<AdrEntry>> LoadAllAsync(CancellationToken ct = default) + { + if (!Directory.Exists(_dir)) return []; + + var results = new List<AdrEntry>(); + foreach (var file in Directory.GetFiles(_dir, "ADR-*.json").OrderBy(f => f)) + { + var entry = await LoadFileAsync(file, ct); + if (entry is not null) results.Add(entry); + } + return results; + } + + public async Task<AdrEntry?> LoadAsync(string id, CancellationToken ct = default) + { + var path = FilePath(id); + if (!File.Exists(path)) return null; + return await LoadFileAsync(path, ct); + } + + // Write + + public async Task SaveAsync(AdrEntry entry, CancellationToken ct = default) + { + await _lock.WaitAsync(ct); + try + { + Directory.CreateDirectory(_dir); + var json = JsonSerializer.Serialize(entry, JsonOpts); + await WriteAtomicAsync(FilePath(entry.Id), json, ct); + } + finally { _lock.Release(); } + } + + public async Task<bool> DeleteAsync(string id, CancellationToken ct = default) + { + await _lock.WaitAsync(ct); + try + { + var path = FilePath(id); + if (!File.Exists(path)) return false; + File.Delete(path); + return true; + } + finally { _lock.Release(); } + } + + /// <summary> + /// Moves the file for <paramref name="id"/> into the <c>archive/</c> subdirectory. + /// Archived entries are excluded from <see cref="LoadAllAsync"/> but remain queryable + /// via <see cref="LoadArchivedAsync"/>. + /// </summary> + public async Task<bool> ArchiveAsync(string id, CancellationToken ct = default) + { + await _lock.WaitAsync(ct); + try + { + var src = FilePath(id); + if (!File.Exists(src)) return false; + var archiveDir = Path.Combine(_dir, "archive"); + Directory.CreateDirectory(archiveDir); + var dst = Path.Combine(archiveDir, Path.GetFileName(src)); + File.Move(src, dst, overwrite: true); + return true; + } + finally { _lock.Release(); } + } + + /// <summary>Returns all archived ADR entries from the <c>archive/</c> subdirectory.</summary> + public async Task<List<AdrEntry>> LoadArchivedAsync(CancellationToken ct = default) + { + var archiveDir = Path.Combine(_dir, "archive"); + if (!Directory.Exists(archiveDir)) return []; + + var results = new List<AdrEntry>(); + foreach (var file in Directory.GetFiles(archiveDir, "ADR-*.json").OrderBy(f => f)) + { + var entry = await LoadFileAsync(file, ct); + if (entry is not null) results.Add(entry); + } + return results; + } + + // ID allocation + + /// <summary>Returns the next available ADR ID in the format <c>ADR-NNNN</c>.</summary> + public string NextId() + { + if (!Directory.Exists(_dir)) return "ADR-0001"; + + var max = Directory.GetFiles(_dir, "ADR-*.json") + .Select(f => Path.GetFileNameWithoutExtension(f)) + .Select(n => int.TryParse(n.Length > 4 ? n[4..] : "0", out var num) ? num : 0) + .DefaultIfEmpty(0) + .Max(); + + return $"ADR-{max + 1:D4}"; + } + + // Helpers + + private string FilePath(string id) => + Path.Combine(_dir, $"{id.ToUpperInvariant()}.json"); + + private static async Task<AdrEntry?> LoadFileAsync(string path, CancellationToken ct) + { + try + { + var json = await File.ReadAllTextAsync(path, ct); + return JsonSerializer.Deserialize<AdrEntry>(json, JsonOpts); + } + catch { return null; } + } + + private static async Task WriteAtomicAsync(string path, string content, CancellationToken ct) + { + var tmp = path + ".tmp"; + await File.WriteAllTextAsync(tmp, content, ct); + File.Move(tmp, path, overwrite: true); + } +} diff --git a/src/Infrastructure/ArchitectureScanner.cs b/src/Infrastructure/ArchitectureScanner.cs new file mode 100644 index 0000000..5a69acb --- /dev/null +++ b/src/Infrastructure/ArchitectureScanner.cs @@ -0,0 +1,140 @@ +using System.Text.RegularExpressions; +using fuseraft.Core.Models; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace fuseraft.Infrastructure; + +/// <summary> +/// Loads an <see cref="ArchitectureManifest"/> from YAML and scans source files for +/// layer violations: <c>using</c> directives that cross a disallowed layer boundary. +/// </summary> +public static class ArchitectureScanner +{ + private static readonly Regex UsingDirective = new( + @"^\s*using\s+([\w.]+)\s*;", + RegexOptions.Compiled); + + /// <summary> + /// Loads the manifest at <paramref name="manifestPath"/> and returns null if the file + /// does not exist or cannot be parsed. + /// </summary> + public static ArchitectureManifest? TryLoadManifest(string manifestPath) + { + if (!File.Exists(manifestPath)) return null; + + try + { + var yaml = File.ReadAllText(manifestPath); + var deserializer = new DeserializerBuilder() + .WithNamingConvention(PascalCaseNamingConvention.Instance) + .IgnoreUnmatchedProperties() + .Build(); + return deserializer.Deserialize<ArchitectureManifest>(yaml); + } + catch + { + return null; + } + } + + /// <summary> + /// Scans all <c>.cs</c> files under <paramref name="projectRoot"/> and returns every + /// <see cref="ArchitectureViolation"/> found relative to the given manifest. + /// </summary> + public static async Task<IReadOnlyList<ArchitectureViolation>> ScanAsync( + ArchitectureManifest manifest, + string projectRoot, + CancellationToken ct = default) + { + projectRoot = Path.GetFullPath(projectRoot); + + // Build effective namespace prefixes per layer; default to "fuseraft.<LayerName>". + var layerNamespaces = manifest.Layers.ToDictionary( + l => l.Name, + l => l.Namespaces.Count > 0 ? l.Namespaces : [$"fuseraft.{l.Name}"], + StringComparer.OrdinalIgnoreCase); + + var violations = new List<ArchitectureViolation>(); + + var files = Directory.EnumerateFiles(projectRoot, "*.cs", SearchOption.AllDirectories) + .Where(f => !IsGeneratedPath(f)); + + foreach (var file in files) + { + ct.ThrowIfCancellationRequested(); + + var relPath = Path.GetRelativePath(projectRoot, file).Replace('\\', '/'); + var sourceLayer = FindLayerForPath(manifest.Layers, relPath); + if (sourceLayer is null) continue; + + var lines = await File.ReadAllLinesAsync(file, ct); + + for (int i = 0; i < lines.Length; i++) + { + var match = UsingDirective.Match(lines[i]); + if (!match.Success) continue; + + var ns = match.Groups[1].Value; + var targetLayer = FindLayerForNamespace(layerNamespaces, ns); + if (targetLayer is null) continue; + if (string.Equals(targetLayer, sourceLayer.Name, StringComparison.OrdinalIgnoreCase)) continue; + + if (!sourceLayer.MayDependOn.Contains(targetLayer, StringComparer.OrdinalIgnoreCase)) + { + violations.Add(new ArchitectureViolation + { + SourceLayer = sourceLayer.Name, + TargetLayer = targetLayer, + File = relPath, + Line = i + 1, + Namespace = ns, + }); + } + } + } + + return violations; + } + + // Helpers + + private static bool IsGeneratedPath(string fullPath) + { + var sep = Path.DirectorySeparatorChar; + return fullPath.Contains($"{sep}obj{sep}", StringComparison.Ordinal) + || fullPath.Contains($"{sep}bin{sep}", StringComparison.Ordinal); + } + + private static ArchitectureLayer? FindLayerForPath( + IReadOnlyList<ArchitectureLayer> layers, + string relPath) + { + foreach (var layer in layers) + { + foreach (var p in layer.Paths) + { + var prefix = p.Replace('\\', '/').TrimEnd('/') + '/'; + if (relPath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + return layer; + } + } + return null; + } + + private static string? FindLayerForNamespace( + Dictionary<string, List<string>> layerNamespaces, + string ns) + { + foreach (var (layerName, prefixes) in layerNamespaces) + { + foreach (var prefix in prefixes) + { + if (ns.Equals(prefix, StringComparison.OrdinalIgnoreCase) + || ns.StartsWith(prefix + ".", StringComparison.OrdinalIgnoreCase)) + return layerName; + } + } + return null; + } +} diff --git a/src/Infrastructure/ConfidenceComputer.cs b/src/Infrastructure/ConfidenceComputer.cs new file mode 100644 index 0000000..7df680a --- /dev/null +++ b/src/Infrastructure/ConfidenceComputer.cs @@ -0,0 +1,71 @@ +using fuseraft.Core.Models; + +namespace fuseraft.Infrastructure; + +/// <summary> +/// Maps a support composition to a confidence status tier. +/// +/// <para>Tier rules (applied in order):</para> +/// <list type="bullet"> +/// <item><b>Verified</b> — two or more of: <c>TestResult</c>, <c>ExitCode</c>, <c>Validator</c>, <c>GitHistory</c></item> +/// <item><b>Inferred</b> — one hard evidence source (<c>ADR</c>, <c>RepositoryMemory</c>, or single <c>Validator</c> / <c>ExitCode</c> / <c>TestResult</c> / <c>GitHistory</c>)</item> +/// <item><b>Assumed</b> — <c>AgentAssertion</c> only, no corroborating hard evidence</item> +/// <item><b>Guessed</b> — no support at all</item> +/// </list> +/// </summary> +public static class ConfidenceComputer +{ + private static readonly HashSet<EvidenceClass> HardEvidence = + [ + EvidenceClass.TestResult, + EvidenceClass.ExitCode, + EvidenceClass.Validator, + EvidenceClass.GitHistory, + ]; + + /// <summary> + /// Applies time-based decay to a confidence status. When a <c>Verified</c> claim has + /// no explicit <c>ExpiresAt</c> and its <c>VerifiedAt</c> timestamp is older than + /// <paramref name="decayDays"/>, the status is downgraded to <c>Inferred</c>. + /// Claims with explicit <c>ExpiresAt</c> are governed by <see cref="ProvenanceRegistry.IsValidAsync"/>, + /// not by this method. + /// </summary> + public static string Decay( + string status, + DateTimeOffset? verifiedAt, + DateTimeOffset? expiresAt, + int decayDays) + { + if (decayDays <= 0) return status; + if (expiresAt.HasValue) return status; + if (verifiedAt is null) return status; + if (!status.Equals("Verified", StringComparison.OrdinalIgnoreCase)) return status; + + var age = DateTimeOffset.UtcNow - verifiedAt.Value; + return age.TotalDays > decayDays ? "Inferred" : status; + } + + /// <summary> + /// Computes the confidence status string from the supplied evidence classes. + /// The result matches the <see cref="ClaimRecord.Status"/> string values. + /// </summary> + public static string Compute(IReadOnlyList<EvidenceClass> support) + { + if (support.Count == 0) return "Guessed"; + + int hardCount = support.Count(e => HardEvidence.Contains(e)); + + if (hardCount >= 2) + return "Verified"; + + if (hardCount == 1 || + support.Any(e => e is EvidenceClass.ADR or EvidenceClass.RepositoryMemory)) + return "Inferred"; + + if (support.All(e => e == EvidenceClass.AgentAssertion)) + return "Assumed"; + + // Fallback: EvidenceGraph or any unrecognised class with no hard sources. + return "Inferred"; + } +} diff --git a/src/Infrastructure/KnowledgeLayer.cs b/src/Infrastructure/KnowledgeLayer.cs new file mode 100644 index 0000000..75beaac --- /dev/null +++ b/src/Infrastructure/KnowledgeLayer.cs @@ -0,0 +1,167 @@ +using fuseraft.Core; +using fuseraft.Core.Models; + +namespace fuseraft.Infrastructure; + +/// <summary> +/// Concrete knowledge layer backed by the ADR Registry (Gap 1) and Repository Semantic Graph (Gap 2). +/// +/// <para> +/// A single instance is created in <c>OrchestratorBuilder</c> and shared across all orchestrators, +/// context assemblers, and plugin instances within a session so every subsystem reads and writes +/// the same in-memory state. +/// </para> +/// +/// <para> +/// Later gaps extend this class: Gap 3 adds <see cref="RecordClaimAsync"/> via +/// <c>ProvenanceRegistry</c>; Gap 7 adds <see cref="RecordObjectiveAsync"/> via +/// <c>ObjectiveStore</c>. +/// </para> +/// </summary> +public sealed class KnowledgeLayer : IKnowledgeLayer +{ + private readonly AdrRegistry _adrRegistry; + private readonly RepositoryGraphStore _graphStore; + private readonly RepositoryGraphBuilder _graphBuilder; + private readonly ProvenanceRegistry _provenanceRegistry; + private readonly ObjectiveStore _objectiveStore; + + public KnowledgeLayer( + AdrRegistry adrRegistry, + RepositoryGraphStore graphStore, + RepositoryGraphBuilder graphBuilder, + ProvenanceRegistry? provenanceRegistry = null, + ObjectiveStore? objectiveStore = null) + { + _adrRegistry = adrRegistry; + _graphStore = graphStore; + _graphBuilder = graphBuilder; + _provenanceRegistry = provenanceRegistry + ?? new ProvenanceRegistry(fuseraft.Core.FuseraftPaths.LocalProvenance); + _objectiveStore = objectiveStore + ?? new ObjectiveStore(fuseraft.Core.FuseraftPaths.LocalObjectives); + } + + // ── Exposed subsystem accessors (for callers that need direct subsystem access) ── + + /// <summary>Direct access to the ADR registry for operations not expressible through <see cref="IKnowledgeLayer"/>.</summary> + public AdrRegistry AdrRegistry => _adrRegistry; + + /// <summary>Direct access to the repository graph store for traversal operations.</summary> + public RepositoryGraphStore GraphStore => _graphStore; + + /// <summary>Direct access to the graph builder for incremental rebuilds (e.g. from ChangeTracker).</summary> + public RepositoryGraphBuilder GraphBuilder => _graphBuilder; + + /// <summary>Direct access to the provenance registry for validators and context assembly.</summary> + public ProvenanceRegistry ProvenanceRegistry => _provenanceRegistry; + + // ── IKnowledgeLayer ──────────────────────────────────────────────────────────── + + /// <inheritdoc/> + public async Task<IEnumerable<KnowledgeResult>> SearchAsync( + string query, + IReadOnlyList<KnowledgeKind>? kinds = null, + CancellationToken ct = default) + { + var results = new List<KnowledgeResult>(); + bool includeDecisions = kinds is null || kinds.Contains(KnowledgeKind.Decision); + bool includeGraphNodes = kinds is null || kinds.Contains(KnowledgeKind.GraphNode); + + if (includeDecisions) + { + var adrs = await _adrRegistry.SearchAsync(query: query, ct: ct); + results.AddRange(adrs.Select(e => new KnowledgeResult + { + Id = $"adr:{e.Id}", + Kind = KnowledgeKind.Decision, + Title = e.Title, + Summary = e.Decision.Length > 200 ? e.Decision[..200] + "…" : e.Decision, + Status = e.Status, + Tags = e.Tags, + })); + } + + if (includeGraphNodes && !string.IsNullOrWhiteSpace(query)) + { + var graph = await _graphStore.LoadAsync(ct); + var q = query.Trim(); + var nodes = graph.Nodes + .Where(n => + (n.Name?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false) || + n.Id.Contains(q, StringComparison.OrdinalIgnoreCase)) + .Take(20); + + results.AddRange(nodes.Select(n => new KnowledgeResult + { + Id = n.Id, + Kind = KnowledgeKind.GraphNode, + Title = n.Name ?? n.Id, + FilePath = n.FilePath, + Status = n.Kind.ToString(), + })); + } + + return results; + } + + /// <inheritdoc/> + public async Task<KnowledgeArtifact?> RetrieveAsync(string id, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(id)) return null; + + // ADR IDs: "adr:ADR-0042" or bare "ADR-0042" + var adrId = id.StartsWith("adr:", StringComparison.OrdinalIgnoreCase) ? id[4..] : id; + if (adrId.StartsWith("ADR-", StringComparison.OrdinalIgnoreCase)) + { + var entry = await _adrRegistry.GetByIdAsync(adrId, ct); + if (entry is not null) + return new KnowledgeArtifact { Id = id, Kind = KnowledgeKind.Decision, Decision = entry }; + } + + // Graph node IDs: "type:Ns.Class", "method:Ns.Class.Method", "file:rel/path.cs", etc. + var graph = await _graphStore.LoadAsync(ct); + var node = graph.FindById(id); + if (node is not null) + return new KnowledgeArtifact { Id = id, Kind = KnowledgeKind.GraphNode, GraphNode = node }; + + return null; + } + + /// <inheritdoc/> + public async Task<AdrEntry> RecordDecisionAsync(AdrEntry entry, CancellationToken ct = default) + { + await _adrRegistry.SaveAsync(entry, ct); + if (entry.Governs.Count > 0) + await _graphBuilder.UpsertAdrNodeAsync(entry, ct); + return entry; + } + + /// <inheritdoc/> + public Task<ClaimRecord> RecordClaimAsync( + string claim, + IReadOnlyList<EvidenceClass> support, + string? artifactId = null, + DateTimeOffset? expiresAt = null, + CancellationToken ct = default) + { + var record = new ClaimRecord + { + Claim = claim, + Support = [..support], + ArtifactId = artifactId, + ExpiresAt = expiresAt, + }; + return _provenanceRegistry.RecordAsync(record, ct); + } + + /// <inheritdoc/> + public async Task<Objective> RecordObjectiveAsync(Objective objective, CancellationToken ct = default) + { + await _objectiveStore.SaveAsync(objective, ct); + return objective; + } + + /// <summary>Direct access to the objective store for queries not expressible through <see cref="IKnowledgeLayer"/>.</summary> + public ObjectiveStore ObjectiveStore => _objectiveStore; +} diff --git a/src/Infrastructure/KnowledgeLifecycleManager.cs b/src/Infrastructure/KnowledgeLifecycleManager.cs new file mode 100644 index 0000000..db2a4b2 --- /dev/null +++ b/src/Infrastructure/KnowledgeLifecycleManager.cs @@ -0,0 +1,227 @@ +using fuseraft.Core; +using fuseraft.Core.Models; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace fuseraft.Infrastructure; + +/// <summary> +/// Gap 9 — Knowledge Lifecycle Management. +/// +/// <para> +/// Implements time-based retention policies for every knowledge subsystem: +/// <list type="bullet"> +/// <item>Archives superseded ADRs to the decisions archive directory.</item> +/// <item>Demotes approved repository memories that have not been reinforced recently.</item> +/// <item>Decays old <c>Verified</c> provenance claims to <c>Inferred</c>.</item> +/// <item>Prunes orphaned repository graph nodes.</item> +/// <item>Compacts the provenance registry by archiving expired claims.</item> +/// </list> +/// </para> +/// +/// <para> +/// All operations are <b>dry-run by default</b>. Pass <c>apply: true</c> to commit +/// changes to disk. The returned <see cref="GcReport"/> describes every action that +/// was taken (or would be taken in dry-run mode). +/// </para> +/// </summary> +public sealed class KnowledgeLifecycleManager +{ + private readonly AdrStore _adrStore; + private readonly RepositoryMemoryStore _memoryStore; + private readonly RepositoryGraphStore _graphStore; + private readonly ProvenanceRegistry _provenance; + + public KnowledgeLifecycleManager( + AdrStore adrStore, + RepositoryMemoryStore memoryStore, + RepositoryGraphStore graphStore, + ProvenanceRegistry provenance) + { + _adrStore = adrStore; + _memoryStore = memoryStore; + _graphStore = graphStore; + _provenance = provenance; + } + + /// <summary> + /// Loads a <see cref="LifecyclePolicy"/> from <paramref name="path"/> (YAML). + /// Returns defaults when the file is absent or cannot be parsed. + /// </summary> + public static LifecyclePolicy LoadPolicy(string? path = null) + { + var file = path ?? FuseraftPaths.LocalLifecycleConfig; + if (!File.Exists(file)) return new LifecyclePolicy(); + try + { + var yaml = File.ReadAllText(file); + var des = new DeserializerBuilder() + .WithNamingConvention(PascalCaseNamingConvention.Instance) + .IgnoreUnmatchedProperties() + .Build(); + return des.Deserialize<LifecyclePolicy>(yaml) ?? new LifecyclePolicy(); + } + catch { return new LifecyclePolicy(); } + } + + /// <summary> + /// Runs all lifecycle policies and returns a <see cref="GcReport"/> describing + /// what was or would be changed. When <paramref name="apply"/> is <c>false</c>, + /// nothing is written to disk (dry-run). + /// </summary> + public async Task<GcReport> RunAsync( + LifecyclePolicy policy, + bool apply, + CancellationToken ct = default) + { + var archivedDecisions = await ArchiveSupersededAdrsAsync(policy, apply, ct); + var demotedMemories = await DemoteAgedMemoriesAsync(policy, apply, ct); + var decayedClaims = await DecayProvenanceAsync(policy, apply, ct); + var prunedNodes = await PruneOrphanedNodesAsync(policy, apply, ct); + var archivedProvenance = await CompactProvenanceAsync(policy, apply, ct); + + return new GcReport + { + ArchivedDecisionIds = archivedDecisions, + DemotedMemoryIds = demotedMemories, + DecayedClaimIds = decayedClaims, + PrunedNodeIds = prunedNodes, + ArchivedProvenanceIds = archivedProvenance, + }; + } + + // ── Step 1 — Archive superseded ADRs ───────────────────────────────────── + + private async Task<IReadOnlyList<string>> ArchiveSupersededAdrsAsync( + LifecyclePolicy policy, bool apply, CancellationToken ct) + { + var all = await _adrStore.LoadAllAsync(ct); + var cutoff = policy.AdrRetentionDays > 0 + ? DateTimeOffset.UtcNow.AddDays(-policy.AdrRetentionDays) + : DateTimeOffset.MaxValue; // 0 = archive any superseded ADR immediately + + var eligible = all + .Where(e => e.Status.Equals("Superseded", StringComparison.OrdinalIgnoreCase)) + .Where(e => + { + // When AdrRetentionDays = 0 all superseded ADRs are eligible. + if (policy.AdrRetentionDays == 0) return true; + // Otherwise, require the ADR's date to be older than the retention window. + // AdrEntry.Date is a string; parse best-effort; include when unparseable. + return !DateTimeOffset.TryParse(e.Date, out var d) || d < cutoff; + }) + .ToList(); + + if (!apply) return eligible.Select(e => e.Id).ToList(); + + var archived = new List<string>(); + foreach (var entry in eligible) + { + if (await _adrStore.ArchiveAsync(entry.Id, ct)) + archived.Add(entry.Id); + } + return archived; + } + + // ── Step 2 — Demote aged repository memories ───────────────────────────── + + private async Task<IReadOnlyList<string>> DemoteAgedMemoriesAsync( + LifecyclePolicy policy, bool apply, CancellationToken ct) + { + if (policy.MemoryReinforceWindowDays <= 0) return []; + + var cutoff = DateTimeOffset.UtcNow.AddDays(-policy.MemoryReinforceWindowDays); + var entries = await _memoryStore.LoadApprovedAsync(ct); + + var eligible = entries + .Where(e => e.LastReinforcedAt < cutoff) + .ToList(); + + if (!apply) return eligible.Select(e => e.Id).ToList(); + + var demoted = new List<string>(); + foreach (var entry in eligible) + { + await _memoryStore.SaveAsync(entry with { Status = "Candidate" }, ct); + demoted.Add(entry.Id); + } + return demoted; + } + + // ── Step 3 — Decay provenance confidence ───────────────────────────────── + + private async Task<IReadOnlyList<string>> DecayProvenanceAsync( + LifecyclePolicy policy, bool apply, CancellationToken ct) + { + if (policy.ConfidenceDecayDays <= 0) return []; + return await _provenance.DecayAsync(policy.ConfidenceDecayDays, apply, ct); + } + + // ── Step 4 — Prune orphaned graph nodes ────────────────────────────────── + + private async Task<IReadOnlyList<string>> PruneOrphanedNodesAsync( + LifecyclePolicy policy, bool apply, CancellationToken ct) + { + if (policy.OrphanedNodeGracePeriodDays <= 0) return []; + + var cutoff = DateTimeOffset.UtcNow.AddDays(-policy.OrphanedNodeGracePeriodDays); + var graph = await _graphStore.LoadAsync(ct); + + // Build set of all node IDs that appear in at least one edge. + var connected = new HashSet<string>(StringComparer.Ordinal); + foreach (var edge in graph.Edges) + { + connected.Add(edge.From); + connected.Add(edge.To); + } + + // Orphaned: no edges (from or to), not an ADR node (has its own archive path), + // and old enough to be past the grace period. + var orphans = graph.Nodes + .Where(n => n.Kind != NodeType.Adr + && n.Kind != NodeType.Violation + && !connected.Contains(n.Id) + && n.Timestamp < cutoff) + .Select(n => n.Id) + .ToList(); + + if (!apply || orphans.Count == 0) + return orphans; + + var orphanSet = new HashSet<string>(orphans, StringComparer.Ordinal); + graph.Nodes.RemoveAll(n => orphanSet.Contains(n.Id)); + await _graphStore.SaveAsync(graph, ct); + return orphans; + } + + // ── Step 5 — Compact provenance registry ───────────────────────────────── + + private async Task<IReadOnlyList<string>> CompactProvenanceAsync( + LifecyclePolicy policy, bool apply, CancellationToken ct) + { + bool ShouldArchive(ClaimRecord r) + { + // Always archive records whose ExpiresAt is in the past. + if (r.ExpiresAt.HasValue && r.ExpiresAt.Value < DateTimeOffset.UtcNow) + return true; + + // Additionally archive records older than MaxProvenanceAgeDays (when set). + if (policy.MaxProvenanceAgeDays > 0) + { + var cutoff = DateTimeOffset.UtcNow.AddDays(-policy.MaxProvenanceAgeDays); + var age = r.VerifiedAt ?? r.ObservedAt; + if (age < cutoff) return true; + } + + return false; + } + + var archived = await _provenance.CompactAsync( + ShouldArchive, + FuseraftPaths.LocalProvenanceArchive, + apply, + ct); + + return archived.Select(r => r.Id).ToList(); + } +} diff --git a/src/Infrastructure/KnowledgeSnapshotEnricher.cs b/src/Infrastructure/KnowledgeSnapshotEnricher.cs new file mode 100644 index 0000000..db7396f --- /dev/null +++ b/src/Infrastructure/KnowledgeSnapshotEnricher.cs @@ -0,0 +1,158 @@ +using fuseraft.Core.Models; + +namespace fuseraft.Infrastructure; + +/// <summary> +/// Enriches a <see cref="ContextSnapshot"/> with knowledge-layer state derived from +/// the ADR registry, objective manager, architecture scanner, repository memory store, +/// and provenance registry. +/// +/// <para> +/// Called by <see cref="fuseraft.Orchestration.ConversationCompactor"/> after +/// <c>IContextSnapshotter.SnapshotAsync</c> so that compaction summaries include +/// active ADRs, objective progress, architecture violations, approved repository +/// memories, and expired provenance warnings — all without modifying the +/// selection-strategy snapshot path. +/// </para> +/// +/// <para>All data sources are optional; missing ones are silently skipped.</para> +/// </summary> +public sealed class KnowledgeSnapshotEnricher +{ + private readonly AdrRegistry? _adrRegistry; + private readonly ObjectiveManager? _objectiveManager; + private readonly RepositoryMemoryStore? _memoryStore; + private readonly ProvenanceRegistry? _provenance; + private readonly string? _manifestPath; + private readonly string? _projectRoot; + + private const int MaxActiveAdrs = 10; + private const int MaxTopMemories = 5; + private const int MaxExpiredWarnings = 10; + private const int MaxViolations = 10; + + public KnowledgeSnapshotEnricher( + AdrRegistry? adrRegistry = null, + ObjectiveManager? objectiveManager = null, + RepositoryMemoryStore? memoryStore = null, + ProvenanceRegistry? provenance = null, + string? manifestPath = null, + string? projectRoot = null) + { + _adrRegistry = adrRegistry; + _objectiveManager = objectiveManager; + _memoryStore = memoryStore; + _provenance = provenance; + _manifestPath = manifestPath; + _projectRoot = projectRoot; + } + + /// <summary> + /// Returns a copy of <paramref name="snapshot"/> with the five knowledge-layer fields + /// populated from the configured subsystems. All enrichment is best-effort: + /// individual failures leave the corresponding field empty rather than throwing. + /// </summary> + public async Task<ContextSnapshot> EnrichAsync( + ContextSnapshot snapshot, + CancellationToken ct = default) + { + var activeAdrs = await LoadActiveAdrsAsync(ct); + var objectiveState = await LoadObjectiveStateAsync(ct); + var archViolations = await LoadArchViolationsAsync(ct); + var topMemories = await LoadTopMemoriesAsync(ct); + var expiredWarnings = await LoadExpiredWarningsAsync(ct); + + return snapshot with + { + ActiveAdrs = activeAdrs, + ObjectiveState = objectiveState, + ArchitectureViolations = archViolations, + TopRepositoryMemories = topMemories, + ExpiredProvenanceWarnings = expiredWarnings, + }; + } + + // ── Active ADRs ─────────────────────────────────────────────────────────── + + private async Task<IReadOnlyList<AdrSummary>> LoadActiveAdrsAsync(CancellationToken ct) + { + if (_adrRegistry is null) return []; + try + { + var adrs = await _adrRegistry.GetActiveAsync(ct); + return adrs + .Take(MaxActiveAdrs) + .Select(e => new AdrSummary(e.Id, e.Title, e.Status)) + .ToList(); + } + catch { return []; } + } + + // ── Objective state ─────────────────────────────────────────────────────── + + private async Task<string?> LoadObjectiveStateAsync(CancellationToken ct) + { + if (_objectiveManager is null) return null; + try { return await _objectiveManager.BuildActiveSummaryAsync(ct); } + catch { return null; } + } + + // ── Architecture violations ─────────────────────────────────────────────── + + private async Task<IReadOnlyList<string>> LoadArchViolationsAsync(CancellationToken ct) + { + if (_manifestPath is null || _projectRoot is null) return []; + try + { + var manifest = ArchitectureScanner.TryLoadManifest(_manifestPath); + if (manifest is null) return []; + + var violations = await ArchitectureScanner.ScanAsync(manifest, _projectRoot, ct); + return violations + .Take(MaxViolations) + .Select(v => $"{v.SourceLayer} → {v.TargetLayer}: {v.File} line {v.Line}") + .ToList(); + } + catch { return []; } + } + + // ── Top approved repository memories ───────────────────────────────────── + + private async Task<IReadOnlyList<string>> LoadTopMemoriesAsync(CancellationToken ct) + { + if (_memoryStore is null) return []; + try + { + var approved = await _memoryStore.LoadApprovedAsync(ct); + return approved + .OrderByDescending(m => m.ReinforcementCount) + .Take(MaxTopMemories) + .Select(m => m.Pattern.Length > 120 ? m.Pattern[..120] + "…" : m.Pattern) + .ToList(); + } + catch { return []; } + } + + // ── Expired provenance warnings ─────────────────────────────────────────── + + private async Task<IReadOnlyList<string>> LoadExpiredWarningsAsync(CancellationToken ct) + { + if (_provenance is null) return []; + try + { + var now = DateTimeOffset.UtcNow; + var all = await _provenance.GetAllAsync(ct); + return all + .Where(r => r.ExpiresAt.HasValue && r.ExpiresAt.Value < now) + .OrderBy(r => r.ExpiresAt!.Value) + .Take(MaxExpiredWarnings) + .Select(r => + { + var claim = r.Claim.Length > 80 ? r.Claim[..80] + "…" : r.Claim; + return $"'{claim}' expired {r.ExpiresAt!.Value:yyyy-MM-dd HH:mm} UTC"; + }) + .ToList(); + } + catch { return []; } + } +} diff --git a/src/Infrastructure/MemoryManager.cs b/src/Infrastructure/MemoryManager.cs index 438f188..2f578a6 100644 --- a/src/Infrastructure/MemoryManager.cs +++ b/src/Infrastructure/MemoryManager.cs @@ -15,6 +15,7 @@ public sealed class MemoryManager : IDisposable { private readonly IReadOnlyList<IMemoryProvider> _providers; private readonly ILogger<MemoryManager>? _logger; + private RepositoryMemoryStore? _repositoryStore; public MemoryManager(IReadOnlyList<IMemoryProvider> providers, ILogger<MemoryManager>? logger = null) { @@ -22,6 +23,14 @@ public MemoryManager(IReadOnlyList<IMemoryProvider> providers, ILogger<MemoryMan _logger = logger; } + /// <summary> + /// Attaches a <see cref="RepositoryMemoryStore"/> so that <c>Approved</c>, + /// high-confidence repository memories are injected into agent prompts via + /// <see cref="PreTurnAsync"/>. Call this after construction when the store is + /// available (e.g. from <c>OrchestratorBuilder</c>). + /// </summary> + public void AttachRepositoryMemory(RepositoryMemoryStore store) => _repositoryStore = store; + /// <summary> /// Builds a <see cref="MemoryManager"/> from orchestration config. /// Returns <see langword="null"/> when <paramref name="cfg"/> is null or the provider @@ -52,6 +61,8 @@ public MemoryManager(IReadOnlyList<IMemoryProvider> providers, ILogger<MemoryMan /// Called before each agent turn. /// Returns a memory block to prepend to the agent's system instructions, /// or <see langword="null"/> when no memory applies. + /// Includes <c>Approved</c>, high-confidence repository memories when a + /// <see cref="RepositoryMemoryStore"/> has been attached via <see cref="AttachRepositoryMemory"/>. /// </summary> public async Task<string?> PreTurnAsync(string agentName, CancellationToken ct = default) { @@ -72,6 +83,32 @@ public MemoryManager(IReadOnlyList<IMemoryProvider> providers, ILogger<MemoryMan } } + // Repository scope: inject Approved, high-confidence entries only. + if (_repositoryStore is not null) + { + try + { + var approved = await _repositoryStore.LoadApprovedAsync(ct); + var highConf = approved.Where(e => + e.Confidence.Equals("Verified", StringComparison.OrdinalIgnoreCase) || + e.Confidence.Equals("Inferred", StringComparison.OrdinalIgnoreCase)).ToList(); + + if (highConf.Count > 0) + { + var sb = new System.Text.StringBuilder(); + sb.AppendLine("REPOSITORY MEMORY — patterns observed across sessions:"); + foreach (var m in highConf.OrderByDescending(m => m.ReinforcementCount).Take(20)) + sb.AppendLine($" [{m.Confidence}] (×{m.ReinforcementCount}) {m.Pattern}"); + blocks.Add(sb.ToString().TrimEnd()); + } + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + _logger?.LogWarning(ex, "MemoryManager: repository memory load error."); + } + } + return blocks.Count == 0 ? null : string.Join("\n\n", blocks); } diff --git a/src/Infrastructure/ObjectiveManager.cs b/src/Infrastructure/ObjectiveManager.cs new file mode 100644 index 0000000..63029db --- /dev/null +++ b/src/Infrastructure/ObjectiveManager.cs @@ -0,0 +1,142 @@ +using fuseraft.Core.Models; + +namespace fuseraft.Infrastructure; + +/// <summary> +/// Coordinates creation, update, and progress queries for <see cref="Objective"/> records. +/// Delegates persistence to <see cref="ObjectiveStore"/>. +/// </summary> +public sealed class ObjectiveManager(ObjectiveStore store) +{ + public async Task<Objective> CreateAsync( + string title, + string description, + IEnumerable<string>? remainingTasks = null, + CancellationToken ct = default) + { + var id = store.NextId(); + var obj = new Objective + { + Id = id, + Title = title.Trim(), + Description = description.Trim(), + Status = "Active", + RemainingTasks = remainingTasks?.Select(t => t.Trim()).ToList() ?? [], + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow, + }; + await store.SaveAsync(obj, ct); + return obj; + } + + public Task<Objective?> GetAsync(string id, CancellationToken ct = default) + => store.GetAsync(id, ct); + + public Task<List<Objective>> ListAllAsync(CancellationToken ct = default) + => store.LoadAllAsync(ct); + + public Task<List<Objective>> ListActiveAsync(CancellationToken ct = default) + => store.LoadActiveAsync(ct); + + public async Task<Objective?> UpdateStatusAsync( + string id, string status, CancellationToken ct = default) + { + var obj = await store.GetAsync(id, ct); + if (obj is null) return null; + + obj = obj with { Status = status, UpdatedAt = DateTimeOffset.UtcNow }; + await store.SaveAsync(obj, ct); + return obj; + } + + public async Task<Objective?> UpdateAsync( + string id, + string? title = null, + string? description = null, + string? status = null, + CancellationToken ct = default) + { + var obj = await store.GetAsync(id, ct); + if (obj is null) return null; + + obj = obj with + { + Title = title ?? obj.Title, + Description = description ?? obj.Description, + Status = status ?? obj.Status, + UpdatedAt = DateTimeOffset.UtcNow, + }; + await store.SaveAsync(obj, ct); + return obj; + } + + /// <summary> + /// Moves <paramref name="task"/> to <c>CompletedTasks</c> (when <paramref name="completed"/> is true) + /// or adds it to <c>RemainingTasks</c> (when false). Removes it from the other list if present. + /// Also records <paramref name="sessionId"/> in <c>Sessions</c> when provided. + /// </summary> + public async Task<Objective?> LinkTaskAsync( + string id, + string task, + bool completed, + string? sessionId = null, + CancellationToken ct = default) + { + var obj = await store.GetAsync(id, ct); + if (obj is null) return null; + + var remaining = obj.RemainingTasks.Where(t => t != task).ToList(); + var done = obj.CompletedTasks.Where(t => t != task).ToList(); + var sessions = obj.Sessions.ToList(); + + if (completed) + done.Add(task); + else if (!remaining.Contains(task)) + remaining.Add(task); + + if (sessionId is not null && !sessions.Contains(sessionId)) + sessions.Add(sessionId); + + obj = obj with + { + CompletedTasks = done, + RemainingTasks = remaining, + Sessions = sessions, + UpdatedAt = DateTimeOffset.UtcNow, + }; + await store.SaveAsync(obj, ct); + return obj; + } + + /// <summary> + /// Builds a compact summary block of active objectives for injection into agent prompts. + /// Returns null when no active objectives exist. + /// </summary> + public async Task<string?> BuildActiveSummaryAsync(CancellationToken ct = default) + { + var active = await store.LoadActiveAsync(ct); + if (active.Count == 0) return null; + + var sb = new System.Text.StringBuilder(); + sb.AppendLine("## Active Objectives"); + foreach (var o in active) + { + var pct = o.PercentComplete; + sb.Append($"[{o.Id}] {o.Title}"); + if (o.CompletedTasks.Count + o.RemainingTasks.Count > 0) + sb.Append($" — {pct:F0}% complete ({o.CompletedTasks.Count}/{o.CompletedTasks.Count + o.RemainingTasks.Count} tasks)"); + sb.AppendLine(); + if (!string.IsNullOrWhiteSpace(o.Description)) + sb.AppendLine($" {o.Description.Trim()}"); + if (o.RemainingTasks.Count > 0) + { + sb.AppendLine(" Remaining:"); + foreach (var t in o.RemainingTasks.Take(5)) + sb.AppendLine($" - {t}"); + if (o.RemainingTasks.Count > 5) + sb.AppendLine($" … and {o.RemainingTasks.Count - 5} more"); + } + } + return sb.ToString().TrimEnd(); + } +} diff --git a/src/Infrastructure/ObjectiveStore.cs b/src/Infrastructure/ObjectiveStore.cs new file mode 100644 index 0000000..4b9086c --- /dev/null +++ b/src/Infrastructure/ObjectiveStore.cs @@ -0,0 +1,106 @@ +using fuseraft.Core.Models; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace fuseraft.Infrastructure; + +/// <summary> +/// File-backed store for <see cref="Objective"/> records persisted as YAML under +/// <c>.fuseraft/knowledge/objectives/</c>. Each objective is one file named +/// <c>OBJ-NNNN.yaml</c>. Writes are atomic (write-to-temp then rename). +/// </summary> +public sealed class ObjectiveStore +{ + private readonly string _dir; + private readonly SemaphoreSlim _lock = new(1, 1); + + private static readonly ISerializer Serializer = new SerializerBuilder() + .WithNamingConvention(PascalCaseNamingConvention.Instance) + .DisableAliases() + .Build(); + + private static readonly IDeserializer Deserializer = new DeserializerBuilder() + .WithNamingConvention(PascalCaseNamingConvention.Instance) + .IgnoreUnmatchedProperties() + .Build(); + + public ObjectiveStore(string directory) => _dir = Path.GetFullPath(directory); + + // ── Read ──────────────────────────────────────────────────────────────── + + public async Task<List<Objective>> LoadAllAsync(CancellationToken ct = default) + { + if (!Directory.Exists(_dir)) return []; + + var results = new List<Objective>(); + foreach (var file in Directory.GetFiles(_dir, "OBJ-*.yaml").OrderBy(f => f)) + { + ct.ThrowIfCancellationRequested(); + var obj = await LoadFileAsync(file, ct); + if (obj is not null) results.Add(obj); + } + return results; + } + + public async Task<Objective?> GetAsync(string id, CancellationToken ct = default) + { + var path = FilePath(id); + return File.Exists(path) ? await LoadFileAsync(path, ct) : null; + } + + public async Task<List<Objective>> LoadActiveAsync(CancellationToken ct = default) + { + var all = await LoadAllAsync(ct); + return all.Where(o => o.Status.Equals("Active", StringComparison.OrdinalIgnoreCase)).ToList(); + } + + // ── Write ──────────────────────────────────────────────────────────────── + + public async Task SaveAsync(Objective obj, CancellationToken ct = default) + { + await _lock.WaitAsync(ct); + try + { + Directory.CreateDirectory(_dir); + var yaml = Serializer.Serialize(obj); + await WriteAtomicAsync(FilePath(obj.Id), yaml, ct); + } + finally { _lock.Release(); } + } + + // ── ID allocation ──────────────────────────────────────────────────────── + + public string NextId() + { + if (!Directory.Exists(_dir)) return "OBJ-0001"; + + var max = Directory.GetFiles(_dir, "OBJ-*.yaml") + .Select(f => Path.GetFileNameWithoutExtension(f)) + .Select(n => int.TryParse(n.Length > 4 ? n[4..] : "0", out var num) ? num : 0) + .DefaultIfEmpty(0) + .Max(); + + return $"OBJ-{max + 1:D4}"; + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private string FilePath(string id) => Path.Combine(_dir, $"{id.ToUpperInvariant()}.yaml"); + + private async Task<Objective?> LoadFileAsync(string path, CancellationToken ct) + { + try + { + var yaml = await File.ReadAllTextAsync(path, ct); + return Deserializer.Deserialize<Objective>(yaml); + } + catch { return null; } + } + + private static async Task WriteAtomicAsync(string path, string content, CancellationToken ct) + { + var tmp = path + ".tmp"; + await File.WriteAllTextAsync(tmp, content, ct); + File.Move(tmp, path, overwrite: true); + } +} diff --git a/src/Infrastructure/Plugins/DecisionPlugin.cs b/src/Infrastructure/Plugins/DecisionPlugin.cs new file mode 100644 index 0000000..4d1b4fc --- /dev/null +++ b/src/Infrastructure/Plugins/DecisionPlugin.cs @@ -0,0 +1,192 @@ +using System.ComponentModel; +using System.Text; +using fuseraft.Core; +using fuseraft.Core.Models; +using fuseraft.Infrastructure; + +namespace fuseraft.Infrastructure.Plugins; + +/// <summary> +/// Agent-facing tools for the Architecture Decision Registry. +/// +/// Tool names (via <c>decision_</c> prefix): +/// decision_search — keyword + status/tag filter across all ADRs +/// decision_read — fetch a single ADR by ID +/// decision_create — record a new architecture decision +/// decision_supersede — mark an existing ADR as superseded +/// </summary> +public sealed class DecisionPlugin +{ + private readonly AdrRegistry _registry; + private readonly IKnowledgeLayer? _knowledgeLayer; + + public DecisionPlugin(AdrRegistry registry, IKnowledgeLayer? knowledgeLayer = null) + { + _registry = registry; + _knowledgeLayer = knowledgeLayer; + } + + [Description("Search architecture decision records by keyword, status, or tag.")] + public async Task<string> SearchAsync( + [Description("Keyword to match against title, context, decision text, and tags. Leave empty to list all.")] + string query = "", + [Description("Filter by status: Proposed, Accepted, Deprecated, or Superseded.")] + string? status = null, + [Description("Filter by tag.")] + string? tag = null) + { + var results = await _registry.SearchAsync(query, status, tag); + if (results.Count == 0) return PluginResult.NotFound("No matching decisions found."); + + var sb = new StringBuilder(); + sb.AppendLine($"=== Decisions ({results.Count} result(s)) ==="); + foreach (var e in results) + { + sb.AppendLine(); + sb.Append(FormatSummary(e)); + } + return sb.ToString().TrimEnd(); + } + + [Description("Read an architecture decision record by ID.")] + public async Task<string> ReadAsync( + [Description("Decision ID, e.g. ADR-0042.")] + string id) + { + if (string.IsNullOrWhiteSpace(id)) + return PluginResult.Error("id must not be empty."); + + var entry = await _registry.GetByIdAsync(id.Trim()); + return entry is null + ? PluginResult.NotFound($"No decision with ID '{id}'.") + : FormatFull(entry); + } + + [Description("Record a new architecture decision.")] + public async Task<string> CreateAsync( + [Description("Short descriptive title.")] + string title, + [Description("Why this decision was needed — background and forces at play.")] + string context, + [Description("The decision that was made.")] + string decision, + [Description("Comma-separated alternatives that were considered and rejected.")] + string? alternatives = null, + [Description("Comma-separated consequences of this decision (positive and negative).")] + string? consequences = null, + [Description("Comma-separated tags for categorization (e.g. persistence,security).")] + string? tags = null, + [Description("Comma-separated IDs of earlier decisions this supersedes (e.g. ADR-0017,ADR-0021).")] + string? supersedes = null, + [Description("Comma-separated file paths or symbol IDs this decision governs (e.g. src/Auth.cs,type:fuseraft.Auth.TokenManager).")] + string? governs = null) + { + if (string.IsNullOrWhiteSpace(title)) return PluginResult.Error("title must not be empty."); + if (string.IsNullOrWhiteSpace(context)) return PluginResult.Error("context must not be empty."); + if (string.IsNullOrWhiteSpace(decision)) return PluginResult.Error("decision must not be empty."); + + var id = _registry.NextId(); + var entry = new AdrEntry + { + Id = id, + Title = title.Trim(), + Status = "Accepted", + Date = DateOnly.FromDateTime(DateTime.UtcNow).ToString("yyyy-MM-dd"), + Context = context.Trim(), + Decision = decision.Trim(), + Alternatives = SplitCsv(alternatives), + Consequences = SplitCsv(consequences), + Tags = SplitCsv(tags), + Supersedes = SplitCsv(supersedes), + Governs = SplitCsv(governs), + }; + + // Route through IKnowledgeLayer when available — it handles both the ADR store + // write and the graph node upsert so the ADR subsystem doesn't directly call + // into the graph subsystem. + if (_knowledgeLayer is not null) + await _knowledgeLayer.RecordDecisionAsync(entry); + else + await _registry.SaveAsync(entry); + + foreach (var supersededId in entry.Supersedes) + { + var old = await _registry.GetByIdAsync(supersededId.Trim()); + if (old is not null && !old.Status.Equals("Superseded", StringComparison.OrdinalIgnoreCase)) + await _registry.SaveAsync(old with { Status = "Superseded" }); + } + + return PluginResult.Ok($"Created {id}: {entry.Title}"); + } + + [Description("Mark an architecture decision record as superseded.")] + public async Task<string> SupersedeAsync( + [Description("ID of the decision to supersede, e.g. ADR-0017.")] + string id, + [Description("ID of the newer decision that replaces it, e.g. ADR-0042.")] + string newId) + { + if (string.IsNullOrWhiteSpace(id)) return PluginResult.Error("id must not be empty."); + if (string.IsNullOrWhiteSpace(newId)) return PluginResult.Error("newId must not be empty."); + + var entry = await _registry.GetByIdAsync(id.Trim()); + if (entry is null) return PluginResult.NotFound($"No decision with ID '{id}'."); + + if (entry.Status.Equals("Superseded", StringComparison.OrdinalIgnoreCase)) + return PluginResult.Info($"{id} is already marked as Superseded."); + + await _registry.SaveAsync(entry with { Status = "Superseded" }); + return PluginResult.Ok($"{id} marked as Superseded (replaced by {newId.Trim()})."); + } + + // Formatting + + private static string FormatSummary(AdrEntry e) + { + var sb = new StringBuilder(); + sb.Append($"[{e.Id}] {e.Title}"); + sb.Append($" status: {e.Status}"); + sb.Append($" date: {e.Date}"); + if (e.Tags.Count > 0) sb.Append($" tags: {string.Join(", ", e.Tags)}"); + if (e.Supersedes.Count > 0) sb.Append($" supersedes: {string.Join(", ", e.Supersedes)}"); + return sb.ToString(); + } + + private static string FormatFull(AdrEntry e) + { + var sb = new StringBuilder(); + sb.AppendLine($"Id: {e.Id}"); + sb.AppendLine($"Title: {e.Title}"); + sb.AppendLine($"Status: {e.Status}"); + sb.AppendLine($"Date: {e.Date}"); + if (e.Tags.Count > 0) sb.AppendLine($"Tags: {string.Join(", ", e.Tags)}"); + if (e.Supersedes.Count > 0) sb.AppendLine($"Supersedes: {string.Join(", ", e.Supersedes)}"); + sb.AppendLine(); + sb.AppendLine("Context:"); + sb.AppendLine(Indent(e.Context)); + sb.AppendLine(); + sb.AppendLine("Decision:"); + sb.AppendLine(Indent(e.Decision)); + if (e.Alternatives.Count > 0) + { + sb.AppendLine(); + sb.AppendLine("Alternatives:"); + foreach (var a in e.Alternatives) sb.AppendLine($" - {a}"); + } + if (e.Consequences.Count > 0) + { + sb.AppendLine(); + sb.AppendLine("Consequences:"); + foreach (var c in e.Consequences) sb.AppendLine($" - {c}"); + } + return sb.ToString().TrimEnd(); + } + + private static string Indent(string text) => + string.Join("\n", text.Split('\n').Select(l => $" {l}")); + + private static List<string> SplitCsv(string? value) => + string.IsNullOrWhiteSpace(value) + ? [] + : [.. value.Split(',').Select(s => s.Trim()).Where(s => s.Length > 0)]; +} diff --git a/src/Infrastructure/Plugins/GraphPlugin.cs b/src/Infrastructure/Plugins/GraphPlugin.cs new file mode 100644 index 0000000..9d84322 --- /dev/null +++ b/src/Infrastructure/Plugins/GraphPlugin.cs @@ -0,0 +1,141 @@ +using System.ComponentModel; +using System.Text; +using fuseraft.Core.Models; + +namespace fuseraft.Infrastructure.Plugins; + +/// <summary> +/// Agent-facing tools for the repository semantic graph. +/// +/// Tool names (via <c>graph_</c> prefix): +/// graph_search — find nodes by name or type +/// graph_refs — what references a given symbol (inbound references edges) +/// graph_dependents — transitive dependents of a symbol (inbound depends_on edges) +/// </summary> +public sealed class GraphPlugin +{ + private readonly RepositoryGraphStore _store; + + public GraphPlugin(RepositoryGraphStore store) => _store = store; + + [Description("Search the repository graph for nodes by name, type, or file path.")] + public async Task<string> SearchAsync( + [Description("Partial name to match against node names. Leave empty to list all.")] + string query = "", + [Description("Node kind to filter by: File, Namespace, Type, Interface, Method, Property, Field, or Adr.")] + string? kind = null, + [Description("Relative file path to restrict results to a single file.")] + string? file = null) + { + var graph = await _store.LoadAsync(); + + NodeType? kindFilter = null; + if (kind is not null && Enum.TryParse<NodeType>(kind, ignoreCase: true, out var parsed)) + kindFilter = parsed; + + var results = graph.Nodes.AsEnumerable(); + if (kindFilter.HasValue) + results = results.Where(n => n.Kind == kindFilter.Value); + if (file is not null) + results = results.Where(n => n.FilePath is not null && + n.FilePath.Contains(file, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrWhiteSpace(query)) + results = results.Where(n => + (n.Name?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false) || + (n.Id.Contains(query, StringComparison.OrdinalIgnoreCase))); + + var list = results.Take(50).ToList(); + if (list.Count == 0) return PluginResult.NotFound("No matching graph nodes found."); + + var sb = new StringBuilder(); + sb.AppendLine($"=== Graph nodes ({list.Count} result(s)) ==="); + foreach (var n in list) + { + sb.Append($" [{n.Kind}] {n.Id}"); + if (n.FilePath is not null) sb.Append($" file: {n.FilePath}"); + if (n.StartLine.HasValue) sb.Append($":{n.StartLine}"); + sb.AppendLine(); + } + return sb.ToString().TrimEnd(); + } + + [Description("Find all graph nodes that reference the given symbol ID.")] + public async Task<string> RefsAsync( + [Description("SymbolId of the target node (e.g. type:fuseraft.Core.Models.AdrEntry).")] + string symbolId) + { + if (string.IsNullOrWhiteSpace(symbolId)) + return PluginResult.Error("symbolId must not be empty."); + + var graph = await _store.LoadAsync(); + var edges = graph.EdgesTo(symbolId, EdgeType.References) + .Concat(graph.EdgesTo(symbolId, EdgeType.Implements)) + .Concat(graph.EdgesTo(symbolId, EdgeType.Inherits)) + .ToList(); + + if (edges.Count == 0) + return PluginResult.NotFound($"No references found for '{symbolId}'."); + + var sb = new StringBuilder(); + sb.AppendLine($"=== References to {symbolId} ({edges.Count}) ==="); + foreach (var e in edges) + { + var fromNode = graph.FindById(e.From); + sb.AppendLine($" [{e.Relation}] {e.From}" + + (fromNode?.FilePath is not null ? $" ({fromNode.FilePath}:{fromNode.StartLine})" : "")); + } + return sb.ToString().TrimEnd(); + } + + [Description("Find transitive dependents of a symbol — nodes that depend_on or reference it directly or indirectly.")] + public async Task<string> DependentsAsync( + [Description("SymbolId of the root node (e.g. type:fuseraft.Core.Models.AdrEntry).")] + string symbolId, + [Description("Maximum traversal depth. Defaults to 3.")] + int depth = 3) + { + if (string.IsNullOrWhiteSpace(symbolId)) + return PluginResult.Error("symbolId must not be empty."); + + var graph = await _store.LoadAsync(); + if (depth < 1) depth = 1; + if (depth > 10) depth = 10; + + var visited = new HashSet<string>(StringComparer.Ordinal) { symbolId }; + var frontier = new HashSet<string>(StringComparer.Ordinal) { symbolId }; + var results = new List<(string From, string Relation, int Level)>(); + + for (int d = 1; d <= depth && frontier.Count > 0; d++) + { + var next = new HashSet<string>(StringComparer.Ordinal); + foreach (var id in frontier) + { + var inbound = graph.EdgesTo(id, EdgeType.DependsOn) + .Concat(graph.EdgesTo(id, EdgeType.References)) + .Concat(graph.EdgesTo(id, EdgeType.Implements)) + .Concat(graph.EdgesTo(id, EdgeType.Inherits)); + + foreach (var e in inbound) + { + if (!visited.Add(e.From)) continue; + results.Add((e.From, e.Relation, d)); + next.Add(e.From); + } + } + frontier = next; + } + + if (results.Count == 0) + return PluginResult.NotFound($"No dependents found for '{symbolId}'."); + + var sb = new StringBuilder(); + sb.AppendLine($"=== Dependents of {symbolId} (depth {depth}) ==="); + foreach (var (from, rel, level) in results) + { + var node = graph.FindById(from); + sb.AppendLine($" [depth={level}] [{rel}] {from}" + + (node?.FilePath is not null ? $" ({node.FilePath}:{node.StartLine})" : "")); + } + return sb.ToString().TrimEnd(); + } +} diff --git a/src/Infrastructure/Plugins/ObjectivePlugin.cs b/src/Infrastructure/Plugins/ObjectivePlugin.cs new file mode 100644 index 0000000..766a75d --- /dev/null +++ b/src/Infrastructure/Plugins/ObjectivePlugin.cs @@ -0,0 +1,163 @@ +using System.ComponentModel; +using System.Text; +using fuseraft.Infrastructure; + +namespace fuseraft.Infrastructure.Plugins; + +/// <summary> +/// Agent-facing tools for long-horizon objective tracking. +/// +/// Tool names (via <c>objective_</c> prefix): +/// objective_create — record a new objective +/// objective_read — fetch a single objective by ID +/// objective_update — update title, description, or status +/// objective_list — list all objectives (optionally filtered by status) +/// objective_link_task — add or complete a task linked to an objective +/// </summary> +public sealed class ObjectivePlugin +{ + private readonly ObjectiveManager _manager; + + public ObjectivePlugin(ObjectiveManager manager) => _manager = manager; + + [Description("Create a new long-horizon objective.")] + public async Task<string> CreateAsync( + [Description("Short descriptive title for the objective.")] + string title, + [Description("What this objective achieves and why it matters.")] + string description = "", + [Description("Comma-separated list of remaining tasks (optional).")] + string? tasks = null) + { + if (string.IsNullOrWhiteSpace(title)) + return PluginResult.Error("title must not be empty."); + + var remaining = string.IsNullOrWhiteSpace(tasks) + ? null + : tasks.Split(',').Select(t => t.Trim()).Where(t => t.Length > 0); + + var obj = await _manager.CreateAsync(title, description, remaining); + return PluginResult.Ok($"Created {obj.Id}: {obj.Title}"); + } + + [Description("Read a long-horizon objective by ID.")] + public async Task<string> ReadAsync( + [Description("Objective ID, e.g. OBJ-0001.")] + string id) + { + if (string.IsNullOrWhiteSpace(id)) + return PluginResult.Error("id must not be empty."); + + var obj = await _manager.GetAsync(id.Trim()); + return obj is null + ? PluginResult.NotFound($"No objective with ID '{id}'.") + : FormatFull(obj); + } + + [Description("Update an objective's title, description, or status.")] + public async Task<string> UpdateAsync( + [Description("Objective ID to update.")] + string id, + [Description("New title (leave empty to keep current).")] + string? title = null, + [Description("New description (leave empty to keep current).")] + string? description = null, + [Description("New status: Active, Paused, Completed, or Abandoned.")] + string? status = null) + { + if (string.IsNullOrWhiteSpace(id)) + return PluginResult.Error("id must not be empty."); + + var obj = await _manager.UpdateAsync( + id.Trim(), + string.IsNullOrWhiteSpace(title) ? null : title.Trim(), + string.IsNullOrWhiteSpace(description) ? null : description.Trim(), + string.IsNullOrWhiteSpace(status) ? null : status.Trim()); + + return obj is null + ? PluginResult.NotFound($"No objective with ID '{id}'.") + : PluginResult.Ok($"Updated {obj.Id}: {obj.Title} (status: {obj.Status})"); + } + + [Description("List objectives, optionally filtered by status.")] + public async Task<string> ListAsync( + [Description("Filter by status: Active, Paused, Completed, Abandoned. Leave empty for all.")] + string? status = null) + { + var all = await _manager.ListAllAsync(); + var filtered = string.IsNullOrWhiteSpace(status) + ? all + : all.Where(o => o.Status.Equals(status.Trim(), StringComparison.OrdinalIgnoreCase)).ToList(); + + if (filtered.Count == 0) + return PluginResult.NotFound("No matching objectives found."); + + var sb = new StringBuilder(); + sb.AppendLine($"=== Objectives ({filtered.Count} result(s)) ==="); + foreach (var o in filtered) + { + sb.AppendLine(); + var pct = o.CompletedTasks.Count + o.RemainingTasks.Count > 0 + ? $" — {o.PercentComplete:F0}%" + : string.Empty; + sb.AppendLine($"[{o.Id}] {o.Title} ({o.Status}{pct})"); + if (!string.IsNullOrWhiteSpace(o.Description)) + sb.AppendLine($" {o.Description.Trim()}"); + } + return sb.ToString().TrimEnd(); + } + + [Description("Mark a task as completed or add a pending task to an objective.")] + public async Task<string> LinkTaskAsync( + [Description("Objective ID, e.g. OBJ-0001.")] + string id, + [Description("Short task description.")] + string task, + [Description("True if the task is now completed; false to add it as a remaining task.")] + bool completed = true, + [Description("Current session ID to record (optional).")] + string? sessionId = null) + { + if (string.IsNullOrWhiteSpace(id)) return PluginResult.Error("id must not be empty."); + if (string.IsNullOrWhiteSpace(task)) return PluginResult.Error("task must not be empty."); + + var obj = await _manager.LinkTaskAsync(id.Trim(), task.Trim(), completed, sessionId?.Trim()); + if (obj is null) return PluginResult.NotFound($"No objective with ID '{id}'."); + + var verb = completed ? "Completed" : "Added"; + return PluginResult.Ok($"{verb} task on {obj.Id} — progress: {obj.PercentComplete:F0}% ({obj.CompletedTasks.Count}/{obj.CompletedTasks.Count + obj.RemainingTasks.Count})"); + } + + // ── Formatting ─────────────────────────────────────────────────────────── + + private static string FormatFull(fuseraft.Core.Models.Objective o) + { + var sb = new StringBuilder(); + sb.AppendLine($"Id: {o.Id}"); + sb.AppendLine($"Title: {o.Title}"); + sb.AppendLine($"Status: {o.Status}"); + if (!string.IsNullOrWhiteSpace(o.Description)) + sb.AppendLine($"Description: {o.Description}"); + + var total = o.CompletedTasks.Count + o.RemainingTasks.Count; + if (total > 0) + sb.AppendLine($"Progress: {o.PercentComplete:F0}% ({o.CompletedTasks.Count}/{total} tasks)"); + + if (o.CompletedTasks.Count > 0) + { + sb.AppendLine("Completed Tasks:"); + foreach (var t in o.CompletedTasks) sb.AppendLine($" ✓ {t}"); + } + if (o.RemainingTasks.Count > 0) + { + sb.AppendLine("Remaining Tasks:"); + foreach (var t in o.RemainingTasks) sb.AppendLine($" • {t}"); + } + if (o.Sessions.Count > 0) + sb.AppendLine($"Sessions: {string.Join(", ", o.Sessions)}"); + + sb.AppendLine($"Created: {o.CreatedAt:yyyy-MM-dd}"); + sb.AppendLine($"Updated: {o.UpdatedAt:yyyy-MM-dd}"); + return sb.ToString().TrimEnd(); + } +} diff --git a/src/Infrastructure/Plugins/PluginCapabilityMap.cs b/src/Infrastructure/Plugins/PluginCapabilityMap.cs index 65c62de..bdae02f 100644 --- a/src/Infrastructure/Plugins/PluginCapabilityMap.cs +++ b/src/Infrastructure/Plugins/PluginCapabilityMap.cs @@ -135,6 +135,17 @@ internal static class PluginCapabilityMap ["probe_compare_outputs"] = "run", ["probe_run_hypothesis"] = "run", + // Decision (ADR Registry) + ["decision_search"] = "read", + ["decision_read"] = "read", + ["decision_create"] = "write", + ["decision_supersede"] = "write", + + // Graph (repository semantic graph — all tools are read-only) + ["graph_search"] = "read", + ["graph_refs"] = "read", + ["graph_dependents"] = "read", + // CodeExecution ["code_execution_check_docker"] = "read", ["code_execution_sandbox_run"] = "execute", diff --git a/src/Infrastructure/Plugins/PluginRegistry.cs b/src/Infrastructure/Plugins/PluginRegistry.cs index ce0c6c6..1a24b9d 100644 --- a/src/Infrastructure/Plugins/PluginRegistry.cs +++ b/src/Infrastructure/Plugins/PluginRegistry.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging; using fuseraft.Core; using fuseraft.Core.Models; +using fuseraft.Infrastructure; namespace fuseraft.Infrastructure.Plugins; @@ -91,6 +92,18 @@ public PluginRegistry RegisterDefaults() Register("Compaction", () => new CompactionPlugin()); + // Stub registrations for introspection (fuseraft plugins). OrchestratorBuilder + // calls ConfigureKnowledge() to replace these with a shared-instance version. + var graphStoreForDecision = new RepositoryGraphStore(FuseraftPaths.LocalRepositoryGraph); + Register("Decision", () => new DecisionPlugin( + new AdrRegistry(new AdrStore(FuseraftPaths.LocalDecisions)), + knowledgeLayer: null)); + + Register("Graph", () => new GraphPlugin(graphStoreForDecision)); + + Register("Objective", () => new ObjectivePlugin( + new ObjectiveManager(new ObjectiveStore(FuseraftPaths.LocalObjectives)))); + // Stub — OrchestratorBuilder replaces this with a session-scoped instance. Register("SessionContext", () => new SessionContextPlugin( Path.Combine(Directory.GetCurrentDirectory(), ".fuseraft", "state", "sessions", "default", "context_summary.md"))); @@ -100,6 +113,21 @@ public PluginRegistry RegisterDefaults() return this; } + /// <summary> + /// Re-registers the knowledge plugins (Decision, Graph) using the shared + /// <see cref="IKnowledgeLayer"/> instance created by <c>OrchestratorBuilder</c>. + /// Call this after the knowledge layer is created so all agents in the session share + /// the same underlying stores rather than the stub instances from <see cref="RegisterDefaults"/>. + /// </summary> + public PluginRegistry ConfigureKnowledge(IKnowledgeLayer knowledgeLayer) + { + var layer = (KnowledgeLayer)knowledgeLayer; + Register("Decision", () => new DecisionPlugin(layer.AdrRegistry, knowledgeLayer)); + Register("Graph", () => new GraphPlugin(layer.GraphStore)); + Register("Objective", () => new ObjectivePlugin(new ObjectiveManager(layer.ObjectiveStore))); + return this; + } + /// <summary> /// Re-registers the security-sensitive plugins (FileSystem, Shell, Http) using the /// constraints from <paramref name="security"/> and optional named API profiles. diff --git a/src/Infrastructure/ProvenanceRegistry.cs b/src/Infrastructure/ProvenanceRegistry.cs new file mode 100644 index 0000000..c0758ae --- /dev/null +++ b/src/Infrastructure/ProvenanceRegistry.cs @@ -0,0 +1,232 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using fuseraft.Core.Models; + +namespace fuseraft.Infrastructure; + +/// <summary> +/// Stores <see cref="ClaimRecord"/> entries keyed by artifact or evidence-graph node ID, +/// persisted to <c>.fuseraft/state/provenance.json</c>. +/// +/// <para> +/// Records are appended and never mutated in place — each call to <see cref="RecordAsync"/> +/// adds or replaces the claim for a given <see cref="ClaimRecord.Id"/>. Validators call +/// <see cref="RecordAsync"/> when they produce a passing result; downstream agents and the +/// Context Broker (Gap 8) query the registry to determine whether ground-truth evidence +/// supports a given artifact. +/// </para> +/// +/// <para> +/// Expiry is checked by <see cref="IsValidAsync"/>: a claim is invalid when its +/// <see cref="ClaimRecord.ExpiresAt"/> is set and is in the past. +/// </para> +/// </summary> +public sealed class ProvenanceRegistry +{ + private readonly string _path; + private readonly SemaphoreSlim _lock = new(1, 1); + + private static readonly JsonSerializerOptions JsonOpts = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter() }, + }; + + public ProvenanceRegistry(string path) => _path = path; + + // ── Write ─────────────────────────────────────────────────────────────── + + /// <summary> + /// Persists a <see cref="ClaimRecord"/>, replacing any existing record with the same + /// <see cref="ClaimRecord.Id"/>. The computed <see cref="ClaimRecord.Status"/> is set + /// from the support composition before saving. + /// </summary> + public async Task<ClaimRecord> RecordAsync(ClaimRecord record, CancellationToken ct = default) + { + var computed = record with + { + Status = ConfidenceComputer.Compute(record.Support), + VerifiedAt = record.Support.Count > 0 ? DateTimeOffset.UtcNow : record.VerifiedAt, + }; + + await _lock.WaitAsync(ct); + try + { + var all = await LoadAllInternalAsync(ct); + all.RemoveAll(r => string.Equals(r.Id, computed.Id, StringComparison.Ordinal)); + all.Add(computed); + await SaveAsync(all, ct); + } + finally { _lock.Release(); } + + return computed; + } + + // ── Read ──────────────────────────────────────────────────────────────── + + public async Task<ClaimRecord?> GetByIdAsync(string id, CancellationToken ct = default) + { + var all = await LoadAllAsync(ct); + return all.FirstOrDefault(r => string.Equals(r.Id, id, StringComparison.Ordinal)); + } + + /// <summary>Returns the most recent claim recorded for the given artifact ID.</summary> + public async Task<ClaimRecord?> GetByArtifactAsync(string artifactId, CancellationToken ct = default) + { + var all = await LoadAllAsync(ct); + return all + .Where(r => string.Equals(r.ArtifactId, artifactId, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(r => r.ObservedAt) + .FirstOrDefault(); + } + + public Task<List<ClaimRecord>> GetAllAsync(CancellationToken ct = default) => + LoadAllAsync(ct); + + // ── Expiry ────────────────────────────────────────────────────────────── + + /// <summary> + /// Returns <c>false</c> when the record does not exist or its <see cref="ClaimRecord.ExpiresAt"/> + /// is set and is in the past. Callers must re-verify stale claims before acting on them. + /// </summary> + public async Task<bool> IsValidAsync(string id, CancellationToken ct = default) + { + var record = await GetByIdAsync(id, ct); + if (record is null) return false; + if (record.ExpiresAt.HasValue && record.ExpiresAt.Value < DateTimeOffset.UtcNow) + return false; + return true; + } + + // ── Lifecycle ─────────────────────────────────────────────────────────── + + /// <summary> + /// Archives records matching <paramref name="shouldArchive"/> to <paramref name="archivePath"/> + /// (appended, never overwritten) and, when <paramref name="apply"/> is <c>true</c>, + /// removes them from the active store. Returns the records that would be or were archived. + /// </summary> + public async Task<IReadOnlyList<ClaimRecord>> CompactAsync( + Func<ClaimRecord, bool> shouldArchive, + string archivePath, + bool apply, + CancellationToken ct = default) + { + await _lock.WaitAsync(ct); + try + { + var all = await LoadAllInternalAsync(ct); + var toArchive = all.Where(shouldArchive).ToList(); + if (toArchive.Count == 0) return []; + + if (apply) + { + // Append to archive (dedup by ID, newest wins). + var existing = await LoadFromFileAsync(archivePath, ct); + var archiveMap = existing + .Concat(toArchive) + .GroupBy(r => r.Id) + .ToDictionary(g => g.Key, g => g.Last()); + await SaveToPathAsync(archivePath, [.. archiveMap.Values], ct); + + // Remove archived records from the active store. + var archiveIds = new HashSet<string>(toArchive.Select(r => r.Id), StringComparer.Ordinal); + await SaveAsync(all.Where(r => !archiveIds.Contains(r.Id)).ToList(), ct); + } + + return toArchive; + } + finally { _lock.Release(); } + } + + /// <summary> + /// Applies time-based confidence decay to all active records. + /// When <paramref name="apply"/> is <c>true</c>, saves records whose status changed. + /// Returns the IDs of records that were or would be downgraded. + /// </summary> + public async Task<IReadOnlyList<string>> DecayAsync( + int decayDays, + bool apply, + CancellationToken ct = default) + { + await _lock.WaitAsync(ct); + try + { + var all = await LoadAllInternalAsync(ct); + var updated = new List<ClaimRecord>(); + var changed = new List<string>(); + + foreach (var r in all) + { + var newStatus = ConfidenceComputer.Decay(r.Status, r.VerifiedAt, r.ExpiresAt, decayDays); + if (string.Equals(newStatus, r.Status, StringComparison.Ordinal)) + { + updated.Add(r); + } + else + { + updated.Add(r with { Status = newStatus }); + changed.Add(r.Id); + } + } + + if (apply && changed.Count > 0) + await SaveAsync(updated, ct); + + return changed; + } + finally { _lock.Release(); } + } + + // ── Helpers ───────────────────────────────────────────────────────────── + + private async Task<List<ClaimRecord>> LoadAllAsync(CancellationToken ct) + { + await _lock.WaitAsync(ct); + try { return await LoadAllInternalAsync(ct); } + finally { _lock.Release(); } + } + + private async Task<List<ClaimRecord>> LoadAllInternalAsync(CancellationToken ct) + { + if (!File.Exists(_path)) return []; + try + { + var json = await File.ReadAllTextAsync(_path, ct); + return JsonSerializer.Deserialize<List<ClaimRecord>>(json, JsonOpts) ?? []; + } + catch { return []; } + } + + private static async Task<List<ClaimRecord>> LoadFromFileAsync(string path, CancellationToken ct) + { + if (!File.Exists(path)) return []; + try + { + var json = await File.ReadAllTextAsync(path, ct); + return JsonSerializer.Deserialize<List<ClaimRecord>>(json, JsonOpts) ?? []; + } + catch { return []; } + } + + private async Task SaveAsync(List<ClaimRecord> records, CancellationToken ct) + { + Directory.CreateDirectory(Path.GetDirectoryName(_path)!); + var json = JsonSerializer.Serialize(records, JsonOpts); + var tmp = _path + ".tmp"; + await File.WriteAllTextAsync(tmp, json, ct); + File.Move(tmp, _path, overwrite: true); + } + + private static async Task SaveToPathAsync(string path, List<ClaimRecord> records, CancellationToken ct) + { + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + var json = JsonSerializer.Serialize(records, JsonOpts); + var tmp = path + ".tmp"; + await File.WriteAllTextAsync(tmp, json, ct); + File.Move(tmp, path, overwrite: true); + } +} diff --git a/src/Infrastructure/RepositoryGraphBuilder.cs b/src/Infrastructure/RepositoryGraphBuilder.cs new file mode 100644 index 0000000..f0a1b33 --- /dev/null +++ b/src/Infrastructure/RepositoryGraphBuilder.cs @@ -0,0 +1,423 @@ +using System.Text.RegularExpressions; +using fuseraft.Core.Models; + +namespace fuseraft.Infrastructure; + +/// <summary> +/// Builds and incrementally maintains the <see cref="RepositoryGraph"/> by scanning C# source files. +/// +/// <para> +/// Uses structural text analysis (regex over source lines) to extract symbol declarations — +/// no Roslyn dependency required for the initial build. Each file is scanned in isolation so +/// incremental rebuilds update only the nodes in the changed file. +/// </para> +/// +/// <para> +/// SymbolId scheme (stable, fully-qualified names): +/// <list type="bullet"> +/// <item><c>file:relative/path/to/File.cs</c></item> +/// <item><c>namespace:My.Namespace</c></item> +/// <item><c>type:My.Namespace.ClassName</c></item> +/// <item><c>interface:My.Namespace.IName</c></item> +/// <item><c>method:My.Namespace.ClassName.MethodName</c></item> +/// <item><c>property:My.Namespace.ClassName.PropName</c></item> +/// <item><c>field:My.Namespace.ClassName.FieldName</c></item> +/// <item><c>adr:ADR-NNNN</c></item> +/// </list> +/// </para> +/// </summary> +public sealed class RepositoryGraphBuilder +{ + private readonly RepositoryGraphStore _store; + private readonly string _projectRoot; + private readonly SemaphoreSlim _buildLock = new(1, 1); + + // Structural patterns for C# source + private static readonly Regex NamespaceRx = new(@"^\s*(?:file\s+)?namespace\s+([\w.]+)", RegexOptions.Compiled); + private static readonly Regex UsingRx = new(@"^\s*using\s+([\w.]+)\s*;", RegexOptions.Compiled); + private static readonly Regex ClassRx = new(@"(?:^|\s)(?:public|internal|private|protected)(?:\s+(?:abstract|sealed|static|partial|record|readonly))*\s+class\s+(\w+)(?:\s*<[^>]*>)?\s*(?::\s*([\w,\s<>.]+?))?(?:\s*where|\s*\{|$)", RegexOptions.Compiled); + private static readonly Regex InterfaceRx = new(@"(?:^|\s)(?:public|internal|private|protected)(?:\s+partial)?\s+interface\s+(\w+)(?:\s*<[^>]*>)?\s*(?::\s*([\w,\s<>.]+?))?(?:\s*where|\s*\{|$)", RegexOptions.Compiled); + private static readonly Regex MethodRx = new(@"^\s*(?:public|internal|private|protected)(?:\s+(?:static|virtual|abstract|override|sealed|async|extern|new))*\s+[\w<>?\[\].,\s]+\s+(\w+)\s*\(", RegexOptions.Compiled); + private static readonly Regex PropertyRx = new(@"^\s*(?:public|internal|private|protected)(?:\s+(?:static|virtual|abstract|override|sealed|new|required))*\s+[\w<>?\[\].,\s]+\s+(\w+)\s*\{", RegexOptions.Compiled); + private static readonly Regex FieldRx = new(@"^\s*(?:public|internal|private|protected)(?:\s+(?:static|readonly|const|volatile|new))*\s+[\w<>?\[\].,\s]+\s+(_?\w+)\s*(?:=|;)", RegexOptions.Compiled); + private static readonly Regex RecordRx = new(@"(?:^|\s)(?:public|internal|private|protected)(?:\s+(?:abstract|sealed|partial))*\s+record\s+(?:class\s+|struct\s+)?(\w+)(?:\s*<[^>]*>)?\s*(?:\(|:\s*([\w,\s<>.]+?))?\s*(?:where|\{|$)", RegexOptions.Compiled); + + public RepositoryGraphBuilder(RepositoryGraphStore store, string? projectRoot = null) + { + _store = store; + _projectRoot = Path.GetFullPath(projectRoot ?? Directory.GetCurrentDirectory()); + } + + // ── Public API ──────────────────────────────────────────────────────────── + + /// <summary> + /// Rebuilds nodes for <paramref name="absoluteFilePath"/> in the persisted graph. + /// Removes stale nodes first, then re-scans the file and saves. + /// No-ops for non-.cs files. + /// </summary> + public async Task RebuildFileAsync(string absoluteFilePath, CancellationToken ct = default) + { + if (!absoluteFilePath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) return; + if (!File.Exists(absoluteFilePath)) return; + + await _buildLock.WaitAsync(ct); + try + { + var graph = await _store.LoadAsync(ct); + var relative = RelativePath(absoluteFilePath); + graph.RemoveFile(relative); + ScanFile(absoluteFilePath, relative, graph); + await _store.SaveAsync(graph, ct); + } + finally { _buildLock.Release(); } + } + + /// <summary> + /// Full initial build: scans all .cs files under <paramref name="directory"/> (or the project + /// root when omitted) and overwrites the persisted graph. + /// Returns the number of nodes created. + /// </summary> + public async Task<(int Nodes, int Edges)> BuildAllAsync( + string? directory = null, + CancellationToken ct = default) + { + var root = directory is not null ? Path.GetFullPath(directory) : _projectRoot; + var graph = new RepositoryGraph(); + var files = Directory.GetFiles(root, "*.cs", SearchOption.AllDirectories) + .Where(f => !IsBuildArtifact(f)) + .ToList(); + + foreach (var f in files) + { + if (ct.IsCancellationRequested) break; + var relative = RelativePath(f, root); + ScanFile(f, relative, graph); + } + + await _buildLock.WaitAsync(ct); + try { await _store.SaveAsync(graph, ct); } + finally { _buildLock.Release(); } + + return (graph.Nodes.Count, graph.Edges.Count); + } + + /// <summary> + /// Upserts an <see cref="AdrEntry"/> as a graph node and wires <see cref="EdgeType.AdrGoverns"/> + /// edges to every file or symbol listed in <paramref name="adr"/>.<c>Governs</c>. + /// </summary> + public async Task UpsertAdrNodeAsync(AdrEntry adr, CancellationToken ct = default) + { + await _buildLock.WaitAsync(ct); + try + { + var graph = await _store.LoadAsync(ct); + var adrId = $"adr:{adr.Id}"; + + // Remove stale ADR node and its outgoing adr_governs edges. + graph.Nodes.RemoveAll(n => string.Equals(n.Id, adrId, StringComparison.Ordinal)); + graph.Edges.RemoveAll(e => + string.Equals(e.From, adrId, StringComparison.Ordinal) && + string.Equals(e.Relation, EdgeType.AdrGoverns, StringComparison.Ordinal)); + + graph.AddNode(new RepositoryGraphNode + { + Id = adrId, + Kind = NodeType.Adr, + Name = adr.Id, + Timestamp = DateTimeOffset.UtcNow, + }); + + foreach (var governed in adr.Governs) + { + var target = NormalizeGovernsTarget(governed, graph); + if (target is null) continue; + graph.AddEdge(new RepositoryGraphEdge + { + From = adrId, + To = target, + Relation = EdgeType.AdrGoverns, + }); + } + + await _store.SaveAsync(graph, ct); + } + finally { _buildLock.Release(); } + } + + // ── File scanning ───────────────────────────────────────────────────────── + + private void ScanFile(string absolutePath, string relativePath, RepositoryGraph graph) + { + string[] lines; + try { lines = File.ReadAllLines(absolutePath); } + catch { return; } + + // File node + var fileId = $"file:{relativePath}"; + graph.AddNode(new RepositoryGraphNode + { + Id = fileId, + Kind = NodeType.File, + FilePath = relativePath, + Name = Path.GetFileName(relativePath), + }); + + string? currentNamespace = null; + string? currentType = null; + NodeType currentKind = NodeType.Type; + + for (int i = 0; i < lines.Length; i++) + { + var line = lines[i]; + var lineNo = i + 1; + + // Namespace declaration + var nsMatch = NamespaceRx.Match(line); + if (nsMatch.Success) + { + currentNamespace = nsMatch.Groups[1].Value; + var nsId = $"namespace:{currentNamespace}"; + graph.AddNode(new RepositoryGraphNode + { + Id = nsId, + Kind = NodeType.Namespace, + FilePath = relativePath, + Name = currentNamespace, + StartLine = lineNo, + }); + graph.AddEdge(new RepositoryGraphEdge { From = fileId, To = nsId, Relation = EdgeType.Defines }); + continue; + } + + // Using directives + var usingMatch = UsingRx.Match(line); + if (usingMatch.Success && !line.Contains("=")) + { + var imported = usingMatch.Groups[1].Value; + var importId = $"namespace:{imported}"; + if (graph.FindById(importId) is null) + graph.AddNode(new RepositoryGraphNode { Id = importId, Kind = NodeType.Namespace, Name = imported }); + graph.AddEdge(new RepositoryGraphEdge { From = fileId, To = importId, Relation = EdgeType.Imports }); + continue; + } + + // Interface declaration + var ifaceMatch = InterfaceRx.Match(line); + if (ifaceMatch.Success) + { + var name = ifaceMatch.Groups[1].Value; + var fqn = currentNamespace is not null ? $"{currentNamespace}.{name}" : name; + var id = $"interface:{fqn}"; + graph.AddNode(new RepositoryGraphNode + { + Id = id, + Kind = NodeType.Interface, + FilePath = relativePath, + Name = name, + Namespace = currentNamespace, + StartLine = lineNo, + }); + graph.AddEdge(new RepositoryGraphEdge { From = fileId, To = id, Relation = EdgeType.Defines }); + currentType = fqn; + currentKind = NodeType.Interface; + + AddInheritanceEdges(id, ifaceMatch.Groups[2].Value, currentNamespace, NodeType.Interface, graph); + continue; + } + + // Record declaration (before class so "record class" is caught here) + var recMatch = RecordRx.Match(line); + if (recMatch.Success) + { + var name = recMatch.Groups[1].Value; + var fqn = currentNamespace is not null ? $"{currentNamespace}.{name}" : name; + var id = $"type:{fqn}"; + graph.AddNode(new RepositoryGraphNode + { + Id = id, + Kind = NodeType.Type, + FilePath = relativePath, + Name = name, + Namespace = currentNamespace, + StartLine = lineNo, + }); + graph.AddEdge(new RepositoryGraphEdge { From = fileId, To = id, Relation = EdgeType.Defines }); + currentType = fqn; + currentKind = NodeType.Type; + + AddInheritanceEdges(id, recMatch.Groups[2].Value, currentNamespace, NodeType.Type, graph); + continue; + } + + // Class declaration + var classMatch = ClassRx.Match(line); + if (classMatch.Success) + { + var name = classMatch.Groups[1].Value; + var fqn = currentNamespace is not null ? $"{currentNamespace}.{name}" : name; + var id = $"type:{fqn}"; + graph.AddNode(new RepositoryGraphNode + { + Id = id, + Kind = NodeType.Type, + FilePath = relativePath, + Name = name, + Namespace = currentNamespace, + StartLine = lineNo, + }); + graph.AddEdge(new RepositoryGraphEdge { From = fileId, To = id, Relation = EdgeType.Defines }); + currentType = fqn; + currentKind = NodeType.Type; + + AddInheritanceEdges(id, classMatch.Groups[2].Value, currentNamespace, NodeType.Type, graph); + continue; + } + + if (currentType is null) continue; + var typeId = $"{(currentKind == NodeType.Interface ? "interface" : "type")}:{currentType}"; + + // Method declaration (coarse heuristic — skip property accessors) + if (!line.TrimStart().StartsWith("get") && !line.TrimStart().StartsWith("set") && + !line.TrimStart().StartsWith("init") && !line.TrimStart().StartsWith("//")) + { + var methMatch = MethodRx.Match(line); + if (methMatch.Success) + { + var name = methMatch.Groups[1].Value; + if (!IsKeyword(name)) + { + var id = $"method:{currentType}.{name}"; + graph.AddNode(new RepositoryGraphNode + { + Id = id, + Kind = NodeType.Method, + FilePath = relativePath, + Name = name, + Namespace = currentNamespace, + StartLine = lineNo, + }); + graph.AddEdge(new RepositoryGraphEdge { From = typeId, To = id, Relation = EdgeType.Defines }); + continue; + } + } + } + + // Property declaration + var propMatch = PropertyRx.Match(line); + if (propMatch.Success) + { + var name = propMatch.Groups[1].Value; + if (!IsKeyword(name)) + { + var id = $"property:{currentType}.{name}"; + graph.AddNode(new RepositoryGraphNode + { + Id = id, + Kind = NodeType.Property, + FilePath = relativePath, + Name = name, + Namespace = currentNamespace, + StartLine = lineNo, + }); + graph.AddEdge(new RepositoryGraphEdge { From = typeId, To = id, Relation = EdgeType.Defines }); + continue; + } + } + + // Field declaration + var fieldMatch = FieldRx.Match(line); + if (fieldMatch.Success) + { + var name = fieldMatch.Groups[1].Value; + if (!IsKeyword(name)) + { + var id = $"field:{currentType}.{name}"; + graph.AddNode(new RepositoryGraphNode + { + Id = id, + Kind = NodeType.Field, + FilePath = relativePath, + Name = name, + Namespace = currentNamespace, + StartLine = lineNo, + }); + graph.AddEdge(new RepositoryGraphEdge { From = typeId, To = id, Relation = EdgeType.Defines }); + } + } + } + } + + private static void AddInheritanceEdges( + string fromId, + string baseListRaw, + string? currentNamespace, + NodeType fromKind, + RepositoryGraph graph) + { + if (string.IsNullOrWhiteSpace(baseListRaw)) return; + + foreach (var raw in baseListRaw.Split(',')) + { + var name = raw.Trim().Split('<')[0].Trim(); // strip generic args + if (string.IsNullOrEmpty(name)) continue; + + // Heuristic: interfaces start with I followed by uppercase + bool looksLikeInterface = name.Length > 1 && name[0] == 'I' && char.IsUpper(name[1]); + var prefix = looksLikeInterface ? "interface" : "type"; + var toId = currentNamespace is not null ? $"{prefix}:{currentNamespace}.{name}" : $"{prefix}:{name}"; + + // Ensure target node exists (as a stub) so edges are valid. + if (graph.FindById(toId) is null) + graph.AddNode(new RepositoryGraphNode + { + Id = toId, + Kind = looksLikeInterface ? NodeType.Interface : NodeType.Type, + Name = name, + Namespace = currentNamespace, + }); + + var relation = looksLikeInterface ? EdgeType.Implements : EdgeType.Inherits; + graph.AddEdge(new RepositoryGraphEdge { From = fromId, To = toId, Relation = relation }); + } + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private string RelativePath(string absolute, string? root = null) + { + var baseDir = root ?? _projectRoot; + try + { + var rel = Path.GetRelativePath(baseDir, absolute); + return rel.Replace('\\', '/'); + } + catch { return Path.GetFileName(absolute); } + } + + private static string? NormalizeGovernsTarget(string governed, RepositoryGraph graph) + { + // Already a SymbolId — verify it exists or return as-is. + if (governed.Contains(':')) + { + var node = graph.FindById(governed); + return node is not null ? governed : governed; // accept even if not yet in graph + } + + // Looks like a file path — normalise separators and look for a file node. + var normalised = governed.Replace('\\', '/'); + var fileId = $"file:{normalised}"; + return fileId; + } + + private static bool IsKeyword(string name) => + name is "if" or "else" or "while" or "for" or "foreach" or "switch" or "case" + or "return" or "throw" or "catch" or "finally" or "try" or "new" or "this" + or "base" or "null" or "true" or "false" or "var" or "void" or "override" + or "virtual" or "abstract" or "sealed" or "static" or "readonly" or "const"; + + private static bool IsBuildArtifact(string path) => + path.Contains("/obj/", StringComparison.Ordinal) || + path.Contains("\\obj\\", StringComparison.Ordinal) || + path.Contains("/bin/", StringComparison.Ordinal) || + path.Contains("\\bin\\", StringComparison.Ordinal); +} diff --git a/src/Infrastructure/RepositoryGraphStore.cs b/src/Infrastructure/RepositoryGraphStore.cs new file mode 100644 index 0000000..110aafe --- /dev/null +++ b/src/Infrastructure/RepositoryGraphStore.cs @@ -0,0 +1,64 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using fuseraft.Core.Models; + +namespace fuseraft.Infrastructure; + +/// <summary> +/// Persists and loads the <see cref="RepositoryGraph"/> to/from a single JSON file +/// at <c>.fuseraft/state/repository.graph</c>. +/// +/// Writes are atomic (write-to-temp then rename) and protected by a semaphore. +/// </summary> +public sealed class RepositoryGraphStore +{ + private readonly string _path; + private readonly SemaphoreSlim _lock = new(1, 1); + + private static readonly JsonSerializerOptions JsonOpts = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, + }; + + public RepositoryGraphStore(string path) => + _path = Path.GetFullPath(path); + + // ── Read ────────────────────────────────────────────────────────────────── + + /// <summary>Loads the graph from disk. Returns an empty graph when the file does not exist.</summary> + public async Task<RepositoryGraph> LoadAsync(CancellationToken ct = default) + { + if (!File.Exists(_path)) return new RepositoryGraph(); + try + { + var json = await File.ReadAllTextAsync(_path, ct); + var graph = JsonSerializer.Deserialize<RepositoryGraph>(json, JsonOpts); + return graph ?? new RepositoryGraph(); + } + catch { return new RepositoryGraph(); } + } + + // ── Write ───────────────────────────────────────────────────────────────── + + /// <summary>Saves <paramref name="graph"/> to disk atomically.</summary> + public async Task SaveAsync(RepositoryGraph graph, CancellationToken ct = default) + { + await _lock.WaitAsync(ct); + try + { + graph.LastUpdated = DateTimeOffset.UtcNow; + var dir = Path.GetDirectoryName(_path); + if (dir is not null) Directory.CreateDirectory(dir); + + var json = JsonSerializer.Serialize(graph, JsonOpts); + var tmp = _path + ".tmp"; + await File.WriteAllTextAsync(tmp, json, ct); + File.Move(tmp, _path, overwrite: true); + } + finally { _lock.Release(); } + } +} diff --git a/src/Infrastructure/RepositoryMemoryExtractor.cs b/src/Infrastructure/RepositoryMemoryExtractor.cs new file mode 100644 index 0000000..51098ce --- /dev/null +++ b/src/Infrastructure/RepositoryMemoryExtractor.cs @@ -0,0 +1,159 @@ +using fuseraft.Core.Models; +using fuseraft.Orchestration; + +namespace fuseraft.Infrastructure; + +/// <summary> +/// Derives candidate <see cref="RepositoryMemoryEntry"/> records from the evidence graph +/// after a session closes. +/// +/// <para> +/// All extraction is deterministic — no LLM call is made. Patterns are derived from: +/// <list type="bullet"> +/// <item>Shell commands that exited successfully (<see cref="EvidenceClass.ExitCode"/>)</item> +/// <item>Test results that passed (<see cref="EvidenceClass.TestResult"/>)</item> +/// <item>Files written more than once in a session (<see cref="EvidenceClass.EvidenceGraph"/>)</item> +/// </list> +/// </para> +/// +/// <para> +/// New candidates are written with <c>Status = Candidate</c>. When the same pattern +/// has already been <c>Approved</c>, the existing entry's +/// <see cref="RepositoryMemoryEntry.ReinforcementCount"/> is incremented and +/// <see cref="RepositoryMemoryEntry.Confidence"/> is recomputed. Candidates are never +/// promoted to <c>Approved</c> here — that requires explicit human review +/// (<c>fuseraft memory review</c>) or a reviewer agent. +/// </para> +/// </summary> +public sealed class RepositoryMemoryExtractor +{ + private readonly EvidenceStore _evidenceStore; + private readonly RepositoryMemoryStore _memoryStore; + + public RepositoryMemoryExtractor(EvidenceStore evidenceStore, RepositoryMemoryStore memoryStore) + { + _evidenceStore = evidenceStore; + _memoryStore = memoryStore; + } + + /// <summary> + /// Extracts candidate memories from the evidence graph for the given session. + /// Returns the new <c>Candidate</c> entries created; approved entries that were + /// reinforced are not included in the returned list. + /// </summary> + public async Task<IReadOnlyList<RepositoryMemoryEntry>> ExtractAsync( + string? sessionId = null, + CancellationToken ct = default) + { + var commandNodes = await _evidenceStore.QueryNodes( + n => n.NodeType == "CommandRun" && n.ExitCode == 0 && + !string.IsNullOrWhiteSpace(n.Command) && + (sessionId is null || n.SessionId == sessionId), ct); + + var testNodes = await _evidenceStore.QueryNodes( + n => n.NodeType == "TestResult" && + string.Equals(n.Status, "PASS", StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(n.Criterion) && + (sessionId is null || n.SessionId == sessionId), ct); + + var fileWriteNodes = await _evidenceStore.QueryNodes( + n => n.NodeType == "FileWrite" && + !string.IsNullOrWhiteSpace(n.Path) && + (sessionId is null || n.SessionId == sessionId), ct); + + var existing = await _memoryStore.LoadAllAsync(ct); + var newCandidates = new List<RepositoryMemoryEntry>(); + var seenPatterns = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + + // Successful shell commands + foreach (var node in commandNodes) + { + var cmd = node.Command!.Length > 120 ? node.Command[..120] + "…" : node.Command; + var pattern = $"Shell command succeeds: {cmd}"; + if (seenPatterns.Add(pattern)) + await RecordOrReinforceAsync(pattern, [EvidenceClass.ExitCode], + existing, newCandidates, sessionId, ct); + } + + // Passing test results + foreach (var node in testNodes) + { + var pattern = $"Test passes: {node.Criterion}"; + if (seenPatterns.Add(pattern)) + await RecordOrReinforceAsync(pattern, [EvidenceClass.TestResult, EvidenceClass.ExitCode], + existing, newCandidates, sessionId, ct); + } + + // Files written more than once (frequently modified) + var writeCounts = fileWriteNodes + .GroupBy(n => n.Path!, StringComparer.OrdinalIgnoreCase) + .Where(g => g.Count() > 1); + + foreach (var group in writeCounts) + { + var pattern = $"File is modified repeatedly in sessions: {group.Key}"; + if (seenPatterns.Add(pattern)) + await RecordOrReinforceAsync(pattern, [EvidenceClass.EvidenceGraph], + existing, newCandidates, sessionId, ct); + } + + return newCandidates; + } + + // ── Reinforcement ──────────────────────────────────────────────────────── + + private async Task RecordOrReinforceAsync( + string pattern, + List<EvidenceClass> evidence, + List<RepositoryMemoryEntry> existing, + List<RepositoryMemoryEntry> newCandidates, + string? sessionId, + CancellationToken ct) + { + // Reinforce an existing approved entry when the pattern matches. + var approved = existing.FirstOrDefault(e => + e.Status.Equals("Approved", StringComparison.OrdinalIgnoreCase) && + IsSamePattern(e.Pattern, pattern)); + + if (approved is not null) + { + var merged = MergeEvidence(approved.Evidence, evidence); + await _memoryStore.SaveAsync(approved with + { + ReinforcementCount = approved.ReinforcementCount + 1, + LastReinforcedAt = DateTimeOffset.UtcNow, + Evidence = merged, + Confidence = ConfidenceComputer.Compute(merged), + }, ct); + return; + } + + // Skip duplicate candidates. + if (existing.Any(e => + e.Status.Equals("Candidate", StringComparison.OrdinalIgnoreCase) && + IsSamePattern(e.Pattern, pattern))) + return; + + var entry = new RepositoryMemoryEntry + { + Pattern = pattern, + Evidence = evidence, + Confidence = ConfidenceComputer.Compute(evidence), + Status = "Candidate", + SourceSessionId = sessionId, + }; + await _memoryStore.SaveAsync(entry, ct); + newCandidates.Add(entry); + } + + private static bool IsSamePattern(string a, string b) => + string.Equals(a.Trim(), b.Trim(), StringComparison.OrdinalIgnoreCase); + + private static List<EvidenceClass> MergeEvidence(List<EvidenceClass> existing, List<EvidenceClass> added) + { + var merged = new List<EvidenceClass>(existing); + foreach (var e in added) + if (!merged.Contains(e)) merged.Add(e); + return merged; + } +} diff --git a/src/Infrastructure/RepositoryMemoryStore.cs b/src/Infrastructure/RepositoryMemoryStore.cs new file mode 100644 index 0000000..8113a87 --- /dev/null +++ b/src/Infrastructure/RepositoryMemoryStore.cs @@ -0,0 +1,149 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using fuseraft.Core.Models; + +namespace fuseraft.Infrastructure; + +/// <summary> +/// Persistent store for <see cref="RepositoryMemoryEntry"/> records. +/// +/// <para> +/// Each entry is written as an indented JSON file named <c>{id}.json</c> under +/// <c>.fuseraft/knowledge/repository/</c>. A human-readable <c>MEMORY.md</c> index +/// in the same directory lists every entry with its ID, status, confidence, and the +/// first line of its pattern — matching the layout used by the agent memory store. +/// </para> +/// +/// <para> +/// Writes are atomic (write-to-temp then rename) and protected by a semaphore. +/// </para> +/// </summary> +public sealed class RepositoryMemoryStore +{ + private readonly string _dir; + private readonly SemaphoreSlim _lock = new(1, 1); + + private const string IndexFile = "MEMORY.md"; + + private static readonly JsonSerializerOptions JsonOpts = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter() }, + }; + + public RepositoryMemoryStore(string directory) => _dir = Path.GetFullPath(directory); + + // ── Read ──────────────────────────────────────────────────────────────── + + public async Task<List<RepositoryMemoryEntry>> LoadAllAsync(CancellationToken ct = default) + { + if (!Directory.Exists(_dir)) return []; + + var results = new List<RepositoryMemoryEntry>(); + foreach (var file in Directory.GetFiles(_dir, "*.json").OrderBy(f => f)) + { + var entry = await LoadFileAsync(file, ct); + if (entry is not null) results.Add(entry); + } + return results; + } + + /// <summary>Returns only entries with <c>Status = Approved</c>.</summary> + public async Task<List<RepositoryMemoryEntry>> LoadApprovedAsync(CancellationToken ct = default) + { + var all = await LoadAllAsync(ct); + return all.Where(e => e.Status.Equals("Approved", StringComparison.OrdinalIgnoreCase)).ToList(); + } + + /// <summary>Returns only entries with <c>Status = Candidate</c>.</summary> + public async Task<List<RepositoryMemoryEntry>> LoadCandidatesAsync(CancellationToken ct = default) + { + var all = await LoadAllAsync(ct); + return all.Where(e => e.Status.Equals("Candidate", StringComparison.OrdinalIgnoreCase)).ToList(); + } + + public async Task<RepositoryMemoryEntry?> GetByIdAsync(string id, CancellationToken ct = default) + { + var path = FilePath(id); + return File.Exists(path) ? await LoadFileAsync(path, ct) : null; + } + + // ── Write ──────────────────────────────────────────────────────────────── + + public async Task SaveAsync(RepositoryMemoryEntry entry, CancellationToken ct = default) + { + await _lock.WaitAsync(ct); + try + { + Directory.CreateDirectory(_dir); + var json = JsonSerializer.Serialize(entry, JsonOpts); + await WriteAtomicAsync(FilePath(entry.Id), json, ct); + await RebuildIndexAsync(ct); + } + finally { _lock.Release(); } + } + + public async Task DeleteAsync(string id, CancellationToken ct = default) + { + await _lock.WaitAsync(ct); + try + { + var path = FilePath(id); + if (File.Exists(path)) File.Delete(path); + await RebuildIndexAsync(ct); + } + finally { _lock.Release(); } + } + + // ── Index ──────────────────────────────────────────────────────────────── + + private async Task RebuildIndexAsync(CancellationToken ct) + { + var entries = new List<RepositoryMemoryEntry>(); + foreach (var file in Directory.GetFiles(_dir, "*.json").OrderBy(f => f)) + { + var e = await LoadFileAsync(file, ct); + if (e is not null) entries.Add(e); + } + + var sb = new StringBuilder(); + sb.AppendLine("# Repository Memory Index"); + sb.AppendLine(); + sb.AppendLine("Patterns observed across sessions. Candidates require review before injection."); + sb.AppendLine(); + + foreach (var e in entries.OrderBy(e => e.Status).ThenByDescending(e => e.ReinforcementCount)) + { + var preview = e.Pattern.Length > 80 ? e.Pattern[..80] + "…" : e.Pattern; + sb.AppendLine($"- [{e.Status}] [{e.Confidence}] (reinforced {e.ReinforcementCount}×) {preview}"); + } + + var indexPath = Path.Combine(_dir, IndexFile); + await WriteAtomicAsync(indexPath, sb.ToString(), ct); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private string FilePath(string id) => Path.Combine(_dir, $"{id}.json"); + + private static async Task<RepositoryMemoryEntry?> LoadFileAsync(string path, CancellationToken ct) + { + try + { + var json = await File.ReadAllTextAsync(path, ct); + return JsonSerializer.Deserialize<RepositoryMemoryEntry>(json, JsonOpts); + } + catch { return null; } + } + + private static async Task WriteAtomicAsync(string path, string content, CancellationToken ct) + { + var tmp = path + ".tmp"; + await File.WriteAllTextAsync(tmp, content, ct); + File.Move(tmp, path, overwrite: true); + } +} diff --git a/src/Orchestration/AgentOrchestrator.cs b/src/Orchestration/AgentOrchestrator.cs index 46216c7..133ebf0 100644 --- a/src/Orchestration/AgentOrchestrator.cs +++ b/src/Orchestration/AgentOrchestrator.cs @@ -29,7 +29,8 @@ public sealed class AgentOrchestrator( EventEmitter? eventEmitter = null, GovernanceKernel? governanceKernel = null, fuseraft.Infrastructure.MemoryManager? memoryManager = null, - ContextAssembler? contextAssembler = null) : IOrchestrator + ContextAssembler? contextAssembler = null, + DependencyPlanner? dependencyPlanner = null) : IOrchestrator { // IOrchestrator @@ -426,6 +427,26 @@ await eventEmitter.EmitAsync("turn_end", int postSelectCount = history.Count; if (agent is null) break; + // Prerequisite enforcement: if DependencyPlanner is active and the selected agent + // has unmet Requires tokens, inject a blocker message into history so the selector + // knows to route elsewhere, then skip this turn. + if (dependencyPlanner is { HasDependencies: true } && + !dependencyPlanner.CanExecute(agent.Name ?? string.Empty)) + { + var unmet = dependencyPlanner.GetUnmetRequirements(agent.Name ?? string.Empty); + var blockerText = + $"[DependencyPlanner] Agent '{agent.Name}' is blocked — waiting for prerequisites: " + + string.Join(", ", unmet.Select(t => $"'{t}'")) + ". " + + "Route to an agent that can produce these tokens first."; + + logger.LogInformation( + "[Orchestrator] Prerequisite block: agent '{Agent}' waiting for [{Tokens}].", + agent.Name, string.Join(", ", unmet)); + + history.Add(new ChatMessage(ChatRole.User, blockerText)); + continue; + } + logger.LogDebug( "[Orchestrator] Turn {Turn}: selected agent '{Agent}' (Name property='{NameProp}') | history={HistCount} msgs", turn, agent.Name, agent.Name, history.Count); @@ -549,6 +570,9 @@ await eventEmitter.EmitAsync("turn_end", ToolCalls = ExtractToolCalls(response.Messages, agent.Name ?? "Unknown") }; + // Fulfill this agent's produced tokens now that its turn is complete. + dependencyPlanner?.Fulfill(agent.Name ?? string.Empty); + cumulativeTokens += agentMessage.Usage?.TotalTokens ?? 0; logger.LogDebug( diff --git a/src/Orchestration/ChangeTracker.cs b/src/Orchestration/ChangeTracker.cs index 1e53394..7378dc2 100644 --- a/src/Orchestration/ChangeTracker.cs +++ b/src/Orchestration/ChangeTracker.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using fuseraft.Core.Models; +using fuseraft.Infrastructure; namespace fuseraft.Orchestration; @@ -36,6 +37,7 @@ public sealed class ChangeTracker private readonly EventEmitter? _eventEmitter; private readonly EvidenceStore? _evidenceStore; private readonly IntentLog? _intentLog; + private readonly RepositoryGraphBuilder? _graphBuilder; private readonly ILogger<ChangeTracker>? _logger; private readonly ConcurrentQueue<InvocationRecord> _pending = new(); private readonly SemaphoreSlim _fileLock = new(1, 1); @@ -78,12 +80,13 @@ private static bool FunctionNameMatches(string name, string pattern) => DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; - public ChangeTracker(string logPath, EventEmitter? eventEmitter = null, EvidenceStore? evidenceStore = null, IntentLog? intentLog = null, ILogger<ChangeTracker>? logger = null) + public ChangeTracker(string logPath, EventEmitter? eventEmitter = null, EvidenceStore? evidenceStore = null, IntentLog? intentLog = null, ILogger<ChangeTracker>? logger = null, RepositoryGraphBuilder? graphBuilder = null) { _logPath = logPath; _eventEmitter = eventEmitter; _evidenceStore = evidenceStore; _intentLog = intentLog; + _graphBuilder = graphBuilder; _logger = logger; } @@ -402,6 +405,26 @@ private async Task EmitEvidenceNodesAsync( } await _evidenceStore!.RecordAsync(nodes, edges, ct); + + // Incrementally rebuild repository graph nodes for every written .cs file so that + // graph_search and adr_governs traversal reflect the latest source structure. + if (_graphBuilder is not null) + { + var writtenPaths = records + .Where(r => + (FunctionNameMatches(r.Name, "write_file") || FunctionNameMatches(r.Name, "patch_file") || + FunctionNameMatches(r.Name, "copy_file") || FunctionNameMatches(r.Name, "move_file")) + && r.Succeeded) + .Select(r => GetArg(r.Args, "destination") ?? GetArg(r.Args, "path")) + .OfType<string>() + .Where(p => p.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)); + + foreach (var path in writtenPaths) + { + var abs = Path.GetFullPath(path); + _ = _graphBuilder.RebuildFileAsync(abs, CancellationToken.None); // fire-and-forget + } + } } // Parses search_symbol output to extract SymbolDefinition nodes for the evidence graph. diff --git a/src/Orchestration/ContextBroker.cs b/src/Orchestration/ContextBroker.cs new file mode 100644 index 0000000..8a0fea5 --- /dev/null +++ b/src/Orchestration/ContextBroker.cs @@ -0,0 +1,141 @@ +using System.Text; +using fuseraft.Core; +using fuseraft.Core.Models; +using fuseraft.Infrastructure; + +namespace fuseraft.Orchestration; + +/// <summary> +/// Adaptive context broker — Gap 8 implementation. +/// +/// <para>Pipeline: <c>IntentAnalyzer → KnowledgeRetriever → ContextBudgeter → Prompt Assembly</c></para> +/// +/// <para> +/// Given a natural-language query or task description, the broker extracts intent signals, +/// queries all registered knowledge subsystems (ADR registry, repository semantic graph, +/// repository memory), ranks results by provenance confidence, trims to a character budget, +/// and returns a formatted context block ready for injection into an agent prompt. +/// </para> +/// +/// <para> +/// Expired claims (past their <c>ExpiresAt</c>) are excluded from output. The broker +/// falls back gracefully to <c>null</c> (no content) when no relevant items are found. +/// </para> +/// </summary> +public sealed class ContextBroker +{ + private readonly KnowledgeRetriever _retriever; + + public ContextBroker( + IKnowledgeLayer knowledgeLayer, + RepositoryMemoryStore? memoryStore = null, + ProvenanceRegistry? provenance = null) + { + _retriever = new KnowledgeRetriever(knowledgeLayer, memoryStore, provenance); + } + + /// <summary> + /// Runs the full broker pipeline for <paramref name="query"/> and returns a formatted + /// context block, or <c>null</c> when no relevant knowledge is found. + /// </summary> + /// <param name="query"> + /// A natural-language query, keyword, or task description. When empty, the broker + /// returns <c>null</c> without querying the knowledge layer. + /// </param> + /// <param name="maxChars">Character budget for the output. 0 = no limit.</param> + public async Task<string?> ResolveAsync( + string query, + int maxChars = 0, + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(query)) + return null; + + var signals = IntentAnalyzer.Analyze(query); + if (signals.IsEmpty) + return null; + + var allItems = await _retriever.RetrieveAsync(signals, ct); + if (allItems.Count == 0) + return null; + + var budgeted = ContextBudgeter.Budget(allItems, maxChars); + if (budgeted.Count == 0) + return null; + + return Format(query, budgeted); + } + + // Groups items by kind and confidence, then formats into a labelled block. + private static string Format(string query, IReadOnlyList<RetrievedItem> items) + { + var sb = new StringBuilder(); + sb.AppendLine($"[Knowledge Broker — adaptive context for: {Truncate(query, 80)}]"); + + // Group: Decisions (ADRs) + var decisions = items.Where(i => i.Result.Kind == KnowledgeKind.Decision).ToList(); + if (decisions.Count > 0) + { + sb.AppendLine(); + sb.AppendLine("## Architecture Decisions"); + foreach (var item in decisions) + AppendItem(sb, item); + } + + // Group: Graph nodes (symbols / files / types) + var graphNodes = items.Where(i => i.Result.Kind == KnowledgeKind.GraphNode).ToList(); + if (graphNodes.Count > 0) + { + sb.AppendLine(); + sb.AppendLine("## Repository Symbols"); + foreach (var item in graphNodes) + AppendItem(sb, item); + } + + // Group: Repository memory (approved patterns) + var memories = items.Where(i => i.Result.Kind == KnowledgeKind.Memory).ToList(); + if (memories.Count > 0) + { + sb.AppendLine(); + sb.AppendLine("## Repository Memory"); + foreach (var item in memories) + AppendItem(sb, item); + } + + // Group: Claims and objectives + var rest = items + .Where(i => i.Result.Kind is not KnowledgeKind.Decision + and not KnowledgeKind.GraphNode + and not KnowledgeKind.Memory) + .ToList(); + if (rest.Count > 0) + { + sb.AppendLine(); + sb.AppendLine("## Other Knowledge"); + foreach (var item in rest) + AppendItem(sb, item); + } + + return sb.ToString().TrimEnd(); + } + + private static void AppendItem(StringBuilder sb, RetrievedItem item) + { + var r = item.Result; + var confidence = item.ConfidenceTier != "Guessed" + ? $" [{item.ConfidenceTier}]" + : string.Empty; + var status = r.Status is not null ? $" (status: {r.Status})" : string.Empty; + + sb.Append($"- {r.Title}{confidence}{status}"); + if (!string.IsNullOrWhiteSpace(r.FilePath)) + sb.Append($" — {r.FilePath}"); + sb.AppendLine(); + + if (!string.IsNullOrWhiteSpace(r.Summary)) + sb.AppendLine($" {r.Summary}"); + } + + private static string Truncate(string s, int max) => + s.Length <= max ? s : s[..max] + "…"; +} diff --git a/src/Orchestration/ContextBudgeter.cs b/src/Orchestration/ContextBudgeter.cs new file mode 100644 index 0000000..716154b --- /dev/null +++ b/src/Orchestration/ContextBudgeter.cs @@ -0,0 +1,61 @@ +namespace fuseraft.Orchestration; + +/// <summary> +/// Ranks <see cref="RetrievedItem"/> results by confidence tier and trims them to a +/// character budget. Expired items are excluded entirely. +/// +/// <para>Tier priority (ascending rank number = higher priority):</para> +/// <list type="bullet"> +/// <item><c>Verified</c> — two or more hard evidence sources (rank 0)</item> +/// <item><c>Inferred</c> — one hard source or ADR / RepositoryMemory (rank 1)</item> +/// <item><c>Assumed</c> — AgentAssertion only (rank 2)</item> +/// <item><c>Guessed</c> — no provenance (rank 3)</item> +/// </list> +/// </summary> +public static class ContextBudgeter +{ + private static readonly Dictionary<string, int> TierRank = + new(StringComparer.OrdinalIgnoreCase) + { + ["Verified"] = 0, + ["Inferred"] = 1, + ["Assumed"] = 2, + ["Guessed"] = 3, + }; + + /// <summary> + /// Filters expired items, sorts by confidence tier, and returns only as many items + /// as fit within <paramref name="maxChars"/> (estimated by title + summary length). + /// </summary> + public static IReadOnlyList<RetrievedItem> Budget( + IEnumerable<RetrievedItem> items, + int maxChars) + { + var ranked = items + .Where(i => !i.IsExpired) + .OrderBy(i => TierRank.GetValueOrDefault(i.ConfidenceTier, 3)) + .ToList(); + + if (maxChars <= 0) + return ranked; + + var result = new List<RetrievedItem>(ranked.Count); + int remaining = maxChars; + + foreach (var item in ranked) + { + var cost = EstimateChars(item); + if (cost > remaining) break; + result.Add(item); + remaining -= cost; + } + + return result; + } + + private static int EstimateChars(RetrievedItem item) => + (item.Result.Title?.Length ?? 0) + + (item.Result.Summary?.Length ?? 0) + + (item.Result.FilePath?.Length ?? 0) + + 60; // formatting overhead per entry +} diff --git a/src/Orchestration/ContextRebuilder.cs b/src/Orchestration/ContextRebuilder.cs index 2f3f9fe..7fd1bf6 100644 --- a/src/Orchestration/ContextRebuilder.cs +++ b/src/Orchestration/ContextRebuilder.cs @@ -79,6 +79,45 @@ public static AgentMessage BuildContextMessage(ContextSnapshot snapshot, int tur sb.AppendLine(); } + if (snapshot.ActiveAdrs.Count > 0) + { + sb.AppendLine("ACTIVE ARCHITECTURE DECISIONS:"); + foreach (var adr in snapshot.ActiveAdrs) + sb.AppendLine($" [{adr.Id}] {adr.Title} (status: {adr.Status})"); + sb.AppendLine(); + } + + if (!string.IsNullOrWhiteSpace(snapshot.ObjectiveState)) + { + sb.AppendLine("ACTIVE OBJECTIVES:"); + sb.AppendLine(snapshot.ObjectiveState.TrimEnd()); + sb.AppendLine(); + } + + if (snapshot.ArchitectureViolations.Count > 0) + { + sb.AppendLine($"ARCHITECTURE VIOLATIONS ({snapshot.ArchitectureViolations.Count} at compaction time \u2014 verify before merging):"); + foreach (var v in snapshot.ArchitectureViolations) + sb.AppendLine($" \u26a0 {v}"); + sb.AppendLine(); + } + + if (snapshot.TopRepositoryMemories.Count > 0) + { + sb.AppendLine("REPOSITORY MEMORY (approved cross-session patterns):"); + foreach (var mem in snapshot.TopRepositoryMemories) + sb.AppendLine($" \u2022 {mem}"); + sb.AppendLine(); + } + + if (snapshot.ExpiredProvenanceWarnings.Count > 0) + { + sb.AppendLine("EXPIRED PROVENANCE WARNINGS (re-verify before acting on these artifacts):"); + foreach (var w in snapshot.ExpiredProvenanceWarnings) + sb.AppendLine($" \u26a0 {w}"); + sb.AppendLine(); + } + var stateHint = snapshot.CurrentStateName is not null ? $" Continue from state '{snapshot.CurrentStateName}'." : string.Empty; diff --git a/src/Orchestration/ConversationCompactor.cs b/src/Orchestration/ConversationCompactor.cs index b9acd1a..6519282 100644 --- a/src/Orchestration/ConversationCompactor.cs +++ b/src/Orchestration/ConversationCompactor.cs @@ -27,7 +27,9 @@ public sealed class ConversationCompactor( string? changeLogPath = null, IntentLog? intentLog = null, string? eventsLogPath = null, - EvidenceStore? evidenceStore = null) + EvidenceStore? evidenceStore = null, + fuseraft.Infrastructure.ObjectiveManager? objectiveManager = null, + fuseraft.Infrastructure.KnowledgeSnapshotEnricher? knowledgeEnricher = null) { // Tracks savings ratios from the last AntiThrashWindow compactions so we can detect // conversations that are thrashing (repeatedly compacting but saving very little). @@ -160,7 +162,8 @@ public IReadOnlyList<AgentMessage> TrimToWindow(IReadOnlyList<AgentMessage> mess toCompact[0].TurnIndex, toCompact[^1].TurnIndex); var reasoningBlock = BuildReasoningBlock(reasoningExcerpts); var symbolBlock = await BuildSymbolGraphBlockAsync(cancellationToken); - var prefixBlock = CombineBlocks(symbolBlock, reasoningBlock); + var objectiveBlock = await BuildObjectiveBlockAsync(cancellationToken); + var prefixBlock = CombineBlocks(CombineBlocks(symbolBlock, objectiveBlock), reasoningBlock); // Intent mode: reconstruct from the intent log — fully deterministic, no LLM call. // When the intent log is unavailable, record a visible fallback notice so agents @@ -198,7 +201,9 @@ public IReadOnlyList<AgentMessage> TrimToWindow(IReadOnlyList<AgentMessage> mess // Lossless: skip LLM call entirely; rebuild from durable state. if ((mode == "lossless" || mode == "intent") && snapshotter is not null) { - var snapshot = await snapshotter.SnapshotAsync(cancellationToken); + var snapshot = await snapshotter.SnapshotAsync(cancellationToken); + if (knowledgeEnricher is not null) + snapshot = await knowledgeEnricher.EnrichAsync(snapshot, cancellationToken); var reconstructed = ContextRebuilder.BuildContextMessage(snapshot, toCompact[^1].TurnIndex); if (!string.IsNullOrEmpty(prefixBlock)) reconstructed = reconstructed with @@ -221,7 +226,9 @@ public IReadOnlyList<AgentMessage> TrimToWindow(IReadOnlyList<AgentMessage> mess // Hybrid: prepend reconstruction before the LLM summary. if (mode == "hybrid" && snapshotter is not null) { - var snapshot = await snapshotter.SnapshotAsync(cancellationToken); + var snapshot = await snapshotter.SnapshotAsync(cancellationToken); + if (knowledgeEnricher is not null) + snapshot = await knowledgeEnricher.EnrichAsync(snapshot, cancellationToken); var reconstructed = ContextRebuilder.BuildContextMessage(snapshot, toCompact[^1].TurnIndex); try @@ -646,6 +653,17 @@ private static string BuildReasoningBlock(IReadOnlyList<(int Turn, string Agent, // Combines symbolBlock and reasoningBlock into a single prefix, separated by a divider // when both are non-empty. Symbol graph comes first so the dependency map frames the // reasoning excerpts that follow. + private async Task<string> BuildObjectiveBlockAsync(CancellationToken ct) + { + if (objectiveManager is null) return string.Empty; + try + { + var summary = await objectiveManager.BuildActiveSummaryAsync(ct); + return summary ?? string.Empty; + } + catch { return string.Empty; } + } + private static string CombineBlocks(string symbolBlock, string reasoningBlock) { if (string.IsNullOrEmpty(symbolBlock) && string.IsNullOrEmpty(reasoningBlock)) diff --git a/src/Orchestration/DependencyPlanner.cs b/src/Orchestration/DependencyPlanner.cs new file mode 100644 index 0000000..3db7191 --- /dev/null +++ b/src/Orchestration/DependencyPlanner.cs @@ -0,0 +1,213 @@ +using fuseraft.Core.Models; + +namespace fuseraft.Orchestration; + +/// <summary> +/// Optional scheduling layer that enforces <c>Produces</c>/<c>Requires</c> token dependencies +/// declared on <see cref="AgentConfig"/> entries. +/// +/// <para> +/// Construction validates the dependency graph (cycle detection via topological sort) and throws +/// <see cref="InvalidOperationException"/> when a cycle is detected. The planner is activated +/// only when at least one agent declares <c>Produces</c> or <c>Requires</c>. +/// </para> +/// +/// <para> +/// During a session: +/// <list type="number"> +/// <item>Call <see cref="CanExecute"/> to check whether an agent's prerequisites are satisfied.</item> +/// <item>Call <see cref="Fulfill"/> after an agent turn completes to add its produced tokens to the fulfilled set.</item> +/// <item>Read <see cref="FulfilledTokens"/> from validators or context assembly for observable state.</item> +/// </list> +/// </para> +/// </summary> +public sealed class DependencyPlanner +{ + private readonly IReadOnlyList<AgentConfig> _agents; + private readonly HashSet<string> _fulfilled = new(StringComparer.OrdinalIgnoreCase); + private readonly object _lock = new(); + + /// <summary> + /// Grouped layers of agent names that can execute in parallel within each layer. + /// Agents in layer 0 have no requirements; agents in layer N require at least one + /// token produced by layer N-1 or earlier. + /// </summary> + public IReadOnlyList<IReadOnlyList<string>> ExecutionLayers { get; } + + /// <summary> + /// Flat topological execution order derived from <see cref="ExecutionLayers"/>. + /// </summary> + public IReadOnlyList<string> TopologicalOrder { get; } + + /// <summary> + /// True when at least one agent declares <c>Produces</c> or <c>Requires</c>. + /// When false the planner is a no-op and should not affect routing. + /// </summary> + public bool HasDependencies { get; } + + /// <summary> + /// The current set of fulfilled tokens, updated by <see cref="Fulfill"/>. + /// </summary> + public IReadOnlySet<string> FulfilledTokens + { + get { lock (_lock) return _fulfilled.ToHashSet(StringComparer.OrdinalIgnoreCase); } + } + + /// <summary> + /// Fired whenever a new token is added to the fulfilled set. + /// </summary> + public event Action<string>? TokenFulfilled; + + public DependencyPlanner(IReadOnlyList<AgentConfig> agents) + { + _agents = agents; + + HasDependencies = agents.Any(a => a.Produces.Count > 0 || a.Requires.Count > 0); + + (ExecutionLayers, TopologicalOrder) = HasDependencies + ? BuildAndValidate(agents) + : ([], []); + } + + /// <summary> + /// Returns true when all <c>Requires</c> tokens for <paramref name="agentName"/> are + /// present in the fulfilled set. Always returns true for agents with no <c>Requires</c>. + /// </summary> + public bool CanExecute(string agentName) + { + var cfg = _agents.FirstOrDefault(a => + string.Equals(a.Name, agentName, StringComparison.OrdinalIgnoreCase)); + if (cfg is null || cfg.Requires.Count == 0) return true; + + lock (_lock) + return cfg.Requires.All(r => _fulfilled.Contains(r)); + } + + /// <summary> + /// Returns agents whose <c>Requires</c> are fully satisfied by the current fulfilled set. + /// </summary> + public IReadOnlyList<AgentConfig> GetEligible() + { + lock (_lock) + return _agents.Where(a => a.Requires.All(r => _fulfilled.Contains(r))).ToList(); + } + + /// <summary> + /// Marks all <c>Produces</c> tokens declared by <paramref name="agentName"/> as fulfilled. + /// </summary> + public void Fulfill(string agentName) + { + var cfg = _agents.FirstOrDefault(a => + string.Equals(a.Name, agentName, StringComparison.OrdinalIgnoreCase)); + if (cfg is null || cfg.Produces.Count == 0) return; + + foreach (var token in cfg.Produces) + { + bool added; + lock (_lock) added = _fulfilled.Add(token); + if (added) TokenFulfilled?.Invoke(token); + } + } + + /// <summary> + /// Returns a human-readable list of unmet <c>Requires</c> tokens for <paramref name="agentName"/>. + /// Returns an empty list when all prerequisites are satisfied. + /// </summary> + public IReadOnlyList<string> GetUnmetRequirements(string agentName) + { + var cfg = _agents.FirstOrDefault(a => + string.Equals(a.Name, agentName, StringComparison.OrdinalIgnoreCase)); + if (cfg is null || cfg.Requires.Count == 0) return []; + + lock (_lock) + return cfg.Requires.Where(r => !_fulfilled.Contains(r)).ToList(); + } + + // Builds the execution layers via Kahn's topological sort. Throws on cycles. + private static (IReadOnlyList<IReadOnlyList<string>> Layers, IReadOnlyList<string> Order) + BuildAndValidate(IReadOnlyList<AgentConfig> agents) + { + // Map each token to the set of agent names that produce it. + var producerMap = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase); + foreach (var agent in agents) + { + foreach (var token in agent.Produces) + { + if (!producerMap.TryGetValue(token, out var list)) + producerMap[token] = list = []; + list.Add(agent.Name); + } + } + + // Build adjacency list: producer → consumer (edge: producer must run before consumer). + var inDegree = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase); + var adjacency = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase); + + foreach (var agent in agents) + { + inDegree.TryAdd(agent.Name, 0); + adjacency.TryAdd(agent.Name, []); + } + + foreach (var consumer in agents) + { + foreach (var req in consumer.Requires) + { + if (!producerMap.TryGetValue(req, out var producers)) continue; + + foreach (var producerName in producers) + { + if (string.Equals(producerName, consumer.Name, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException( + $"Agent '{consumer.Name}' both produces and requires token '{req}' — self-dependency is not allowed."); + + adjacency[producerName].Add(consumer.Name); + inDegree[consumer.Name]++; + } + } + } + + // Kahn's algorithm — processes agents in topological layers. + var queue = new Queue<string>(); + var layers = new List<IReadOnlyList<string>>(); + var order = new List<string>(); + + var currentInDegree = new Dictionary<string, int>(inDegree, StringComparer.OrdinalIgnoreCase); + foreach (var kv in currentInDegree.Where(kv => kv.Value == 0)) + queue.Enqueue(kv.Key); + + while (queue.Count > 0) + { + // All agents currently in the queue form one parallel layer. + var layer = new List<string>(); + int count = queue.Count; + for (int i = 0; i < count; i++) + { + var node = queue.Dequeue(); + layer.Add(node); + order.Add(node); + + foreach (var neighbor in adjacency[node]) + { + if (--currentInDegree[neighbor] == 0) + queue.Enqueue(neighbor); + } + } + layers.Add(layer); + } + + if (order.Count != agents.Count) + { + // Find the cycle participants for the error message. + var inCycle = agents + .Select(a => a.Name) + .Except(order, StringComparer.OrdinalIgnoreCase) + .ToList(); + throw new InvalidOperationException( + $"Dependency cycle detected among agents: {string.Join(", ", inCycle.Select(n => $"'{n}'"))}. " + + "Verify that no agent's Requires token is only produced by agents that depend on it (directly or transitively)."); + } + + return (layers, order); + } +} diff --git a/src/Orchestration/HandoffContextResolver.cs b/src/Orchestration/HandoffContextResolver.cs index b730bf9..366f23f 100644 --- a/src/Orchestration/HandoffContextResolver.cs +++ b/src/Orchestration/HandoffContextResolver.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.AI; using fuseraft.Core; using fuseraft.Core.Models; +using fuseraft.Infrastructure; namespace fuseraft.Orchestration; @@ -28,6 +29,10 @@ public sealed class ContextAssembler private readonly string? _sandboxRoot; private readonly string? _changeLogPath; private readonly string? _briefPath; + private readonly RepositoryGraphStore? _graphStore; + private readonly AdrRegistry? _adrRegistry; + private readonly fuseraft.Infrastructure.ObjectiveManager? _objectiveManager; + private readonly ContextBroker? _contextBroker; private string _sessionId = string.Empty; @@ -45,11 +50,19 @@ public sealed class ContextAssembler public ContextAssembler( string? sandboxRoot = null, string? changeLogPath = null, - string? briefPath = null) + string? briefPath = null, + RepositoryGraphStore? graphStore = null, + AdrRegistry? adrRegistry = null, + fuseraft.Infrastructure.ObjectiveManager? objectiveManager = null, + ContextBroker? contextBroker = null) { - _sandboxRoot = sandboxRoot; - _changeLogPath = changeLogPath; - _briefPath = briefPath; + _sandboxRoot = sandboxRoot; + _changeLogPath = changeLogPath; + _briefPath = briefPath; + _graphStore = graphStore; + _adrRegistry = adrRegistry; + _objectiveManager = objectiveManager; + _contextBroker = contextBroker; } public void SetSessionId(string sessionId) => _sessionId = sessionId; @@ -194,16 +207,95 @@ public async Task<IReadOnlyList<ChatMessage>> AssembleForAgentAsync( var (type, param) = ParseSource(src.Source); return type switch { - "session_context" => await ResolveSessionContextAsync(ct), - "changes_recent" => await ResolveChangesRecentAsync( - int.TryParse(param, out var n) ? Math.Max(1, n) : 3, - maxChars, ct), - "brief_field" => await ResolveBriefFieldAsync(param ?? string.Empty, maxChars, ct), - "file" => await ResolveFileAsync(param ?? string.Empty, maxChars, ct), - _ => null, + "session_context" => await ResolveSessionContextAsync(ct), + "changes_recent" => await ResolveChangesRecentAsync( + int.TryParse(param, out var n) ? Math.Max(1, n) : 3, + maxChars, ct), + "brief_field" => await ResolveBriefFieldAsync(param ?? string.Empty, maxChars, ct), + "file" => await ResolveFileAsync(param ?? string.Empty, maxChars, ct), + "adr_graph" => await ResolveAdrGraphAsync(maxChars, ct), + "active_objectives" => await ResolveActiveObjectivesAsync(maxChars, ct), + "broker" => await ResolveBrokerAsync(param ?? string.Empty, maxChars, ct), + _ => null, }; } + private async Task<string?> ResolveBrokerAsync(string query, int maxChars, CancellationToken ct) + { + if (_contextBroker is null) return null; + try { return await _contextBroker.ResolveAsync(query, maxChars, ct); } + catch { return null; } + } + + private async Task<string?> ResolveActiveObjectivesAsync(int maxChars, CancellationToken ct) + { + if (_objectiveManager is null) return null; + try + { + var summary = await _objectiveManager.BuildActiveSummaryAsync(ct); + return summary is null ? null : Truncate(summary, maxChars); + } + catch { return null; } + } + + // Walks adr_governs edges in the repository graph for every file recently touched + // in this session. Returns a formatted block of governing ADR IDs and titles. + private async Task<string?> ResolveAdrGraphAsync(int maxChars, CancellationToken ct) + { + if (_graphStore is null || _adrRegistry is null) return null; + try + { + // Collect recently written files from the change log. + var touchedFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + var logPath = _changeLogPath ?? FuseraftPaths.LocalChanges; + if (File.Exists(logPath)) + { + try + { + var raw = await File.ReadAllTextAsync(logPath, ct); + var log = JsonSerializer.Deserialize<ChangeLog>(raw, JsonOpts); + if (log is not null) + { + foreach (var entry in log.Entries + .Where(e => string.IsNullOrEmpty(_sessionId) || e.SessionId == _sessionId) + .TakeLast(20)) + { + foreach (var f in entry.FilesWritten) + touchedFiles.Add(f.Replace('\\', '/')); + } + } + } + catch { /* best-effort */ } + } + if (touchedFiles.Count == 0) return null; + + // Load the graph and find ADR nodes governing any of the touched files. + var graph = await _graphStore.LoadAsync(ct); + var adrIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + foreach (var filePath in touchedFiles) + { + var fileId = $"file:{filePath}"; + // Walk adr_governs edges: ADR node --adr_governs--> file/symbol node + foreach (var edge in graph.EdgesTo(fileId, EdgeType.AdrGoverns)) + adrIds.Add(edge.From.StartsWith("adr:") ? edge.From[4..] : edge.From); + } + if (adrIds.Count == 0) return null; + + var sb = new StringBuilder(); + sb.AppendLine("Governing architecture decisions for recently touched files:"); + foreach (var id in adrIds) + { + var entry = await _adrRegistry.GetByIdAsync(id, ct); + if (entry is not null) + sb.AppendLine($" [{entry.Id}] {entry.Title} (status: {entry.Status})"); + else + sb.AppendLine($" [{id}]"); + } + return Truncate(sb.ToString().TrimEnd(), maxChars); + } + catch { return null; } + } + private async Task<string?> ResolveSessionContextAsync(CancellationToken ct) { var path = FuseraftPaths.ExpandSessionId(FuseraftPaths.LocalSessionContext, _sessionId); @@ -377,7 +469,10 @@ private static string DefaultLabel(string source) "changes_recent" => "Recent Changes", "brief_field" => $"Task: {param}", "file" => param is not null ? Path.GetFileName(param) : "File", - _ => source, + "adr_graph" => "Governing ADRs", + "active_objectives" => "Active Objectives", + "broker" => string.IsNullOrEmpty(param) ? "Adaptive Context" : $"Adaptive Context: {param}", + _ => source, }; } diff --git a/src/Orchestration/IntentAnalyzer.cs b/src/Orchestration/IntentAnalyzer.cs new file mode 100644 index 0000000..145b344 --- /dev/null +++ b/src/Orchestration/IntentAnalyzer.cs @@ -0,0 +1,106 @@ +namespace fuseraft.Orchestration; + +/// <summary> +/// Signals extracted from a task description by <see cref="IntentAnalyzer"/>. +/// </summary> +public sealed record IntentSignals +{ + /// <summary>Significant domain terms after stop-word filtering.</summary> + public IReadOnlyList<string> Keywords { get; init; } = []; + + /// <summary>PascalCase identifiers likely to be type or method names.</summary> + public IReadOnlyList<string> ReferencedSymbols { get; init; } = []; + + /// <summary>Failure-related tokens adjacent to error keywords in the task text.</summary> + public IReadOnlyList<string> FailurePatterns { get; init; } = []; + + public bool IsEmpty => + Keywords.Count == 0 && ReferencedSymbols.Count == 0 && FailurePatterns.Count == 0; +} + +/// <summary> +/// Extracts intent signals from a task or brief description for use by +/// <see cref="KnowledgeRetriever"/> when querying the knowledge layer. +/// +/// <para>Three signal classes are extracted:</para> +/// <list type="bullet"> +/// <item><b>Keywords</b> — Significant domain terms after stop-word filtering.</item> +/// <item><b>ReferencedSymbols</b> — PascalCase identifiers likely to be type/method names.</item> +/// <item><b>FailurePatterns</b> — Failure-related tokens adjacent to error keywords.</item> +/// </list> +/// </summary> +public static class IntentAnalyzer +{ + private static readonly HashSet<string> StopWords = new(StringComparer.OrdinalIgnoreCase) + { + "a", "an", "the", "and", "or", "but", "for", "nor", "on", "at", "to", "by", + "in", "of", "is", "it", "its", "as", "be", "do", "if", "no", "so", "we", + "us", "our", "my", "your", "this", "that", "with", "from", "into", "have", + "has", "had", "not", "all", "any", "was", "are", "will", "can", "may", + "use", "used", "using", "when", "then", "than", "get", "set", "new", "add", + "run", "file", "path", "type", "name", "value", "data", "true", "false", + "null", "void", "var", "let", "out", "ref", "via", "also", "each", "per", + }; + + private static readonly HashSet<string> FailureKeywords = new(StringComparer.OrdinalIgnoreCase) + { + "error", "fail", "failed", "failure", "broken", "crash", "exception", "invalid", + "missing", "undefined", "wrong", "unexpected", "bug", "issue", "problem", + }; + + private static readonly char[] Delimiters = + [' ', '\t', '\n', '\r', ',', ';', ':', '.', '(', ')', '[', ']', + '{', '}', '"', '\'', '`', '/', '\\', '=', '<', '>', '!', '?', + '@', '#', '*', '+', '-', '&', '|', '^', '%']; + + /// <summary>Extracts intent signals from <paramref name="task"/>.</summary> + public static IntentSignals Analyze(string? task) + { + if (string.IsNullOrWhiteSpace(task)) + return new IntentSignals(); + + var words = task.Split(Delimiters, + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + return new IntentSignals + { + Keywords = ExtractKeywords(words), + ReferencedSymbols = ExtractSymbols(words), + FailurePatterns = ExtractFailurePatterns(words), + }; + } + + private static IReadOnlyList<string> ExtractKeywords(string[] words) => + words + .Where(w => w.Length > 2 && !StopWords.Contains(w) && !IsPascalCase(w)) + .Select(w => w.ToLowerInvariant()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(15) + .ToList(); + + private static IReadOnlyList<string> ExtractSymbols(string[] words) => + words + .Where(IsPascalCase) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(10) + .ToList(); + + private static IReadOnlyList<string> ExtractFailurePatterns(string[] words) + { + var patterns = new List<string>(); + for (int i = 0; i < words.Length; i++) + { + if (!FailureKeywords.Contains(words[i])) continue; + + patterns.Add(words[i].ToLowerInvariant()); + if (i + 1 < words.Length && IsPascalCase(words[i + 1])) + patterns.Add(words[i + 1]); + if (i > 0 && IsPascalCase(words[i - 1])) + patterns.Add(words[i - 1]); + } + return patterns.Distinct(StringComparer.OrdinalIgnoreCase).Take(10).ToList(); + } + + private static bool IsPascalCase(string word) => + word.Length >= 2 && char.IsUpper(word[0]) && word.Any(char.IsLower); +} diff --git a/src/Orchestration/KnowledgeRetriever.cs b/src/Orchestration/KnowledgeRetriever.cs new file mode 100644 index 0000000..ef3c4a0 --- /dev/null +++ b/src/Orchestration/KnowledgeRetriever.cs @@ -0,0 +1,137 @@ +using fuseraft.Core; +using fuseraft.Core.Models; +using fuseraft.Infrastructure; + +namespace fuseraft.Orchestration; + +/// <summary> +/// A knowledge result enriched with provenance metadata for ranking by <see cref="ContextBudgeter"/>. +/// </summary> +public sealed record RetrievedItem +{ + public required KnowledgeResult Result { get; init; } + + /// <summary>Most recent claim for this artifact, or <c>null</c> when no provenance exists.</summary> + public ClaimRecord? Provenance { get; init; } + + /// <summary> + /// <c>true</c> when <see cref="Provenance"/> exists and its <c>ExpiresAt</c> is in the past. + /// Expired items are excluded from broker output by <see cref="ContextBudgeter"/>. + /// </summary> + public bool IsExpired { get; init; } + + /// <summary>Effective confidence tier from provenance status, or <c>"Guessed"</c> when absent.</summary> + public string ConfidenceTier => Provenance?.Status ?? "Guessed"; +} + +/// <summary> +/// Queries <see cref="IKnowledgeLayer"/> and the repository memory store using +/// <see cref="IntentSignals"/> and returns deduplicated, provenance-enriched results. +/// </summary> +public sealed class KnowledgeRetriever +{ + private readonly IKnowledgeLayer _layer; + private readonly RepositoryMemoryStore? _memoryStore; + private readonly ProvenanceRegistry? _provenance; + + public KnowledgeRetriever( + IKnowledgeLayer layer, + RepositoryMemoryStore? memoryStore = null, + ProvenanceRegistry? provenance = null) + { + _layer = layer; + _memoryStore = memoryStore; + _provenance = provenance; + } + + /// <summary> + /// Queries the knowledge layer for each signal in <paramref name="signals"/>, + /// deduplicates by ID, and enriches each result with its provenance record. + /// </summary> + public async Task<IReadOnlyList<RetrievedItem>> RetrieveAsync( + IntentSignals signals, + CancellationToken ct = default) + { + var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + var results = new List<RetrievedItem>(); + + // Symbols are more precise than keywords; try them first so deduplication + // keeps the higher-quality match when both queries hit the same artifact. + var queries = signals.ReferencedSymbols + .Concat(signals.Keywords) + .Concat(signals.FailurePatterns) + .Where(q => !string.IsNullOrWhiteSpace(q)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(12) + .ToList(); + + foreach (var q in queries) + { + IEnumerable<KnowledgeResult> batch; + try { batch = await _layer.SearchAsync(q, ct: ct); } + catch { continue; } + + foreach (var r in batch) + { + if (!seen.Add(r.Id)) continue; + + ClaimRecord? provenance = null; + bool expired = false; + + if (_provenance is not null) + { + try + { + provenance = await _provenance.GetByArtifactAsync(r.Id, ct); + if (provenance?.ExpiresAt.HasValue == true && + provenance.ExpiresAt!.Value < DateTimeOffset.UtcNow) + expired = true; + } + catch { /* best-effort */ } + } + + results.Add(new RetrievedItem + { + Result = r, + Provenance = provenance, + IsExpired = expired, + }); + } + } + + // Repository memory: approved patterns relevant to any query term. + if (_memoryStore is not null && queries.Count > 0) + { + try + { + var memories = await _memoryStore.LoadApprovedAsync(ct); + foreach (var mem in memories) + { + var memId = $"repository-memory:{mem.Id}"; + if (!seen.Add(memId)) continue; + + bool relevant = queries.Any(q => + mem.Pattern.Contains(q, StringComparison.OrdinalIgnoreCase)); + if (!relevant) continue; + + results.Add(new RetrievedItem + { + Result = new KnowledgeResult + { + Id = memId, + Kind = KnowledgeKind.Memory, + Title = mem.Pattern.Length > 80 ? mem.Pattern[..80] + "…" : mem.Pattern, + Summary = $"Reinforced {mem.ReinforcementCount}× — confidence: {mem.Confidence}", + Status = mem.Status, + }, + Provenance = null, + IsExpired = false, + }); + } + } + catch { /* best-effort */ } + } + + return results; + } +} diff --git a/src/Orchestration/Strategies/StrategyFactory.cs b/src/Orchestration/Strategies/StrategyFactory.cs index 86a2d8c..a22c716 100644 --- a/src/Orchestration/Strategies/StrategyFactory.cs +++ b/src/Orchestration/Strategies/StrategyFactory.cs @@ -6,6 +6,7 @@ using fuseraft.Core; using fuseraft.Core.Interfaces; using fuseraft.Core.Models; +using fuseraft.Infrastructure; using fuseraft.Orchestration.Contracts; using fuseraft.Orchestration.Validation; using fuseraft.Orchestration; @@ -15,12 +16,13 @@ namespace fuseraft.Orchestration.Strategies; /// <summary> /// Builds agent selection and termination strategies from configuration. /// </summary> -public sealed class StrategyFactory(Func<ModelConfig, IChatClient> createChatClient, EventEmitter? eventEmitter = null, ILoggerFactory? loggerFactory = null, GovernanceKernel? governanceKernel = null, IHumanApprovalService? humanApprovalService = null, EvidenceStore? evidenceStore = null, TestSelectorConfig? testSelector = null, string? sandboxRoot = null, ContextAssembler? contextAssembler = null) +public sealed class StrategyFactory(Func<ModelConfig, IChatClient> createChatClient, EventEmitter? eventEmitter = null, ILoggerFactory? loggerFactory = null, GovernanceKernel? governanceKernel = null, IHumanApprovalService? humanApprovalService = null, EvidenceStore? evidenceStore = null, ProvenanceRegistry? provenanceRegistry = null, TestSelectorConfig? testSelector = null, string? sandboxRoot = null, ContextAssembler? contextAssembler = null) { private readonly EventEmitter? _eventEmitter = eventEmitter; private readonly GovernanceKernel? _governanceKernel = governanceKernel; private readonly IHumanApprovalService? _humanApprovalService = humanApprovalService; private readonly EvidenceStore? _evidenceStore = evidenceStore; + private readonly ProvenanceRegistry? _provenanceRegistry = provenanceRegistry; private readonly TestSelectorConfig? _testSelector = testSelector; private readonly string? _sandboxRoot = sandboxRoot; private readonly ContextAssembler? _contextAssembler = contextAssembler; @@ -78,7 +80,7 @@ private KeywordSelectionStrategy CreateKeywordSelection( if (config.Routes is not { Count: > 0 }) throw new InvalidOperationException("Keyword selection strategy requires at least one entry in 'Routes'."); - var validators = BuildValidators(validationConfig, testSelector: _testSelector, sandboxRoot: _sandboxRoot); + var validators = BuildValidators(validationConfig, testSelector: _testSelector, sandboxRoot: _sandboxRoot, provenanceRegistry: _provenanceRegistry); // Build the contract engine once — shared across all routes that reference contracts. ContractEngine? contractEngine = contracts is { Count: > 0 } @@ -235,7 +237,8 @@ private static Dictionary<string, IRoutingValidator> BuildValidators( ValidationConfig? config, bool isTermination = false, TestSelectorConfig? testSelector = null, - string? sandboxRoot = null) + string? sandboxRoot = null, + ProvenanceRegistry? provenanceRegistry = null) { var registry = new Dictionary<string, IRoutingValidator>(StringComparer.OrdinalIgnoreCase) { @@ -244,7 +247,8 @@ private static Dictionary<string, IRoutingValidator> BuildValidators( // entry from an earlier turn satisfying the check when APPROVED fires. ["RequireShellPass"] = new RequireShellPassValidator( changeLogPath: config?.ChangeLogPath, - requireCurrentTurn: isTermination) + requireCurrentTurn: isTermination, + provenanceRegistry: provenanceRegistry) }; if (config is not null) @@ -272,9 +276,14 @@ private static Dictionary<string, IRoutingValidator> BuildValidators( registry["RequireRelatedTestsPass"] = new RequireRelatedTestsPassValidator( testSelector, changeLogPath: config?.ChangeLogPath, - sandboxRoot: sandboxRoot); + sandboxRoot: sandboxRoot, + provenanceRegistry: provenanceRegistry); } + registry["ArchitectureValidator"] = new ArchitectureValidator( + projectRoot: sandboxRoot, + provenanceRegistry: provenanceRegistry); + return registry; } @@ -301,7 +310,7 @@ public ITerminationCondition CreateTermination( if (validatorNames is not null && config.Type != "maxiterations") { - var validatorRegistry = BuildValidators(validationConfig, isTermination: true, testSelector: _testSelector, sandboxRoot: _sandboxRoot); + var validatorRegistry = BuildValidators(validationConfig, isTermination: true, testSelector: _testSelector, sandboxRoot: _sandboxRoot, provenanceRegistry: _provenanceRegistry); var validatorList = validatorNames .Select(name => validatorRegistry.TryGetValue(name, out var v) ? v : null) .Where(v => v is not null) diff --git a/src/Orchestration/Validation/ArchitectureValidator.cs b/src/Orchestration/Validation/ArchitectureValidator.cs new file mode 100644 index 0000000..f681935 --- /dev/null +++ b/src/Orchestration/Validation/ArchitectureValidator.cs @@ -0,0 +1,94 @@ +using Microsoft.Extensions.AI; +using fuseraft.Core; +using fuseraft.Core.Interfaces; +using fuseraft.Core.Models; +using fuseraft.Infrastructure; + +namespace fuseraft.Orchestration.Validation; + +/// <summary> +/// Routing validator that blocks a handoff when architecture layer violations are +/// present in the project source tree. +/// +/// <para> +/// Loads the manifest from <c>.fuseraft/architecture.yaml</c> (or the path supplied at +/// construction) and delegates scanning to <see cref="ArchitectureScanner"/>. The +/// <paramref name="history"/> argument is not consulted — this validator checks current +/// filesystem state, not agent conversation content. +/// </para> +/// +/// <para> +/// When no manifest file exists the validator passes unconditionally, so projects that +/// have not yet defined an architecture manifest are unaffected. +/// </para> +/// </summary> +public sealed class ArchitectureValidator( + string? manifestPath = null, + string? projectRoot = null, + EvidenceStore? evidenceStore = null, + ProvenanceRegistry? provenanceRegistry = null) : IRoutingValidator +{ + private readonly string _manifestPath = manifestPath ?? FuseraftPaths.LocalArchitectureManifest; + private readonly string _projectRoot = projectRoot ?? Directory.GetCurrentDirectory(); + + public async Task<RoutingValidationResult> ValidateAsync( + IList<ChatMessage> history, + CancellationToken ct = default) + { + var manifest = ArchitectureScanner.TryLoadManifest(_manifestPath); + if (manifest is null) + return RoutingValidationResult.Pass(); + + var violations = await ArchitectureScanner.ScanAsync(manifest, _projectRoot, ct); + + if (violations.Count > 0) + { + await EmitViolationNodesAsync(violations, ct); + + var lines = violations + .Take(10) + .Select(v => $" {v.File}:{v.Line} — {v.SourceLayer} → {v.TargetLayer} ({v.Namespace})"); + + var summary = string.Join("\n", lines); + if (violations.Count > 10) + summary += $"\n … and {violations.Count - 10} more violation(s)"; + + return RoutingValidationResult.Fail( + $"Architecture violations detected ({violations.Count}):\n{summary}\n\n" + + "Fix the illegal dependencies before handing off."); + } + + if (provenanceRegistry is not null) + { + var claim = new ClaimRecord + { + Claim = "No architecture layer violations detected", + Support = [EvidenceClass.Validator], + }; + try { await provenanceRegistry.RecordAsync(claim, ct); } + catch { /* best-effort */ } + } + + return RoutingValidationResult.Pass(); + } + + private async Task EmitViolationNodesAsync( + IReadOnlyList<ArchitectureViolation> violations, + CancellationToken ct) + { + if (evidenceStore is null) return; + + var nodes = violations.Select(v => new EvidenceNode + { + NodeType = "Violation", + Agent = "ArchitectureValidator", + Path = v.File, + SymbolName = v.Namespace, + Evidence = $"{v.SourceLayer} → {v.TargetLayer}", + Status = "FAIL", + }).ToList(); + + try { await evidenceStore.RecordAsync(nodes, ct: ct); } + catch { /* best-effort */ } + } +} diff --git a/src/Orchestration/Validation/RequireRelatedTestsPassValidator.cs b/src/Orchestration/Validation/RequireRelatedTestsPassValidator.cs index 565f128..b1afc4f 100644 --- a/src/Orchestration/Validation/RequireRelatedTestsPassValidator.cs +++ b/src/Orchestration/Validation/RequireRelatedTestsPassValidator.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.AI; using fuseraft.Core.Interfaces; using fuseraft.Core.Models; +using fuseraft.Infrastructure; using fuseraft.Infrastructure.Plugins; namespace fuseraft.Orchestration.Validation; @@ -23,7 +24,8 @@ namespace fuseraft.Orchestration.Validation; public sealed class RequireRelatedTestsPassValidator( TestSelectorConfig testSelector, string? changeLogPath = null, - string? sandboxRoot = null) : IRoutingValidator + string? sandboxRoot = null, + ProvenanceRegistry? provenanceRegistry = null) : IRoutingValidator { private static readonly JsonSerializerOptions JsonOpts = new() { @@ -69,6 +71,18 @@ public async Task<RoutingValidationResult> ValidateAsync( TrimOutput(result.Stdout, result.Stderr)); } + if (provenanceRegistry is not null) + { + var record = new ClaimRecord + { + Claim = $"Targeted tests passed: {testCommand}", + // TestResult + ExitCode → Verified + Support = [EvidenceClass.TestResult, EvidenceClass.ExitCode], + }; + try { await provenanceRegistry.RecordAsync(record, cancellationToken); } + catch { /* best-effort */ } + } + return RoutingValidationResult.Pass(); } diff --git a/src/Orchestration/Validation/RequireShellPassValidator.cs b/src/Orchestration/Validation/RequireShellPassValidator.cs index 16d79c2..320abf2 100644 --- a/src/Orchestration/Validation/RequireShellPassValidator.cs +++ b/src/Orchestration/Validation/RequireShellPassValidator.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging; using fuseraft.Core.Interfaces; using fuseraft.Core.Models; +using fuseraft.Infrastructure; namespace fuseraft.Orchestration.Validation; @@ -31,7 +32,8 @@ public sealed class RequireShellPassValidator( string? requiredCommandPattern = null, string? changeLogPath = null, bool requireCurrentTurn = false, - ILogger<RequireShellPassValidator>? logger = null) : IRoutingValidator + ILogger<RequireShellPassValidator>? logger = null, + ProvenanceRegistry? provenanceRegistry = null) : IRoutingValidator { private static readonly JsonSerializerOptions JsonOpts = new() { @@ -49,7 +51,7 @@ public async Task<RoutingValidationResult> ValidateAsync( // - hitBoundary = true → a user message was reached before finding a shell pass, // meaning the current turn definitely had no shell run. var (shellPass, hitBoundary) = ScanHistory(history); - if (shellPass) return RoutingValidationResult.Pass(); + if (shellPass) return await PassWithClaimAsync("Shell command completed successfully (current turn)", cancellationToken); // When requireCurrentTurn is true (typically used for termination validators) and // a user boundary was found, the current turn had no shell run — do not consult @@ -70,7 +72,7 @@ public async Task<RoutingValidationResult> ValidateAsync( " 2. Emit the handoff keyword in the same response."); } - return RoutingValidationResult.Pass(); + return await PassWithClaimAsync("Shell command completed successfully (change log)", cancellationToken); } // Change-log check — reads the most recent entry for the active session and checks @@ -107,6 +109,22 @@ private async Task<bool> CheckChangeLogAsync(string logPath, CancellationToken c } } + // Emits a ClaimRecord to ProvenanceRegistry (if wired) and returns Pass(). + private async Task<RoutingValidationResult> PassWithClaimAsync(string claimText, CancellationToken ct) + { + if (provenanceRegistry is not null) + { + var record = new ClaimRecord + { + Claim = claimText, + Support = [EvidenceClass.ExitCode], + }; + try { await provenanceRegistry.RecordAsync(record, ct); } + catch { /* best-effort */ } + } + return RoutingValidationResult.Pass(); + } + // History scan — returns (shellPass, hitBoundary). // hitBoundary=true means we encountered a user message before finding a shell pass, // which definitively indicates the current agent turn had no successful shell run. diff --git a/src/Program.cs b/src/Program.cs index 070da11..2e173d8 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -13,6 +13,11 @@ using fuseraft.Cli.Commands.Log; using fuseraft.Cli.Commands.Repl; using fuseraft.Cli.Commands.Schedule; +using fuseraft.Cli.Commands.Arch; +using fuseraft.Cli.Commands.Knowledge; +using fuseraft.Cli.Commands.Objective; +using fuseraft.Cli.Commands.Graph; +using fuseraft.Cli.Commands.Memory; using fuseraft.Cli.Commands.Skills; using fuseraft.Core; using fuseraft.Core.Interfaces; @@ -134,6 +139,13 @@ services.AddTransient<LogReplCommand>(); services.AddTransient<LogAppCommand>(); services.AddTransient<UpdateCommand>(); +services.AddTransient<GraphBuildCommand>(); +services.AddTransient<MemoryReviewCommand>(); +services.AddTransient<ArchCheckCommand>(); +services.AddTransient<KnowledgeGcCommand>(); +services.AddTransient<ObjectiveCreateCommand>(); +services.AddTransient<ObjectiveListCommand>(); +services.AddTransient<ObjectiveStatusCommand>(); // Use CommandApp<ReplCommand> so bare `fuseraft` drops straight into the REPL. var registrar = new ServiceCollectionRegistrar(services); @@ -328,6 +340,68 @@ .WithDescription("Fetch the latest fuseraft release from GitHub and replace the running binary.") .WithExample(["update"]) .WithExample(["update", "--check"]); + + cfg.AddBranch("graph", branch => + { + branch.SetDescription("Repository semantic graph — index and query symbols across the codebase."); + + branch.AddCommand<GraphBuildCommand>("build") + .WithDescription("Scan the project and build (or rebuild) the repository semantic graph.") + .WithExample(["graph", "build"]) + .WithExample(["graph", "build", "--dir", "src/"]) + .WithExample(["graph", "build", "--output", ".fuseraft/state/repository.graph"]); + }); + + cfg.AddBranch("memory", branch => + { + branch.SetDescription("Repository memory — cross-session patterns extracted from evidence."); + + branch.AddCommand<MemoryReviewCommand>("review") + .WithDescription("Review candidate repository memories and approve or reject them.") + .WithExample(["memory", "review"]) + .WithExample(["memory", "review", "--all"]); + }); + + cfg.AddBranch("objective", branch => + { + branch.SetDescription("Long-horizon objective tracking across sessions."); + + branch.AddCommand<ObjectiveCreateCommand>("create") + .WithDescription("Create a new long-horizon objective.") + .WithExample(["objective", "create", "--title", "Ship knowledge layer", "--description", "Implement all gaps"]) + .WithExample(["objective", "create", "--title", "Refactor auth", "--tasks", "Design,Implement,Test"]); + + branch.AddCommand<ObjectiveListCommand>("list") + .WithDescription("List objectives, optionally filtered by status.") + .WithExample(["objective", "list"]) + .WithExample(["objective", "list", "--status", "Active"]); + + branch.AddCommand<ObjectiveStatusCommand>("status") + .WithDescription("Show detailed status and progress for a specific objective.") + .WithExample(["objective", "status", "OBJ-0001"]); + }); + + cfg.AddBranch("arch", branch => + { + branch.SetDescription("Architecture drift detection — check layer boundary compliance."); + + branch.AddCommand<ArchCheckCommand>("check") + .WithDescription("Scan source files for architecture layer violations.") + .WithExample(["arch", "check"]) + .WithExample(["arch", "check", "--manifest", ".fuseraft/architecture.yaml"]) + .WithExample(["arch", "check", "--dir", "src/"]); + }); + + cfg.AddBranch("knowledge", branch => + { + branch.SetDescription("Knowledge lifecycle management — archive, decay, and prune stale artifacts."); + + branch.AddCommand<KnowledgeGcCommand>("gc") + .WithDescription("Run knowledge lifecycle policies (dry-run by default; --apply to commit changes).") + .WithExample(["knowledge", "gc"]) + .WithExample(["knowledge", "gc", "--apply"]) + .WithExample(["knowledge", "gc", "--apply", "--lifecycle", ".fuseraft/knowledge/lifecycle.yaml"]); + }); }); try diff --git a/tests/FuseraftCli.Tests/KnowledgeLayerRoundTripTests.cs b/tests/FuseraftCli.Tests/KnowledgeLayerRoundTripTests.cs new file mode 100644 index 0000000..9a35ceb --- /dev/null +++ b/tests/FuseraftCli.Tests/KnowledgeLayerRoundTripTests.cs @@ -0,0 +1,446 @@ +using fuseraft.Core.Models; +using fuseraft.Infrastructure; +using fuseraft.Orchestration; + +namespace FuseraftCli.Tests; + +/// <summary> +/// Integration tests covering the full knowledge layer round-trip: +/// write evidence → query graph → traverse to ADR → broker assembles context → +/// validator emits claim → provenance recorded → lifecycle gc runs → nothing lost. +/// </summary> +public sealed class KnowledgeLayerRoundTripTests : IDisposable +{ + // All state lives in a per-test temp directory; nothing touches the real repo. + private readonly string _root; + private readonly string _src; + + private readonly AdrStore _adrStore; + private readonly AdrRegistry _adrRegistry; + private readonly RepositoryGraphStore _graphStore; + private readonly RepositoryGraphBuilder _graphBuilder; + private readonly ProvenanceRegistry _provenance; + private readonly RepositoryMemoryStore _memStore; + private readonly ObjectiveStore _objectiveStore; + private readonly KnowledgeLayer _knowledgeLayer; + + public KnowledgeLayerRoundTripTests() + { + _root = Path.Combine(Path.GetTempPath(), $"fuseraft_kl_{Guid.NewGuid():N}"); + _src = Path.Combine(_root, "src"); + + var stateDir = Path.Combine(_root, ".fuseraft", "state"); + var decisionsDir = Path.Combine(_root, ".fuseraft", "knowledge", "decisions"); + var repoMemDir = Path.Combine(_root, ".fuseraft", "knowledge", "repository"); + var objectivesDir = Path.Combine(_root, ".fuseraft", "knowledge", "objectives"); + var graphPath = Path.Combine(stateDir, "repository.graph"); + var provenancePath = Path.Combine(stateDir, "provenance.json"); + + Directory.CreateDirectory(_src); + Directory.CreateDirectory(stateDir); + Directory.CreateDirectory(decisionsDir); + Directory.CreateDirectory(Path.Combine(decisionsDir, "archive")); + Directory.CreateDirectory(repoMemDir); + Directory.CreateDirectory(objectivesDir); + + _adrStore = new AdrStore(decisionsDir); + _adrRegistry = new AdrRegistry(_adrStore); + _graphStore = new RepositoryGraphStore(graphPath); + _graphBuilder = new RepositoryGraphBuilder(_graphStore, _root); + _provenance = new ProvenanceRegistry(provenancePath); + _memStore = new RepositoryMemoryStore(repoMemDir); + _objectiveStore = new ObjectiveStore(objectivesDir); + + _knowledgeLayer = new KnowledgeLayer( + _adrRegistry, _graphStore, _graphBuilder, _provenance, _objectiveStore); + } + + public void Dispose() => Directory.Delete(_root, recursive: true); + + // ── Stage 1 — Write evidence: build graph from source file ──────────────── + + [Fact] + public async Task Stage1_GraphBuilder_IndexesFileTypeAndMethod() + { + WriteSourceFile("MyService.cs", + "namespace Test;\n" + + "public class MyService\n" + + "{\n" + + " public void Run() { }\n" + + "}\n"); + + await _graphBuilder.BuildAllAsync(_src); + + var graph = await _graphStore.LoadAsync(); + Assert.NotNull(graph.FindById("file:MyService.cs")); + Assert.Contains(graph.Nodes, n => n.Kind == NodeType.Type && n.Name == "MyService"); + Assert.Contains(graph.Nodes, n => n.Kind == NodeType.Method && n.Name == "Run"); + } + + // ── Stage 2 — Create ADR → upsert as graph node ────────────────────────── + + [Fact] + public async Task Stage2_RecordDecision_AddsAdrNodeToGraph() + { + WriteSourceFile("MyService.cs", + "namespace Test;\npublic class MyService { }\n"); + await _graphBuilder.BuildAllAsync(_src); + + var adrId = _adrStore.NextId(); + await _knowledgeLayer.RecordDecisionAsync(new AdrEntry + { + Id = adrId, + Title = "Single-responsibility service classes", + Status = "Accepted", + Decision = "Each service class does exactly one thing.", + Governs = ["file:MyService.cs"], + }); + + var graph = await _graphStore.LoadAsync(); + var adrNode = graph.FindById($"adr:{adrId}"); + Assert.NotNull(adrNode); + Assert.Equal(NodeType.Adr, adrNode!.Kind); + } + + // ── Stage 3 — Query graph: traverse adr_governs edges to ADR ───────────── + + [Fact] + public async Task Stage3_GraphTraversal_FindsAdrGoverningFile() + { + WriteSourceFile("MyService.cs", + "namespace Test;\npublic class MyService { }\n"); + await _graphBuilder.BuildAllAsync(_src); + + var adrId = _adrStore.NextId(); + await _knowledgeLayer.RecordDecisionAsync(new AdrEntry + { + Id = adrId, + Title = "Service design constraint", + Status = "Accepted", + Governs = ["file:MyService.cs"], + }); + + var graph = await _graphStore.LoadAsync(); + var governingEdges = graph.EdgesTo("file:MyService.cs", EdgeType.AdrGoverns).ToList(); + + Assert.Single(governingEdges); + Assert.Equal($"adr:{adrId}", governingEdges[0].From); + } + + // ── Stage 4 — IKnowledgeLayer.SearchAsync returns ADR by keyword ───────── + + [Fact] + public async Task Stage4_KnowledgeSearch_ReturnsAdrMatchingKeyword() + { + var adrId = _adrStore.NextId(); + await _knowledgeLayer.RecordDecisionAsync(new AdrEntry + { + Id = adrId, + Title = "AuthMiddleware caching strategy", + Status = "Accepted", + Decision = "Cache auth tokens in Redis with 5-minute TTL.", + Tags = ["auth", "caching"], + }); + + // SearchAsync does a single-term substring match; search by a tag value. + var results = (await _knowledgeLayer.SearchAsync( + "caching", kinds: [KnowledgeKind.Decision])).ToList(); + + Assert.NotEmpty(results); + Assert.Contains(results, r => r.Id == $"adr:{adrId}"); + } + + // ── Stage 5 — ContextBroker assembles context for a matching query ──────── + + [Fact] + public async Task Stage5_ContextBroker_IncludesAdrInAssembledContext() + { + var adrId = _adrStore.NextId(); + await _knowledgeLayer.RecordDecisionAsync(new AdrEntry + { + Id = adrId, + Title = "AuthMiddleware session caching", + Status = "Accepted", + Decision = "Cache authenticated sessions in Redis with 5-minute TTL.", + Tags = ["auth", "caching"], + }); + + var broker = new ContextBroker(_knowledgeLayer, _memStore, _provenance); + var context = await broker.ResolveAsync("auth session middleware caching"); + + Assert.NotNull(context); + Assert.Contains("AuthMiddleware session caching", context!); + Assert.Contains("[Knowledge Broker", context); + } + + // ── Stage 6 — Record provenance claim backed by hard evidence ──────────── + + [Fact] + public async Task Stage6_RecordClaim_ComputesVerifiedStatus() + { + var claim = await _knowledgeLayer.RecordClaimAsync( + claim: "Build passes and all tests green", + support: [EvidenceClass.TestResult, EvidenceClass.ExitCode], + artifactId: "build:main"); + + Assert.Equal("Verified", claim.Status); + Assert.NotNull(claim.VerifiedAt); + Assert.Equal("build:main", claim.ArtifactId); + } + + // ── Stage 7 — Provenance persisted and IsValid returns true ───────────── + + [Fact] + public async Task Stage7_PersistedClaim_IsValidReturnsTrue() + { + var claim = await _knowledgeLayer.RecordClaimAsync( + claim: "Integration test assertion held", + support: [EvidenceClass.Validator, EvidenceClass.TestResult]); + + Assert.True(await _provenance.IsValidAsync(claim.Id)); + + var loaded = await _provenance.GetByIdAsync(claim.Id); + Assert.NotNull(loaded); + Assert.Equal("Verified", loaded!.Status); + } + + // ── Stage 8 — GC runs, fresh artifacts survive ──────────────────────────── + + [Fact] + public async Task Stage8_LifecycleGc_PreservesFreshArtifacts() + { + // Live (Accepted) ADR — must not be archived. + var adrId = _adrStore.NextId(); + await _adrStore.SaveAsync(new AdrEntry { Id = adrId, Title = "Live decision", Status = "Accepted" }); + + // Fresh Verified claim — must not be archived or decayed. + var claim = await _knowledgeLayer.RecordClaimAsync( + claim: "Fresh evidence", + support: [EvidenceClass.TestResult, EvidenceClass.ExitCode]); + + // Connected graph node — must not be pruned as orphan. + WriteSourceFile("Svc.cs", "namespace T;\npublic class Svc { }\n"); + await _graphBuilder.BuildAllAsync(_src); + + var policy = new LifecyclePolicy + { + AdrRetentionDays = 0, + MemoryReinforceWindowDays = 90, + ConfidenceDecayDays = 30, + OrphanedNodeGracePeriodDays = 7, + }; + var gc = new KnowledgeLifecycleManager(_adrStore, _memStore, _graphStore, _provenance); + var report = await gc.RunAsync(policy, apply: true); + + Assert.DoesNotContain(adrId, report.ArchivedDecisionIds); + Assert.DoesNotContain(claim.Id, report.DecayedClaimIds); + Assert.DoesNotContain(claim.Id, report.ArchivedProvenanceIds); + + Assert.NotNull(await _adrStore.LoadAsync(adrId)); + Assert.True(await _provenance.IsValidAsync(claim.Id)); + } + + // ── Full round-trip: all 8 stages in a single flow ──────────────────────── + + [Fact] + public async Task FullRoundTrip_AllStagesSucceed() + { + // 1. Write evidence — build graph from a source file. + WriteSourceFile("AuthService.cs", + "namespace Test.Auth;\n" + + "public class AuthService { public bool Validate(string token) => true; }\n"); + await _graphBuilder.BuildAllAsync(_src); + + // 2. Query graph — file node must be present. + var graph = await _graphStore.LoadAsync(); + var fileNode = graph.FindById("file:AuthService.cs"); + Assert.NotNull(fileNode); + + // 3. Traverse to ADR — create ADR governing the file; find via edge traversal. + var adrId = _adrStore.NextId(); + await _knowledgeLayer.RecordDecisionAsync(new AdrEntry + { + Id = adrId, + Title = "Token validation must short-circuit on expiry", + Status = "Accepted", + Decision = "Reject tokens whose exp claim is in the past without a database call.", + Tags = ["auth", "validation"], + Governs = ["file:AuthService.cs"], + }); + + graph = await _graphStore.LoadAsync(); + var governing = graph.EdgesTo("file:AuthService.cs", EdgeType.AdrGoverns).ToList(); + Assert.Single(governing); + Assert.Equal($"adr:{adrId}", governing[0].From); + + // 4. Broker assembles context — ADR title must appear in output. + var broker = new ContextBroker(_knowledgeLayer, _memStore, _provenance); + var context = await broker.ResolveAsync("token validation auth expiry"); + Assert.NotNull(context); + Assert.Contains("Token validation", context!); + + // 5–6. Validator emits claim → provenance recorded with Verified status. + var claim = await _knowledgeLayer.RecordClaimAsync( + claim: "Auth token validation verified by test and exit-code evidence", + support: [EvidenceClass.TestResult, EvidenceClass.Validator], + artifactId: "file:AuthService.cs"); + Assert.Equal("Verified", claim.Status); + + // 7. Provenance IsValid returns true. + Assert.True(await _provenance.IsValidAsync(claim.Id)); + + // 8. Lifecycle GC runs — nothing is lost. + var gc = new KnowledgeLifecycleManager(_adrStore, _memStore, _graphStore, _provenance); + var report = await gc.RunAsync(new LifecyclePolicy + { + AdrRetentionDays = 0, + ConfidenceDecayDays = 30, + MemoryReinforceWindowDays = 90, + OrphanedNodeGracePeriodDays = 7, + }, apply: true); + + Assert.DoesNotContain(adrId, report.ArchivedDecisionIds); + Assert.DoesNotContain(claim.Id, report.ArchivedProvenanceIds); + Assert.DoesNotContain(claim.Id, report.DecayedClaimIds); + + Assert.NotNull(await _adrStore.LoadAsync(adrId)); + Assert.True(await _provenance.IsValidAsync(claim.Id)); + Assert.NotNull((await _graphStore.LoadAsync()).FindById("file:AuthService.cs")); + } + + // ── GC correctness: stale artifacts are archived/demoted ───────────────── + + [Fact] + public async Task LifecycleGc_ArchivesSupersededAdr_LeavingItInArchive() + { + var adrId = _adrStore.NextId(); + await _adrStore.SaveAsync(new AdrEntry + { + Id = adrId, + Title = "Old caching approach", + Status = "Superseded", + }); + + var gc = new KnowledgeLifecycleManager(_adrStore, _memStore, _graphStore, _provenance); + var report = await gc.RunAsync(new LifecyclePolicy { AdrRetentionDays = 0 }, apply: true); + + Assert.Contains(adrId, report.ArchivedDecisionIds); + Assert.Null(await _adrStore.LoadAsync(adrId)); // gone from active + Assert.Contains(await _adrStore.LoadArchivedAsync(), e => e.Id == adrId); // preserved in archive + } + + [Fact] + public async Task LifecycleGc_DemotesStaleMem_KeepsFreshMem() + { + var staleId = Guid.NewGuid().ToString("N"); + var freshId = Guid.NewGuid().ToString("N"); + + await _memStore.SaveAsync(new RepositoryMemoryEntry + { + Id = staleId, + Pattern = "Always use async for I/O operations", + Status = "Approved", + Confidence = "Verified", + LastReinforcedAt = DateTimeOffset.UtcNow.AddDays(-200), + }); + await _memStore.SaveAsync(new RepositoryMemoryEntry + { + Id = freshId, + Pattern = "Use guard clauses at method entry points", + Status = "Approved", + Confidence = "Verified", + LastReinforcedAt = DateTimeOffset.UtcNow.AddDays(-1), + }); + + var gc = new KnowledgeLifecycleManager(_adrStore, _memStore, _graphStore, _provenance); + var report = await gc.RunAsync( + new LifecyclePolicy { MemoryReinforceWindowDays = 90 }, apply: true); + + Assert.Contains(staleId, report.DemotedMemoryIds); + Assert.DoesNotContain(freshId, report.DemotedMemoryIds); + + var all = await _memStore.LoadAllAsync(); + Assert.Equal("Candidate", all.First(e => e.Id == staleId).Status); + Assert.Equal("Approved", all.First(e => e.Id == freshId).Status); + } + + [Fact] + public async Task LifecycleGc_ArchivesExpiredClaim_PreservesValidClaim() + { + // Record a claim that has already expired. + var expiredClaim = await _provenance.RecordAsync(new ClaimRecord + { + Claim = "Old build passed", + Support = [EvidenceClass.TestResult, EvidenceClass.ExitCode], + ExpiresAt = DateTimeOffset.UtcNow.AddSeconds(-1), + }); + + // Record a fresh claim with no expiry. + var validClaim = await _provenance.RecordAsync(new ClaimRecord + { + Claim = "Current build passes", + Support = [EvidenceClass.TestResult, EvidenceClass.ExitCode], + }); + + var gc = new KnowledgeLifecycleManager(_adrStore, _memStore, _graphStore, _provenance); + var report = await gc.RunAsync( + new LifecyclePolicy { MaxProvenanceAgeDays = 0 }, apply: true); + + Assert.Contains(expiredClaim.Id, report.ArchivedProvenanceIds); + Assert.DoesNotContain(validClaim.Id, report.ArchivedProvenanceIds); + + // Expired claim removed from active store → no longer valid. + Assert.Null(await _provenance.GetByIdAsync(expiredClaim.Id)); + + // Valid claim survives. + Assert.True(await _provenance.IsValidAsync(validClaim.Id)); + } + + [Fact] + public async Task ConfidenceComputer_SupportCompositionDeterminesStatus() + { + // Two hard-evidence sources → Verified. + Assert.Equal("Verified", ConfidenceComputer.Compute( + [EvidenceClass.TestResult, EvidenceClass.ExitCode])); + + // Single hard-evidence source → Inferred. + Assert.Equal("Inferred", ConfidenceComputer.Compute( + [EvidenceClass.Validator])); + + // ADR evidence → Inferred. + Assert.Equal("Inferred", ConfidenceComputer.Compute( + [EvidenceClass.ADR])); + + // AgentAssertion only → Assumed. + Assert.Equal("Assumed", ConfidenceComputer.Compute( + [EvidenceClass.AgentAssertion])); + + // No support → Guessed. + Assert.Equal("Guessed", ConfidenceComputer.Compute([])); + } + + [Fact] + public async Task LifecycleGc_DryRun_WritesNothing() + { + var adrId = _adrStore.NextId(); + await _adrStore.SaveAsync(new AdrEntry { Id = adrId, Title = "Old", Status = "Superseded" }); + + var gc = new KnowledgeLifecycleManager(_adrStore, _memStore, _graphStore, _provenance); + var report = await gc.RunAsync(new LifecyclePolicy { AdrRetentionDays = 0 }, apply: false); + + // Dry-run reports what would happen... + Assert.Contains(adrId, report.ArchivedDecisionIds); + + // ...but nothing was actually changed. + Assert.NotNull(await _adrStore.LoadAsync(adrId)); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private string WriteSourceFile(string name, string content) + { + var path = Path.Combine(_src, name); + File.WriteAllText(path, content); + return path; + } +} diff --git a/tests/README.md b/tests/README.md index c8e193c..c263705 100644 --- a/tests/README.md +++ b/tests/README.md @@ -37,3 +37,4 @@ dotnet test tests/FuseraftCli.Tests | `StateHandoffTests.cs` | State is transferred correctly between agents on handoff | | `StrategyFactoryTests.cs` | `StrategyFactory` resolves the right selection strategy per config | | `ValidateConfigCommandTests.cs` | `validate-config` CLI command catches malformed configs | +| `KnowledgeLayerRoundTripTests.cs` | Full knowledge layer round-trip: graph build → ADR creation → graph traversal → broker context assembly → provenance claim recording → lifecycle GC; also covers `ConfidenceComputer` tiers and GC dry-run correctness |