Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,17 @@ It provides three tools:

The server is repo-aware — it resolves the current working directory to a repo hash at startup, and `search_sessions` defaults its `repo` filter to that hash unless you override it.

### Curate guidelines

Sync the repo's promoted curation guidelines into its `CLAUDE.md` and/or `AGENTS.md` via a managed block. The server tracks which guidelines have been promoted for the current repo; `curate apply` fetches them and writes (or updates) a `<!-- kcap:curated:start -->…<!-- kcap:curated:end -->` block in the relevant files. Content outside the markers is never touched.

```bash
kcap curate apply # preview changes and confirm interactively
kcap curate apply --dry-run # print what would change without writing anything
kcap curate apply --yes # apply without prompting (CI / scripted)
kcap curate apply -y # shorthand for --yes
```

### Loading historical sessions

Backfill older sessions from every detected coding agent in a single run. All seven agents ship per-session `.jsonl` transcripts (`~/.claude/projects/`, `~/.codex/sessions/`, `~/.cursor/projects/<sanitized-workspace>/agent-transcripts/`, `~/.copilot/session-state/`, `~/.gemini/tmp/<project>/chats/`, `~/.kiro/sessions/cli/`, `~/.pi/agent/sessions/`). They're discovered automatically and the command requires an explicit scope so personal/private repos aren't uploaded by accident:
Expand Down
88 changes: 88 additions & 0 deletions src/Capacitor.Cli.Core/Curation/CuratedBlock.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
namespace Capacitor.Cli.Core.Curation;

public static class CuratedBlock {
public const string StartMarker = "<!-- kcap:curated:start -->";
public const string EndMarker = "<!-- kcap:curated:end -->";

const string Heading = "## Curated guidelines";
const string Note = "_Managed by `kcap curate apply` — do not edit; changes are overwritten._";

public static string? Render(IReadOnlyList<CuratedGuideline> guidelines) {
if (guidelines.Count == 0) return null;

var sorted = guidelines
.OrderBy(g => g.Category, StringComparer.Ordinal)
.ThenBy(g => g.Text, StringComparer.Ordinal)
.ToList();

var lines = new List<string> { StartMarker, Heading, "", Note, "" };
foreach (var g in sorted) lines.Add($"- {g.Text}");
lines.Add(EndMarker);
return string.Join("\n", lines);
}

public static IReadOnlyList<string> ExtractBullets(string content) {
var lines = Normalize(content);
var loc = Locate(lines);
if (loc is null) return [];

var (s, e) = loc.Value;
var bullets = new List<string>();
for (var i = s + 1; i < e; i++) {
var t = lines[i];
if (t.StartsWith("- ")) bullets.Add(t[2..]);
}
return bullets;
}

public static string Splice(string content, string? renderedBlock) {
var newline = content.Contains("\r\n") ? "\r\n" : "\n";
var lines = new List<string>(Normalize(content));
var loc = Locate(lines.ToArray()); // throws on malformed

if (renderedBlock is null) {
if (loc is null) return content; // nothing to remove
RemoveBlock(lines, loc.Value);
var removedJoined = string.Join(newline, lines);
removedJoined = removedJoined.TrimEnd('\r', '\n');
return removedJoined.Length == 0 ? removedJoined : removedJoined + newline;
}

var blockLines = renderedBlock.Replace("\r\n", "\n").Split('\n');

if (loc is null) {
// Append, separated by a blank line if the file has trailing content.
if (lines.Count > 0 && !(lines.Count == 1 && lines[0].Length == 0) && lines[^1].Trim().Length != 0)
lines.Add("");
lines.AddRange(blockLines);
} else {
var (s, e) = loc.Value;
lines.RemoveRange(s, e - s + 1);
lines.InsertRange(s, blockLines);
}

var joined = string.Join(newline, lines);
joined = joined.TrimEnd('\r', '\n');
return joined.Length == 0 ? joined : joined + newline;
}

static string[] Normalize(string content) => content.Replace("\r\n", "\n").Split('\n');

static void RemoveBlock(List<string> lines, (int start, int end) loc) {
var (s, e) = loc;
lines.RemoveRange(s, e - s + 1);
}

static (int start, int end)? Locate(string[] lines) {
int start = -1, end = -1, starts = 0, ends = 0;
for (var i = 0; i < lines.Length; i++) {
if (lines[i] == StartMarker) { starts++; if (start < 0) start = i; }
else if (lines[i] == EndMarker) { ends++; end = i; }
}

if (starts == 0 && ends == 0) return null;
if (starts == 1 && ends == 1 && start < end) return (start, end);
throw new CuratedBlockException(
$"Malformed managed block: found {starts} start and {ends} end marker(s). Fix the file by hand and re-run.");
}
}
22 changes: 22 additions & 0 deletions src/Capacitor.Cli.Core/Curation/CuratedGuideline.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace Capacitor.Cli.Core.Curation;

/// <summary>One promoted guideline destined for an instruction file.</summary>
public sealed record CuratedGuideline(string Category, string Text);

/// <summary>Raised when an instruction file's managed markers are malformed; apply fails closed.</summary>
public sealed class CuratedBlockException : Exception {
public CuratedBlockException(string message) : base(message) { }
}

/// <summary>What apply will do to one instruction file.</summary>
public enum CurateAction { Create, Update, Remove, NoOp }

public sealed record FilePlan(
string Path,
CurateAction Action,
string? NewContent, // null only on NoOp; Create/Update/Remove carry the new file content
IReadOnlyList<string> Added, // bullet texts added vs. the current block
IReadOnlyList<string> Removed // bullet texts removed vs. the current block
);

public sealed record ApplyPlan(IReadOnlyList<FilePlan> Files);
25 changes: 25 additions & 0 deletions src/Capacitor.Cli.Core/Curation/CuratedTargets.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace Capacitor.Cli.Core.Curation;

public static class CuratedTargets {
public const string ClaudeMdName = "CLAUDE.md";
public const string AgentsMdName = "AGENTS.md";

public static IReadOnlyList<string> Resolve(
string repoRoot, bool claudeMdExists, bool agentsMdExists, bool hasContent) {
var claude = Path.Combine(repoRoot, ClaudeMdName);
var agents = Path.Combine(repoRoot, AgentsMdName);

if (!hasContent) {
// Nothing to write: only touch files that already exist (to remove a stale block).
var existing = new List<string>();
if (claudeMdExists) existing.Add(claude);
if (agentsMdExists) existing.Add(agents);
return existing;
}

if (claudeMdExists && agentsMdExists) return [claude, agents];
if (claudeMdExists) return [claude];
if (agentsMdExists) return [agents];
return [agents]; // neither exists → create the cross-harness default
}
}
35 changes: 35 additions & 0 deletions src/Capacitor.Cli.Core/Curation/CuratedTextNormalizer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using System.Text;

namespace Capacitor.Cli.Core.Curation;

public static class CuratedTextNormalizer {
/// <summary>
/// Promoted text is untrusted/multiline and only length-validated server-side.
/// Collapse it to a single logical line and neutralize HTML-comment terminators
/// so it can never be mistaken for a managed-block marker.
/// </summary>
public static string? Normalize(string? raw) {
if (string.IsNullOrEmpty(raw)) return null;

var sb = new StringBuilder(raw.Length);
var lastSpace = false;
foreach (var ch in raw) {
var c = ch is '\r' or '\n' or '\t' ? ' ' : ch;
if (c == ' ') {
if (!lastSpace) sb.Append(' ');
lastSpace = true;
} else {
sb.Append(c);
lastSpace = false;
}
}

var s = sb.ToString().Trim();
if (s.Length == 0) return null;

// Defang the comment terminator: a zero-width space (U+200B) breaks the "-->"
// token, which also neutralizes any embedded start/end marker (both end in "-->").
// Use the \u200b escape, never a literal ZWSP, so the source stays reviewable.
return s.Replace("-->", "--\u200b>");
}
}
49 changes: 49 additions & 0 deletions src/Capacitor.Cli.Core/Curation/CurationApplyPlanner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
namespace Capacitor.Cli.Core.Curation;

public static class CurationApplyPlanner {
public static ApplyPlan BuildPlan(
string repoRoot,
IReadOnlyDictionary<string, string?> contentByPath,
IReadOnlyList<CuratedGuideline> guidelines) {

var claude = Path.Combine(repoRoot, CuratedTargets.ClaudeMdName);
var agents = Path.Combine(repoRoot, CuratedTargets.AgentsMdName);

var claudeExists = contentByPath.TryGetValue(claude, out var cc) && cc is not null;
var agentsExists = contentByPath.TryGetValue(agents, out var ac) && ac is not null;

var hasContent = guidelines.Count > 0;
var block = CuratedBlock.Render(guidelines); // null when empty
var newBullets = guidelines.Count == 0
? new HashSet<string>(StringComparer.Ordinal)
: CuratedBlock.ExtractBullets(block!).ToHashSet(StringComparer.Ordinal);

var targets = CuratedTargets.Resolve(repoRoot, claudeExists, agentsExists, hasContent);
var files = new List<FilePlan>();

foreach (var path in targets) {
contentByPath.TryGetValue(path, out var current); // null when absent
var oldBullets = current is null
? new HashSet<string>(StringComparer.Ordinal)
: CuratedBlock.ExtractBullets(current).ToHashSet(StringComparer.Ordinal);

var added = newBullets.Except(oldBullets).OrderBy(x => x, StringComparer.Ordinal).ToList();
var removed = oldBullets.Except(newBullets).OrderBy(x => x, StringComparer.Ordinal).ToList();

var newContent = CuratedBlock.Splice(current ?? "", block); // may throw → caller fails closed

CurateAction action;
if (current is null) action = CurateAction.Create;
else if (!hasContent) action = oldBullets.Count > 0 ? CurateAction.Remove : CurateAction.NoOp;
else if (string.Equals(newContent, current)) action = CurateAction.NoOp;
else action = CurateAction.Update;

files.Add(new FilePlan(
path, action,
action == CurateAction.NoOp ? null : newContent,
added, removed));
}

return new ApplyPlan(files);
}
}
15 changes: 15 additions & 0 deletions src/Capacitor.Cli.Core/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,19 @@ public record RepoEntry {
public required DateTimeOffset LastUsed { get; init; }
}

public sealed record CurationApplyItem {
[JsonPropertyName("category")] public string? Category { get; init; }
[JsonPropertyName("cluster_id")] public string? ClusterId { get; init; }
[JsonPropertyName("promoted_text")] public string? PromotedText { get; init; }
[JsonPropertyName("target_kinds")] public IReadOnlyList<string>? TargetKinds { get; init; }
[JsonPropertyName("status")] public string? Status { get; init; }
}

public sealed record CurationApplyResponse {
[JsonPropertyName("repo_hash")] public string? RepoHash { get; init; }
[JsonPropertyName("items")] public IReadOnlyList<CurationApplyItem>? Items { get; init; }
}

[JsonSerializable(typeof(List<RecapEntry>))]
[JsonSerializable(typeof(List<RepoRecapEntry>))]
[JsonSerializable(typeof(EvalContextResult))]
Expand Down Expand Up @@ -584,6 +597,8 @@ public record RepoEntry {
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(string[]))]
[JsonSerializable(typeof(RepoEntry[]))]
[JsonSerializable(typeof(CurationApplyResponse))]
[JsonSerializable(typeof(CurationApplyItem))]
// UseStringEnumConverter=true matches the server's SignalR JSON protocol, which
// serialises enums (e.g. LaunchKind) as camelCase strings. Without it the
// source-gen LaunchKind JsonTypeInfo defaults to numeric and silently drops the
Expand Down
18 changes: 18 additions & 0 deletions src/Capacitor.Cli.Core/Resources/help-curate.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
kcap curate — apply curated guidelines to your project instruction files

Usage:
kcap curate apply [--dry-run] [--yes]

Writes the repo's promoted CLAUDE.md curation guidelines into CLAUDE.md and/or
AGENTS.md at the repository root, inside a managed block. Updates both files when
both exist, the one that exists otherwise, or creates AGENTS.md when neither does.

Options:
--dry-run Show what would change and exit without writing.
--yes, -y Skip the confirmation prompt (for scripts/CI).

Notes:
- Run from inside the git repository.
- Only guidelines promoted with the CLAUDE.md target are applied.
- Re-running re-syncs the managed block to the server's current promoted set;
content outside the markers is never touched.
3 changes: 3 additions & 0 deletions src/Capacitor.Cli.Core/Resources/help-usage.txt
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ Session:
hide Hide session (owner-only visibility)
review <pr> Launch Claude with PR review context

Curation:
curate apply [--dry-run] [--yes] Write promoted guidelines into CLAUDE.md / AGENTS.md

MCP servers (stdio):
mcp review [--owner --repo --pr] PR review context for agents
mcp judge --session <id> Per-session judge tools
Expand Down
Loading
Loading