From cef096f4ded46309104f6b5f6d6f57399695fdf7 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:40:46 -0400 Subject: [PATCH 1/3] [AI-2095] Render SessionStart coordination-notices terminal-delivery lane Client half of the kcap-server U3 coordination-notices lane (server shipped first, capability-gated and inert until this lands). On a live Claude/generic SessionStart the CLI now: - advertises the `coordination_notices: "v1"` capability on the POST body, only on the live path (injected after the ordering-guard/backlog return, so a spooled body never carries it; `kcap import` uses the vendor routes with origin=historical and never reaches here); - renders a returned `coordination_notices: [{text}]` list into the SessionStart additionalContext envelope, next to the team-memory index, via a new CoordinationNoticesEmitter (modelled on SessionGuidelinesEmitter); - honours a new `disable_coordination_notices` profile opt-out that mirrors `disable_memory_index` (Profile field, ConfigCommand set arm, help-config), read from the effective profile so it holds for KCAP_URL users too; when set, the capability is not sent at all (notices stay in the bell / Slack). Additive and fail-open throughout: any parse/build error leaves the hook unaffected. AOT-safe (JsonNode + the existing source-gen Profile context; no reflection). Unit + integration tests cover capability send/omit, render, opt-out suppression and malformed-field fail-open. Co-Authored-By: Claude Opus 4.8 --- .../Config/ProfileConfig.cs | 11 ++ .../Resources/help-config.txt | 1 + src/Capacitor.Cli/Commands/ConfigCommand.cs | 2 + .../Commands/Harness/ClaudeHookCommand.cs | 33 ++++- .../CoordinationNoticesEmitter.cs | 67 +++++++++ .../SessionStartCoordinationNoticesTests.cs | 134 ++++++++++++++++++ .../Commands/ConfigCommandTests.cs | 26 ++++ .../Harness/ClaudeHookCommandTests.cs | 84 +++++++++++ .../CoordinationNoticesEmitterTests.cs | 121 ++++++++++++++++ 9 files changed, 478 insertions(+), 1 deletion(-) create mode 100644 src/Capacitor.Cli/CoordinationNoticesEmitter.cs create mode 100644 test/Capacitor.Cli.Tests.Integration/SessionStartCoordinationNoticesTests.cs create mode 100644 test/Capacitor.Cli.Tests.Unit/CoordinationNoticesEmitterTests.cs diff --git a/src/Capacitor.Cli.Core/Config/ProfileConfig.cs b/src/Capacitor.Cli.Core/Config/ProfileConfig.cs index d19055ee8d..368b8042dd 100644 --- a/src/Capacitor.Cli.Core/Config/ProfileConfig.cs +++ b/src/Capacitor.Cli.Core/Config/ProfileConfig.cs @@ -70,6 +70,17 @@ public record Profile { [JsonPropertyName("disable_workitems_nudge")] public bool? DisableWorkItemsNudge { get; init; } + /// + /// when true, kcap does NOT advertise the coordination-notices capability at SessionStart, + /// so the server injects no coordination notices (heads-up about other people's in-flight work + /// that may overlap yours) into the agent's context — those notices still reach the in-app + /// notification centre and Slack. Independent of the memory-index, guidelines and work-items + /// opt-outs so each SessionStart injection can be toggled separately. Mirrors + /// . + /// + [JsonPropertyName("disable_coordination_notices")] + public bool? DisableCoordinationNotices { get; init; } + /// /// When true, kcap keeps ANTHROPIC_API_KEY / OPENAI_API_KEY /// in the spawn environment for headless agent CLIs (title generation, diff --git a/src/Capacitor.Cli.Core/Resources/help-config.txt b/src/Capacitor.Cli.Core/Resources/help-config.txt index 18c01161a6..3af96ccb11 100644 --- a/src/Capacitor.Cli.Core/Resources/help-config.txt +++ b/src/Capacitor.Cli.Core/Resources/help-config.txt @@ -17,6 +17,7 @@ Config keys: disable_session_guidelines Skip injecting recurring-lessons context at SessionStart (true/false) disable_memory_index Skip injecting the team-memory index at SessionStart (true/false) disable_workitems_nudge Skip injecting the work-items nudge at SessionStart (true/false) + disable_coordination_notices Skip injecting coordination notices (others' overlapping work) at SessionStart (true/false) use_provider_api_key Keep ANTHROPIC_API_KEY/OPENAI_API_KEY in headless agent spawns (true/false) excluded_repos Excluded repos, comma-separated (owner/repo,owner/repo) update_check Enable update check (true/false) diff --git a/src/Capacitor.Cli/Commands/ConfigCommand.cs b/src/Capacitor.Cli/Commands/ConfigCommand.cs index 76d3d27bde..160cfbafd6 100644 --- a/src/Capacitor.Cli/Commands/ConfigCommand.cs +++ b/src/Capacitor.Cli/Commands/ConfigCommand.cs @@ -167,6 +167,8 @@ public static Profile ApplySet(Profile profile, string key, string value) => "disable_session_guidelines" => throw new ArgumentException($"Invalid value for disable_session_guidelines: '{value}'. Must be true or false."), "disable_memory_index" when bool.TryParse(value, out var b) => profile with { DisableMemoryIndex = b }, "disable_memory_index" => throw new ArgumentException($"Invalid value for disable_memory_index: '{value}'. Must be true or false."), + "disable_coordination_notices" when bool.TryParse(value, out var b) => profile with { DisableCoordinationNotices = b }, + "disable_coordination_notices" => throw new ArgumentException($"Invalid value for disable_coordination_notices: '{value}'. Must be true or false."), "disable_workitems_nudge" when bool.TryParse(value, out var b) => profile with { DisableWorkItemsNudge = b }, "disable_workitems_nudge" => throw new ArgumentException($"Invalid value for disable_workitems_nudge: '{value}'. Must be true or false."), "use_provider_api_key" when bool.TryParse(value, out var b) => profile with { UseProviderApiKey = b }, diff --git a/src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs b/src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs index 5dbadfe307..b3cc980a74 100644 --- a/src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs +++ b/src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs @@ -579,6 +579,30 @@ await WatcherManager.EnsureWatcherRunning( return 0; } + // Advertise the coordination-notices capability so the server MAY return + // work-overlap / work-item-adjacency notices to inject into this live agent's + // context below (rendered next to the team-memory index). Injected ONLY here — onto + // the LIVE Claude/generic session-start POST body — and deliberately AFTER the + // ordering-guard/backlog return above, so a spooled body never carries the promise + // (matching the memory fetch's "don't pay for what a replay won't use" rule). + // `kcap import` posts /hooks/session-start/{vendor} with origin=historical and never + // reaches this path; the server also refuses a historical origin. Suppressed by the + // disable_coordination_notices opt-out, read from the EFFECTIVE profile so it is + // honoured for KCAP_URL users too — mirroring the sibling guidelines opt-out below + // (the same defect the memory read above still carries). + var coordinationNoticesDisabled = activeProfile?.DisableCoordinationNotices is true; + if (!coordinationNoticesDisabled) { + try { + var node = JsonNode.Parse(body); + if (node is not null) { + node["coordination_notices"] = CoordinationNoticesEmitter.CapabilityVersion; + body = node.ToJsonString(); + } + } catch { + // Best effort — never fail the hook building the capability field. + } + } + // kick off the team-memory index fetch in PARALLEL with the hook POST so // it adds no latency to the critical path. Fully best-effort / fail-open: any failure, // a 401, or a budget overrun yields a null fragment and nothing is injected. Started @@ -670,13 +694,20 @@ await WatcherManager.EnsureWatcherRunning( // hook budget so a slow fetch can't delay the hook (fail-open → null). var memoryFragment = await AwaitMemoryFragmentAsync(memoryIndexTask, processStart); + // Coordination notices ride the hook POST response (no extra fetch), same as the + // guidelines fragment. Gated on the same opt-out that suppressed the capability + // above: when disabled the server was never asked and returns nothing, but the + // gate is defense-in-depth so the opt-out holds even against an over-eager server. + var coordinationFragment = CoordinationNoticesEmitter.BuildFragment( + responseNode, coordinationNoticesDisabled); + // The static work-items nudge. Claude has always carried kcap-workitems, so // the availability gate is always satisfied here; only the opt-out can suppress it. var workItemsNudge = WorkItemsNudgeEmitter.Resolve( SessionStartHarness.Claude, sessionId, activeProfile?.DisableWorkItemsNudge is true); var envelope = SessionStartAdditionalContext.BuildEnvelope( - lessonsFragment, nudgeFragment, memoryFragment, workItemsNudge); + lessonsFragment, nudgeFragment, memoryFragment, coordinationFragment, workItemsNudge); if (envelope is not null) { writer.WriteLine(envelope); diff --git a/src/Capacitor.Cli/CoordinationNoticesEmitter.cs b/src/Capacitor.Cli/CoordinationNoticesEmitter.cs new file mode 100644 index 0000000000..d0d00165b1 --- /dev/null +++ b/src/Capacitor.Cli/CoordinationNoticesEmitter.cs @@ -0,0 +1,67 @@ +using System.Text; +using System.Text.Json.Nodes; + +namespace Capacitor.Cli; + +/// +/// Builds the SessionStart "coordination notices" text fragment from a server +/// /hooks/session-start response body. Returns plain text, not a JSON +/// envelope — the caller (see SessionStartAdditionalContext) joins this +/// fragment with the others (guidelines, version nudge, team-memory index) and +/// serializes a single Claude Code hookSpecificOutput envelope. +/// +/// The server's terminal-delivery lane returns coordination_notices as a +/// bounded JSON array of { text } one-line warnings (with an optional +/// "+N more in the notification centre" tail line) about other people's in-flight +/// work that may overlap this session — the same notices that also reach the +/// in-app notification centre and Slack. Each entry renders as one bullet under a +/// "Coordination notices" heading. +/// +/// +/// Returns null when disabled, absent, empty, or malformed — the caller +/// drops null fragments, so a missing or empty field emits nothing (fail-open, +/// same as guideline injection and the memory index). +/// +/// +static class CoordinationNoticesEmitter { + /// + /// The capability token the CLI advertises on the SessionStart request + /// (coordination_notices: "v1"). An old/opted-out CLI sends nothing and + /// the server does no selection/claim/render; a future wire-shape change bumps + /// this token so a server keyed on the old one simply declines. + /// + internal const string CapabilityVersion = "v1"; + + /// + /// Returns the coordination-notices block text, or null when there is + /// nothing to emit (no coordination_notices, all empty, user opted out, + /// malformed response). + /// + /// The hook response body parsed as a . + /// True when the user has set disable_coordination_notices on their active profile. + public static string? BuildFragment(JsonNode? responseNode, bool disabled) { + if (disabled) return null; + if (responseNode is not JsonObject obj) return null; + if (obj["coordination_notices"] is not JsonArray notices || notices.Count == 0) return null; + + var lines = new List(); + foreach (var node in notices) { + if (node is not JsonObject o) continue; + + string? text; + try { text = o["text"]?.GetValue(); } + catch { continue; } + + if (string.IsNullOrWhiteSpace(text)) continue; + lines.Add($"- {text!.Trim()}"); + } + + if (lines.Count == 0) return null; + + var sb = new StringBuilder(); + sb.AppendLine("## Coordination notices"); + sb.AppendLine("Heads-up: other people are working on things that may overlap yours. Coordinate before you collide."); + foreach (var l in lines) sb.AppendLine(l); + return sb.ToString().TrimEnd(); + } +} diff --git a/test/Capacitor.Cli.Tests.Integration/SessionStartCoordinationNoticesTests.cs b/test/Capacitor.Cli.Tests.Integration/SessionStartCoordinationNoticesTests.cs new file mode 100644 index 0000000000..c071b94dbc --- /dev/null +++ b/test/Capacitor.Cli.Tests.Integration/SessionStartCoordinationNoticesTests.cs @@ -0,0 +1,134 @@ +using System.Text.Json.Nodes; +using Capacitor.Cli.Commands.Harness; +using Capacitor.Cli.Core; +using Capacitor.Cli.Core.Config; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; + +namespace Capacitor.Cli.Tests.Integration; + +/// +/// End-to-end coverage for the SessionStart coordination-notices lane, the client half of the +/// server's terminal-delivery lane (server ships first, capability-gated + inert until this): +/// +/// the CLI advertises the coordination_notices: "v1" capability on the LIVE +/// Claude/generic session-start POST body; +/// when the server returns coordination_notices: [{text}], the CLI injects them +/// into the SessionStart additionalContext envelope next to the team-memory index; +/// the disable_coordination_notices opt-out (mirroring disable_memory_index) +/// suppresses BOTH the capability and any render; +/// a malformed field never fails the hook (fail-open). +/// +/// +/// Config is saved/restored per test (mirrors ) so a +/// profile write can't leak across tests; payloads omit transcript_path/session_id +/// so no watcher spawns and no memory-index GET fires. +/// +public class SessionStartCoordinationNoticesTests : IDisposable { + readonly WireMockServer _server = WireMockServer.Start(); + readonly string _configPath = PathHelpers.ConfigPath("config.json"); + readonly string? _previousConfig; + + public SessionStartCoordinationNoticesTests() { + _previousConfig = File.Exists(_configPath) ? File.ReadAllText(_configPath) : null; + } + + public void Dispose() { + _server.Stop(); + + if (_previousConfig is null) { + if (File.Exists(_configPath)) File.Delete(_configPath); + } else { + File.WriteAllText(_configPath, _previousConfig); + } + } + + static string Payload() => + // No transcript_path / session_id → WatcherManager spawn AND memory-index GET are skipped. + """ + { + "cwd": "/tmp/test", + "model": "claude-sonnet-4-6", + "source": "startup", + "hook_event_name": "session-start" + } + """; + + async Task ConfigureProfileAsync(bool? disableCoordinationNotices) { + var config = new ProfileConfig { + ActiveProfile = "work", + Profiles = new() { + ["work"] = new Profile { + ServerUrl = _server.Url, + DisableCoordinationNotices = disableCoordinationNotices + } + } + }; + await ConfigMutator.MutateAsync(_ => config); + } + + void GivenServerReturns(string bodyJson) => + _server.Given(Request.Create().WithPath("/hooks/session-start").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithHeader("Content-Type", "application/json").WithBody(bodyJson)); + + JsonNode PostedBody() { + var requests = _server.FindLogEntries(Request.Create().WithPath("/hooks/session-start").UsingPost()); + return JsonNode.Parse(requests.Single().RequestMessage.Body!)!; + } + + [Test, NotInParallel("AppConfig_FileState")] + public async Task Advertises_the_capability_and_renders_returned_notices() { + await ConfigureProfileAsync(disableCoordinationNotices: null); + GivenServerReturns( + """ + { + "coordination_notices": [ + { "text": "Priya is also working on the checkout refactor (SHOP-88)." }, + { "text": "+1 more in the notification centre" } + ] + } + """); + + var stdout = new StringWriter(); + await ClaudeHookCommand.Handle(_server.Url!, new StringReader(Payload()), stdout: stdout); + + // Capability advertised on the request. + await Assert.That(PostedBody()["coordination_notices"]?.GetValue()).IsEqualTo("v1"); + + // Notices rendered into the SessionStart envelope. + var json = JsonNode.Parse(stdout.ToString().Trim()); + var ctx = json!["hookSpecificOutput"]!["additionalContext"]!.GetValue(); + await Assert.That(ctx).Contains("## Coordination notices"); + await Assert.That(ctx).Contains("- Priya is also working on the checkout refactor (SHOP-88)."); + await Assert.That(ctx).Contains("- +1 more in the notification centre"); + } + + [Test, NotInParallel("AppConfig_FileState")] + public async Task Opt_out_suppresses_capability_and_render() { + await ConfigureProfileAsync(disableCoordinationNotices: true); + GivenServerReturns("""{ "coordination_notices": [ { "text": "someone else is on this bug" } ] }"""); + + var stdout = new StringWriter(); + await ClaudeHookCommand.Handle(_server.Url!, new StringReader(Payload()), stdout: stdout); + + await Assert.That(PostedBody()["coordination_notices"]).IsNull(); + await Assert.That(stdout.ToString()).DoesNotContain("## Coordination notices"); + await Assert.That(stdout.ToString()).DoesNotContain("someone else is on this bug"); + } + + [Test, NotInParallel("AppConfig_FileState")] + public async Task Malformed_notices_field_does_not_fail_the_hook() { + await ConfigureProfileAsync(disableCoordinationNotices: null); + // Server echoes the capability token back as a bare string instead of the {text}[] array. + GivenServerReturns("""{ "coordination_notices": "v1" }"""); + + var stdout = new StringWriter(); + await ClaudeHookCommand.Handle(_server.Url!, new StringReader(Payload()), stdout: stdout); + + // Capability still advertised; render silently emits nothing (fail-open, no crash). + await Assert.That(PostedBody()["coordination_notices"]?.GetValue()).IsEqualTo("v1"); + await Assert.That(stdout.ToString()).DoesNotContain("## Coordination notices"); + } +} diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ConfigCommandTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ConfigCommandTests.cs index b1db4ea7cf..f9743bde4c 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/ConfigCommandTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/ConfigCommandTests.cs @@ -140,6 +140,32 @@ await Assert.That(() => ConfigCommand.ApplySet(profile, "disable_session_guideli .Throws(); } + [Test] + public async Task ApplySet_DisableCoordinationNotices_True_UpdatesProfile() { + var profile = new Profile(); + + var updated = ConfigCommand.ApplySet(profile, "disable_coordination_notices", "true"); + + await Assert.That(updated.DisableCoordinationNotices).IsTrue(); + } + + [Test] + public async Task ApplySet_DisableCoordinationNotices_False_UpdatesProfile() { + var profile = new Profile { DisableCoordinationNotices = true }; + + var updated = ConfigCommand.ApplySet(profile, "disable_coordination_notices", "false"); + + await Assert.That(updated.DisableCoordinationNotices).IsFalse(); + } + + [Test] + public async Task ApplySet_DisableCoordinationNotices_InvalidValue_Throws() { + var profile = new Profile(); + + await Assert.That(() => ConfigCommand.ApplySet(profile, "disable_coordination_notices", "nope")) + .Throws(); + } + [Test] public async Task ApplySet_UnknownKey_Throws() { var profile = new Profile(); diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookCommandTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookCommandTests.cs index 8bfe523d03..e090b9b190 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookCommandTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookCommandTests.cs @@ -323,6 +323,90 @@ public async Task memory_index_ready_is_discarded_when_the_session_start_post_fa await Assert.That(fx.SpoolFiles.Any()).IsTrue(); // still durably spooled for retry } + // ── SessionStart coordination-notices lane: capability advertise + response render ─────── + + [Test, NotInParallel] + public async Task session_start_advertises_the_coordination_notices_capability_by_default() { + using var fx = new Fixture(); + var sid = Guid.NewGuid().ToString("N"); + + var (exit, _) = await WithProfileAsync(new Profile(), () => RunCapturingStdoutAsync(() => + fx.HandleAsync($$"""{"hook_event_name":"SessionStart","session_id":"{{sid}}","cwd":"/tmp","source":"startup"}"""))); + await Assert.That(exit).IsEqualTo(0); + + var posted = fx.Sent.Single(s => s.StartsWith("/hooks/session-start|", StringComparison.Ordinal)); + var body = JsonNode.Parse(posted[(posted.IndexOf('|') + 1)..]); + await Assert.That(body!["coordination_notices"]?.GetValue()).IsEqualTo("v1"); + } + + [Test, NotInParallel] + public async Task disable_coordination_notices_omits_the_capability_from_the_post() { + using var fx = new Fixture(); + var sid = Guid.NewGuid().ToString("N"); + + var (exit, _) = await WithProfileAsync(new Profile { DisableCoordinationNotices = true }, () => RunCapturingStdoutAsync(() => + fx.HandleAsync($$"""{"hook_event_name":"SessionStart","session_id":"{{sid}}","cwd":"/tmp","source":"startup"}"""))); + await Assert.That(exit).IsEqualTo(0); + + var posted = fx.Sent.Single(s => s.StartsWith("/hooks/session-start|", StringComparison.Ordinal)); + var body = JsonNode.Parse(posted[(posted.IndexOf('|') + 1)..]); + await Assert.That(body!["coordination_notices"]).IsNull(); + } + + [Test, NotInParallel] + public async Task session_start_renders_coordination_notices_from_the_response() { + using var fx = new Fixture { + RespondJson = """{"coordination_notices":[{"text":"Sam is also on AUTH-12"},{"text":"+2 more in the notification centre"}]}""" + }; + var sid = Guid.NewGuid().ToString("N"); + + var (exit, stdout) = await WithProfileAsync(new Profile(), () => RunCapturingStdoutAsync(() => + fx.HandleAsync($$"""{"hook_event_name":"SessionStart","session_id":"{{sid}}","cwd":"/tmp","source":"startup"}"""))); + await Assert.That(exit).IsEqualTo(0); + + var ctx = JsonNode.Parse(stdout)!["hookSpecificOutput"]!["additionalContext"]!.GetValue(); + await Assert.That(ctx).Contains("## Coordination notices"); + await Assert.That(ctx).Contains("- Sam is also on AUTH-12"); + await Assert.That(ctx).Contains("- +2 more in the notification centre"); + } + + /// Non-vacuous control for the opt-out: the SAME server response that renders above + /// produces NOTHING when disable_coordination_notices is set — proving the opt-out suppresses + /// the block (and the capability), not that the fixture never produced one. + [Test, NotInParallel] + public async Task disable_coordination_notices_suppresses_both_the_capability_and_the_render() { + using var fx = new Fixture { + RespondJson = """{"coordination_notices":[{"text":"Sam is also on AUTH-12"}]}""" + }; + var sid = Guid.NewGuid().ToString("N"); + + var (exit, stdout) = await WithProfileAsync(new Profile { DisableCoordinationNotices = true }, () => RunCapturingStdoutAsync(() => + fx.HandleAsync($$"""{"hook_event_name":"SessionStart","session_id":"{{sid}}","cwd":"/tmp","source":"startup"}"""))); + await Assert.That(exit).IsEqualTo(0); + + // Capability never sent. + var posted = fx.Sent.Single(s => s.StartsWith("/hooks/session-start|", StringComparison.Ordinal)); + var body = JsonNode.Parse(posted[(posted.IndexOf('|') + 1)..]); + await Assert.That(body!["coordination_notices"]).IsNull(); + + // Nothing rendered (no other fragments in this response either). + await Assert.That(stdout).DoesNotContain("## Coordination notices"); + await Assert.That(stdout).DoesNotContain("Sam is also on AUTH-12"); + } + + [Test, NotInParallel] + public async Task malformed_coordination_notices_field_does_not_fail_the_hook() { + // Server echoes the capability token back as a bare string instead of the {text}[] array. + using var fx = new Fixture { RespondJson = """{"coordination_notices":"v1"}""" }; + var sid = Guid.NewGuid().ToString("N"); + + var (exit, stdout) = await WithProfileAsync(new Profile(), () => RunCapturingStdoutAsync(() => + fx.HandleAsync($$"""{"hook_event_name":"SessionStart","session_id":"{{sid}}","cwd":"/tmp","source":"startup"}"""))); + + await Assert.That(exit).IsEqualTo(0); + await Assert.That(stdout).DoesNotContain("## Coordination notices"); + } + /// Runs with set to /// , restoring whatever was resolved before (or the closest /// equivalent to "untouched" — see AppConfig.SetResolvedState's lack of an "unset" diff --git a/test/Capacitor.Cli.Tests.Unit/CoordinationNoticesEmitterTests.cs b/test/Capacitor.Cli.Tests.Unit/CoordinationNoticesEmitterTests.cs new file mode 100644 index 0000000000..db417ca422 --- /dev/null +++ b/test/Capacitor.Cli.Tests.Unit/CoordinationNoticesEmitterTests.cs @@ -0,0 +1,121 @@ +using System.Text.Json.Nodes; + +namespace Capacitor.Cli.Tests.Unit; + +public class CoordinationNoticesEmitterTests { + static JsonObject Response(params string[] texts) { + var arr = new JsonArray(); + foreach (var t in texts) arr.Add(new JsonObject { ["text"] = t }); + return new JsonObject { ["coordination_notices"] = arr }; + } + + [Test] + public async Task Renders_heading_and_each_notice_as_a_bullet() { + var body = Response( + "Alex is also working on the auth refactor (AUTH-12).", + "2 sessions touch billing/invoice.ts right now."); + + var fragment = CoordinationNoticesEmitter.BuildFragment(body, disabled: false); + + await Assert.That(fragment).IsNotNull(); + await Assert.That(fragment!).Contains("## Coordination notices"); + await Assert.That(fragment!).Contains("- Alex is also working on the auth refactor (AUTH-12)."); + await Assert.That(fragment!).Contains("- 2 sessions touch billing/invoice.ts right now."); + } + + [Test] + public async Task Renders_a_plus_n_more_tail_line_verbatim_as_a_bullet() { + // The server may append a "+N more in the notification centre" tail as a normal {text} entry. + var body = Response("Someone else is on the same bug.", "+3 more in the notification centre"); + + var fragment = CoordinationNoticesEmitter.BuildFragment(body, disabled: false); + + await Assert.That(fragment).IsNotNull(); + await Assert.That(fragment!).Contains("- +3 more in the notification centre"); + } + + [Test] + public async Task Returns_null_when_disabled() { + var body = Response("x"); + await Assert.That(CoordinationNoticesEmitter.BuildFragment(body, disabled: true)).IsNull(); + } + + [Test] + public async Task Returns_null_when_coordination_notices_empty() { + var body = new JsonObject { ["coordination_notices"] = new JsonArray() }; + await Assert.That(CoordinationNoticesEmitter.BuildFragment(body, disabled: false)).IsNull(); + } + + [Test] + public async Task Returns_null_when_coordination_notices_missing() { + var body = new JsonObject { ["version"] = "1.0.0" }; + await Assert.That(CoordinationNoticesEmitter.BuildFragment(body, disabled: false)).IsNull(); + } + + [Test] + public async Task Skips_entries_with_no_or_blank_text() { + var body = new JsonObject { + ["coordination_notices"] = new JsonArray( + new JsonObject { ["text"] = "real notice" }, + new JsonObject { }, // no text + new JsonObject { ["text"] = "" }, // empty + new JsonObject { ["text"] = " " } // whitespace + ) + }; + + var fragment = CoordinationNoticesEmitter.BuildFragment(body, disabled: false); + + await Assert.That(fragment).IsNotNull(); + await Assert.That(fragment!).Contains("- real notice"); + var bullets = fragment.Split('\n').Count(l => l.StartsWith("- ", StringComparison.Ordinal)); + await Assert.That(bullets).IsEqualTo(1); + } + + [Test] + public async Task Returns_null_when_every_notice_is_blank() { + var body = new JsonObject { + ["coordination_notices"] = new JsonArray( + new JsonObject { ["text"] = "" }, + new JsonObject { ["text"] = " " } + ) + }; + await Assert.That(CoordinationNoticesEmitter.BuildFragment(body, disabled: false)).IsNull(); + } + + [Test] + public async Task Fail_open_when_coordination_notices_is_a_string_not_array() { + // A malformed field must never throw and must render nothing (fail-open). + var body = JsonNode.Parse("""{ "coordination_notices": "v1-echoed-back" }"""); + await Assert.That(CoordinationNoticesEmitter.BuildFragment(body, disabled: false)).IsNull(); + } + + [Test] + public async Task Fail_open_when_an_entry_text_is_a_number_not_a_string() { + var body = JsonNode.Parse("""{ "coordination_notices": [ { "text": 42 }, { "text": "kept" } ] }"""); + var fragment = CoordinationNoticesEmitter.BuildFragment(body, disabled: false); + await Assert.That(fragment).IsNotNull(); + await Assert.That(fragment!).Contains("- kept"); + var bullets = fragment.Split('\n').Count(l => l.StartsWith("- ", StringComparison.Ordinal)); + await Assert.That(bullets).IsEqualTo(1); + } + + [Test] + public async Task Returns_null_when_response_is_top_level_array() { + var body = JsonNode.Parse("""[ { "text": "x" } ]"""); + await Assert.That(CoordinationNoticesEmitter.BuildFragment(body, disabled: false)).IsNull(); + } + + [Test] + public async Task Returns_null_when_response_node_is_null() { + await Assert.That(CoordinationNoticesEmitter.BuildFragment(null, disabled: false)).IsNull(); + } + + [Test] + public async Task Fragment_is_not_a_json_envelope() { + var fragment = CoordinationNoticesEmitter.BuildFragment(Response("mind the overlap"), disabled: false); + await Assert.That(fragment).IsNotNull(); + await Assert.That(fragment!.TrimStart().StartsWith('{')).IsFalse(); + await Assert.That(fragment).DoesNotContain("hookSpecificOutput"); + await Assert.That(fragment).DoesNotContain("hookEventName"); + } +} From eeb21db8e3a5426fe47944b36e9ec93c87c0885b Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:09:53 -0400 Subject: [PATCH 2/3] [AI-2095] Address qodo review: capability-free replay spool + docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix (qodo #4): the coordination-notices capability was injected into `body`, which the transient-POST-failure path also spools — so a replay would make the server mark notices delivered that the replay never renders. Inject into a separate POST-only `postBody`; the spool now keeps the capability-free `body`. New test pins that a spooled session-start body carries no coordination_notices while the live POST still advertises it. - Docs (qodo #1, #5): add a README SessionStart coordination-notices bullet (opt-out + capability-gating) next to the sibling injections, and list disable_coordination_notices in `kcap config set` usage. - Comments (qodo #2): trim narration in ProfileConfig / ClaudeHookCommand / CoordinationNoticesEmitter, keeping the non-obvious rationale. - qodo #3 (move emitter under Harness/Claude/): declined — every sibling SessionStart-fragment emitter lives at src/Capacitor.Cli/ root. Co-Authored-By: Claude Opus 4.8 --- README.md | 1 + .../Config/ProfileConfig.cs | 10 +++---- src/Capacitor.Cli/Commands/ConfigCommand.cs | 1 + .../Commands/Harness/ClaudeHookCommand.cs | 27 ++++++++++--------- .../CoordinationNoticesEmitter.cs | 26 +++++++----------- .../Harness/ClaudeHookCommandTests.cs | 25 +++++++++++++++++ 6 files changed, 55 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 15a9e3095b..70538bc314 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,7 @@ Once set up, Capacitor runs silently in the background. Every Claude Code (and C - **SessionStart context injection** — at every session start the server injects top evaluation-derived fact clusters for the current repo into the session's context. The injected block is split into two sections: `## Known patterns` (repo/project facts relevant to any reader) and `## Guidance from past sessions` (agent-targeted action items derived from prior eval suggestions with `audience: "agent"`). Delivered for every supported harness (Claude Code, Codex CLI, GitHub Copilot CLI, Gemini CLI, AWS Kiro CLI, Google Antigravity, Pi, OpenCode, and Cursor's `cursor-agent`): Claude reads it from its hook response, while the other eight fetch `GET /api/repositories/{hash}/guidelines` alongside the team-memory index and emit both in one combined block, through the same per-harness delivery seam as that index (see the next bullet). Opt out by setting `disable_session_guidelines: true` in `~/.config/kcap/config.json` or via `kcap config set disable_session_guidelines true`. - **SessionStart team-memory index** — at every session start (Claude Code, Codex CLI, GitHub Copilot CLI, Gemini CLI, AWS Kiro CLI, Google Antigravity, Pi, OpenCode, and Cursor CLI's `cursor-agent` — see the capability matrix below for the full per-harness rollout) `kcap` also fetches a compact index of durable [team memories](#memory-mcp-server-for-agents) visible for the current repo/machine and appends a `## Team memory` block to the session's injected context (`additionalContext` for Claude, Codex, Copilot, and Gemini, `additional_context` for Cursor, raw stdout for Kiro, `injectSteps`/`userMessage` for Antigravity, system-prompt append via the kcap Pi extension for Pi, and system-entry append via the kcap OpenCode plugin for OpenCode): one `slug: description` line per memory, grouped **Org / Team / Yours**, with a nudge to call `get_memory` / `search_memories` for full content. Only the index is injected — never the bodies — so the cost stays roughly flat as the pool grows (mirrors a local `MEMORY.md`). Best-effort and fail-open (a slow or failed fetch injects nothing, never blocking the hook), and only ever injected once per conversation. Opt out with `disable_memory_index: true` in `~/.config/kcap/config.json` or `kcap config set disable_memory_index true`. - **SessionStart work-items nudge** — at every session start (Claude Code, Codex CLI, GitHub Copilot CLI, Gemini CLI, AWS Kiro CLI, Google Antigravity, Pi, OpenCode, and Cursor CLI's `cursor-agent`) `kcap` appends a short `## Work items` block carrying the current session id and a reminder to register the session with its work item via the [`kcap-workitems` MCP tools](#work-items-mcp-server-for-agents) (`declare_work_item`) and to declare structure as it is discovered (`declare_work_breakdown` for a parent→parts split, `declare_work_relation` for a `blocks`/`blocked_by` dependency). It rides the same per-harness delivery seam as the team-memory index, is composed independently of that index (so it never affects the index's once-per-session lease), and is shown only when `kcap-workitems` is actually registered for the harness. Opt out with `disable_workitems_nudge: true` in `~/.config/kcap/config.json` or `kcap config set disable_workitems_nudge true`. +- **SessionStart coordination notices** — at every session start (Claude Code / the generic route only) `kcap` advertises a `coordination_notices` capability on its `/hooks/session-start` request, and when the server has pending coordination notices for you — a heads-up that other people have in-flight work that may overlap yours (work-overlap / work-item adjacency) — it appends a `## Coordination notices` block to the session's injected context (`additionalContext`), one short line per notice (bounded, with a `+N more in the notification centre` tail when there are more). The same notices always reach the in-app notification centre and Slack regardless; this block just surfaces the most relevant few directly in the agent's context at the moment you start. Best-effort and fail-open (a missing or malformed field injects nothing, never blocking the hook), and the capability is advertised only on a live session start — never from `kcap import`/backfill. Opt out with `disable_coordination_notices: true` in `~/.config/kcap/config.json` or `kcap config set disable_coordination_notices true`; when set, the capability is not sent at all, so the notices stay in the notification centre / Slack only. - **Crash resilience** — if a `kcap` command hits an unexpected error it records the exception (with stack trace) to `~/.config/kcap/crash.log` (honours `KCAP_CONFIG_DIR`; size-capped) and exits cleanly instead of aborting. Hook and detached-generator commands the coding agent spawns **fail open** (exit 0, nothing surfaced to the agent); other commands exit non-zero with a one-line stderr message pointing at the log. The SessionStart memory foundation is deliberately separate from harness activation. Every row uses diff --git a/src/Capacitor.Cli.Core/Config/ProfileConfig.cs b/src/Capacitor.Cli.Core/Config/ProfileConfig.cs index 368b8042dd..76a2cbbdcc 100644 --- a/src/Capacitor.Cli.Core/Config/ProfileConfig.cs +++ b/src/Capacitor.Cli.Core/Config/ProfileConfig.cs @@ -71,12 +71,10 @@ public record Profile { public bool? DisableWorkItemsNudge { get; init; } /// - /// when true, kcap does NOT advertise the coordination-notices capability at SessionStart, - /// so the server injects no coordination notices (heads-up about other people's in-flight work - /// that may overlap yours) into the agent's context — those notices still reach the in-app - /// notification centre and Slack. Independent of the memory-index, guidelines and work-items - /// opt-outs so each SessionStart injection can be toggled separately. Mirrors - /// . + /// when true, kcap does not advertise the coordination-notices capability at SessionStart, so the + /// server injects no coordination notices (heads-up about others' in-flight work that may overlap + /// yours) — they still reach the notification centre and Slack. Independent of the other SessionStart + /// opt-outs. Mirrors . /// [JsonPropertyName("disable_coordination_notices")] public bool? DisableCoordinationNotices { get; init; } diff --git a/src/Capacitor.Cli/Commands/ConfigCommand.cs b/src/Capacitor.Cli/Commands/ConfigCommand.cs index 160cfbafd6..3e9e4f70d2 100644 --- a/src/Capacitor.Cli/Commands/ConfigCommand.cs +++ b/src/Capacitor.Cli/Commands/ConfigCommand.cs @@ -206,6 +206,7 @@ static int SetUsage() { Console.Error.WriteLine(" default_visibility Default session visibility (private, project, org_public, public)"); Console.Error.WriteLine(" disable_session_guidelines Skip injecting recurring-lessons context at SessionStart (true/false)"); Console.Error.WriteLine(" disable_workitems_nudge Skip injecting the work-items nudge at SessionStart (true/false)"); + Console.Error.WriteLine(" disable_coordination_notices Skip injecting coordination notices (others' overlapping work) at SessionStart (true/false)"); Console.Error.WriteLine(" use_provider_api_key Keep ANTHROPIC_API_KEY/OPENAI_API_KEY in headless agent spawns (true/false)"); Console.Error.WriteLine(" excluded_repos Excluded repos, comma-separated (owner/repo,owner/repo)"); Console.Error.WriteLine(" flows.reviewer_vendor Preferred review-flow reviewer vendor (used only when the definition names none)"); diff --git a/src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs b/src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs index b3cc980a74..7006e105d9 100644 --- a/src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs +++ b/src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs @@ -579,24 +579,23 @@ await WatcherManager.EnsureWatcherRunning( return 0; } - // Advertise the coordination-notices capability so the server MAY return - // work-overlap / work-item-adjacency notices to inject into this live agent's - // context below (rendered next to the team-memory index). Injected ONLY here — onto - // the LIVE Claude/generic session-start POST body — and deliberately AFTER the - // ordering-guard/backlog return above, so a spooled body never carries the promise - // (matching the memory fetch's "don't pay for what a replay won't use" rule). - // `kcap import` posts /hooks/session-start/{vendor} with origin=historical and never - // reaches this path; the server also refuses a historical origin. Suppressed by the - // disable_coordination_notices opt-out, read from the EFFECTIVE profile so it is - // honoured for KCAP_URL users too — mirroring the sibling guidelines opt-out below - // (the same defect the memory read above still carries). + // Advertise the coordination-notices capability so the server MAY return work-overlap + // notices to render below (next to the memory index). Injected into a SEPARATE postBody, + // never `body`: `body` is what the transient-failure and ordering-guard paths spool, and a + // replay is a catch-up, not a live render — a spooled capability would let the server mark + // notices delivered that the replay can never inject (they stay in the bell/Slack and reach + // the next LIVE session-start instead). Live-only by construction: `kcap import` posts + // /hooks/session-start/{vendor} with origin=historical and never reaches here. Suppressed by + // the disable_coordination_notices opt-out, read from the EFFECTIVE profile (honoured for + // KCAP_URL users too, unlike the memory read above). Fail-open. var coordinationNoticesDisabled = activeProfile?.DisableCoordinationNotices is true; + var postBody = body; if (!coordinationNoticesDisabled) { try { var node = JsonNode.Parse(body); if (node is not null) { node["coordination_notices"] = CoordinationNoticesEmitter.CapabilityVersion; - body = node.ToJsonString(); + postBody = node.ToJsonString(); } } catch { // Best effort — never fail the hook building the capability field. @@ -626,7 +625,9 @@ await WatcherManager.EnsureWatcherRunning( HttpResponseMessage? resp = null; try { if (remaining > TimeSpan.Zero) { - using var content = new StringContent(body, Encoding.UTF8, "application/json"); + // postBody carries the coordination-notices capability; the spool below uses the + // capability-free `body` so a replay never claims notices it cannot render. + using var content = new StringContent(postBody, Encoding.UTF8, "application/json"); resp = await client.PostOnceAsync($"{baseUrl}/hooks/session-start", content, remaining, CancellationToken.None); } } catch { resp = null; } diff --git a/src/Capacitor.Cli/CoordinationNoticesEmitter.cs b/src/Capacitor.Cli/CoordinationNoticesEmitter.cs index d0d00165b1..78fa700e06 100644 --- a/src/Capacitor.Cli/CoordinationNoticesEmitter.cs +++ b/src/Capacitor.Cli/CoordinationNoticesEmitter.cs @@ -4,23 +4,17 @@ namespace Capacitor.Cli; /// -/// Builds the SessionStart "coordination notices" text fragment from a server -/// /hooks/session-start response body. Returns plain text, not a JSON -/// envelope — the caller (see SessionStartAdditionalContext) joins this -/// fragment with the others (guidelines, version nudge, team-memory index) and -/// serializes a single Claude Code hookSpecificOutput envelope. +/// Builds the SessionStart "coordination notices" fragment from a server +/// /hooks/session-start response. Plain text, not a JSON envelope — the caller +/// (SessionStartAdditionalContext) joins it with the other fragments and wraps one +/// Claude Code hookSpecificOutput envelope. /// -/// The server's terminal-delivery lane returns coordination_notices as a -/// bounded JSON array of { text } one-line warnings (with an optional -/// "+N more in the notification centre" tail line) about other people's in-flight -/// work that may overlap this session — the same notices that also reach the -/// in-app notification centre and Slack. Each entry renders as one bullet under a -/// "Coordination notices" heading. -/// -/// -/// Returns null when disabled, absent, empty, or malformed — the caller -/// drops null fragments, so a missing or empty field emits nothing (fail-open, -/// same as guideline injection and the memory index). +/// The server returns coordination_notices as a bounded array of { text } one-line +/// warnings (optionally with a "+N more in the notification centre" tail) about others' in-flight +/// work that may overlap this session — the same notices that also reach the notification centre +/// and Slack. Each renders as one bullet under a Coordination notices heading. Returns +/// null when disabled, absent, empty, or malformed, so the caller emits nothing (fail-open, +/// like the guidelines and memory-index fragments). /// /// static class CoordinationNoticesEmitter { diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookCommandTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookCommandTests.cs index e090b9b190..83592fc44b 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookCommandTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookCommandTests.cs @@ -407,6 +407,31 @@ public async Task malformed_coordination_notices_field_does_not_fail_the_hook() await Assert.That(stdout).DoesNotContain("## Coordination notices"); } + /// The capability rides only the POST (postBody); the SPOOLED body (for replay on the + /// next hook) must stay capability-free, so a catch-up replay never makes the server mark + /// coordination notices delivered that the replay can't render into a live agent. + [Test, NotInParallel] + public async Task transient_post_failure_spools_a_body_without_the_coordination_notices_capability() { + using var fx = new Fixture(HttpStatusCode.InternalServerError); // 5xx → transient → spooled + var sid = Guid.NewGuid().ToString("N"); + + var (exit, _) = await WithProfileAsync(new Profile(), () => RunCapturingStdoutAsync(() => + fx.HandleAsync($$"""{"hook_event_name":"SessionStart","session_id":"{{sid}}","cwd":"/tmp","source":"startup"}"""))); + await Assert.That(exit).IsEqualTo(0); + + // The live POST DID advertise the capability... + var posted = fx.Sent.Single(s => s.StartsWith("/hooks/session-start|", StringComparison.Ordinal)); + await Assert.That(JsonNode.Parse(posted[(posted.IndexOf('|') + 1)..])!["coordination_notices"]?.GetValue()) + .IsEqualTo("v1"); + + // ...but the spooled body (replayed later) did NOT. + var files = fx.SpoolFiles.ToList(); + await Assert.That(files.Count).IsEqualTo(1); + var content = await File.ReadAllTextAsync(files[0]); + var spooledBody = JsonNode.Parse(JsonNode.Parse(content.Split('\n')[0])!["body"]!.GetValue()); + await Assert.That(spooledBody!["coordination_notices"]).IsNull(); + } + /// Runs with set to /// , restoring whatever was resolved before (or the closest /// equivalent to "untouched" — see AppConfig.SetResolvedState's lack of an "unset" From 3dd5849bde90ecd80fac25ddc16ba06ff3759fe1 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:59:38 -0400 Subject: [PATCH 3/3] [AI-2095] Drop redundant null-forgiving in CoordinationNoticesEmitter (kiro nit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit string.IsNullOrWhiteSpace is [NotNullWhen(false)], so after the guard the compiler already flows `text` as non-null — the `!` was redundant. Still builds warning-free without it. Co-Authored-By: Claude Opus 4.8 --- src/Capacitor.Cli/CoordinationNoticesEmitter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Capacitor.Cli/CoordinationNoticesEmitter.cs b/src/Capacitor.Cli/CoordinationNoticesEmitter.cs index 78fa700e06..162ac10acf 100644 --- a/src/Capacitor.Cli/CoordinationNoticesEmitter.cs +++ b/src/Capacitor.Cli/CoordinationNoticesEmitter.cs @@ -47,7 +47,7 @@ static class CoordinationNoticesEmitter { catch { continue; } if (string.IsNullOrWhiteSpace(text)) continue; - lines.Add($"- {text!.Trim()}"); + lines.Add($"- {text.Trim()}"); } if (lines.Count == 0) return null;