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
50 changes: 50 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: Test

# The release workflow already runs `go test ./...`, but only on a tag — so a
# broken test first surfaced during a release, with the tag already pushed.
# This moves the same gate to the point where it can still cheaply say no.

on:
pull_request:
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read

# A force-push supersedes the previous run; keep the newest, drop the rest.
# main is exempt: every commit there should keep a result of its own.
concurrency:
group: test-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

jobs:
test:
name: vet + test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod

# Formatting first: it is the cheapest signal and the one most likely to
# differ between a contributor's editor and the repo.
- name: Check formatting
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "::error::gofmt would rewrite these files; run 'gofmt -w .'"
echo "$unformatted"
exit 1
fi

- name: Vet
run: go vet ./...

- name: Test
run: go test ./...
7 changes: 4 additions & 3 deletions cmd/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,10 +173,11 @@ func convergeMCPForAgent(baseDir string, agent *agents.Agent) (bool, error) {
switch agent.ID {
case agents.Cursor:
return agents.ConvergeCursorMCPJSON(path)
case agents.ClaudeCode, agents.GeminiCLI, agents.RooCode:
// Copilot rides the standard shape: its only project-level MCP source is
// the workspace-root .mcp.json, the same file (and key, and entry) as
// claude-code — see copilotMCPPath.
case agents.ClaudeCode, agents.GeminiCLI, agents.RooCode, agents.Copilot:
return agents.ConvergeStandardMCPJSON(path)
case agents.Copilot:
return agents.ConvergeVSCodeMCPJSON(path)
default:
return false, agent.WriteMCPConfig(baseDir)
}
Expand Down
44 changes: 37 additions & 7 deletions cmd/hooks_claude_code.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,28 @@ type hookOutput struct {
SystemMessage string `json:"systemMessage,omitempty"`
}

// copilotHookOutput is Copilot's native sessionStart response: a bare
// top-level additionalContext, no wrapper and no systemMessage slot
// (GitHub's hooks-configuration reference). Copilot's PascalCase
// "Claude-compatible" mode changes only the INPUT payload shape — the output
// schema is the native one either way — so the hooks config we install
// (camelCase "sessionStart") and this writer must agree, or the context is
// parsed as nothing and the session starts blind.
type copilotHookOutput struct {
AdditionalContext string `json:"additionalContext,omitempty"`
}

// sessionOutputShape selects how a host wants session-start context framed.
type sessionOutputShape int

const (
// shapeClaudeCompat — {"hookSpecificOutput": {...}, "systemMessage": ...}.
// Claude Code's native schema, which Cursor and Gemini CLI also accept.
shapeClaudeCompat sessionOutputShape = iota
// shapeCopilotNative — {"additionalContext": "..."}.
shapeCopilotNative
)

// resolveBaseDir returns the base directory from hook input, falling back to cwd.
func resolveBaseDir(input *hookInput) (string, error) {
if input.CWD != "" {
Expand Down Expand Up @@ -82,13 +104,15 @@ func newHooksClaudeCodeCmd(version string) *cobra.Command {
}

func newSessionStartCmd(version string) *cobra.Command {
return newSessionStartHookCmd("session-start", "Handle SessionStart hook event", version)
return newSessionStartHookCmd("session-start", "Handle SessionStart hook event", version, shapeClaudeCompat)
}

// --- Hook command factories ---

// newSessionStartHookCmd creates a session-start hook command (shared across agents).
func newSessionStartHookCmd(use, short, version string) *cobra.Command {
// newSessionStartHookCmd creates a session-start hook command (shared across
// agents). The shape argument is per-host and NOT cosmetic: a host that does
// not recognize the wrapper reads no context at all.
func newSessionStartHookCmd(use, short, version string, shape sessionOutputShape) *cobra.Command {
return &cobra.Command{
Use: use,
Short: short,
Expand All @@ -104,7 +128,7 @@ func newSessionStartHookCmd(use, short, version string) *cobra.Command {
if err != nil {
return err
}
out, err := handleSessionStartDeduped(baseDir, version, input.dedupKey(), defaultSessionStampDir())
out, err := handleSessionStartDeduped(baseDir, version, input.dedupKey(), defaultSessionStampDir(), shape)
if err != nil {
return err
}
Expand Down Expand Up @@ -208,15 +232,15 @@ func sweepExpiredStamps(stampDir string) {
// An empty key or stampDir fails open (always emit); a suppressed repeat
// returns empty output with nil error — hosts must never see a failing hook
// here, just silence.
func handleSessionStartDeduped(baseDir, version, key, stampDir string) ([]byte, error) {
func handleSessionStartDeduped(baseDir, version, key, stampDir string, shape sessionOutputShape) ([]byte, error) {
dedup := key != "" && stampDir != ""
if dedup {
key += "\x00" + baseDir
if !claimSessionStamp(stampDir, key) {
return nil, nil
}
}
out, err := handleSessionStart(baseDir, version)
out, err := handleSessionStart(baseDir, version, shape)
if err != nil {
if dedup {
// Don't let a failed build suppress the retry within the window.
Expand All @@ -229,8 +253,14 @@ func handleSessionStartDeduped(baseDir, version, key, stampDir string) ([]byte,

// --- Session Start Handler (Claude Code adapter) ---

func handleSessionStart(baseDir, version string) ([]byte, error) {
func handleSessionStart(baseDir, version string, shape sessionOutputShape) ([]byte, error) {
ctx, docCount := buildSessionContext(baseDir)
if shape == shapeCopilotNative {
// No systemMessage slot on this host, so the connected line is dropped
// rather than smuggled into additionalContext — it is a cosmetic banner
// for the user, not context for the model.
return json.Marshal(copilotHookOutput{AdditionalContext: ctx})
}
output := hookOutput{
HookSpecificOutput: map[string]any{
"hookEventName": "SessionStart",
Expand Down
4 changes: 2 additions & 2 deletions cmd/hooks_claude_code_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func TestHandleSessionStart_WithDocuments(t *testing.T) {
t.Fatal(err)
}

data, err := handleSessionStart(base, "v0.1.0")
data, err := handleSessionStart(base, "v0.1.0", shapeClaudeCompat)
if err != nil {
t.Fatalf("handleSessionStart: %v", err)
}
Expand Down Expand Up @@ -86,7 +86,7 @@ func TestHandleSessionStart_Empty(t *testing.T) {
t.Parallel()
base := setupArchcoreDir(t)

data, err := handleSessionStart(base, "v0.1.0")
data, err := handleSessionStart(base, "v0.1.0", shapeClaudeCompat)
if err != nil {
t.Fatalf("handleSessionStart: %v", err)
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/hooks_copilot.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ func newHooksCopilotCmd(version string) *cobra.Command {
Short: "Handle GitHub Copilot hook events",
}
cmd.AddCommand(
newSessionStartHookCmd("session-start", "Handle Copilot SessionStart hook event", version),
newSessionStartHookCmd("session-start", "Handle Copilot SessionStart hook event", version, shapeCopilotNative),
)
return cmd
}
77 changes: 72 additions & 5 deletions cmd/hooks_copilot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,84 @@ func TestRunHooksInstallForAgent_CopilotAlsoInstallsMCP(t *testing.T) {
t.Fatalf("runHooksInstallForAgent: %v", err)
}

// .vscode/mcp.json should exist with "servers" key.
data, err := os.ReadFile(filepath.Join(base, ".vscode", "mcp.json"))
// The workspace-root .mcp.json should exist with the "mcpServers" key —
// the only project-level MCP source Copilot CLI reads.
data, err := os.ReadFile(filepath.Join(base, ".mcp.json"))
if err != nil {
t.Fatalf("ReadFile .vscode/mcp.json: %v", err)
t.Fatalf("ReadFile .mcp.json: %v", err)
}

var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if _, ok := raw["servers"]; !ok {
t.Error("missing 'servers' in .vscode/mcp.json")
if _, ok := raw["mcpServers"]; !ok {
t.Error("missing 'mcpServers' in .mcp.json")
}
}

// TestHandleSessionStart_CopilotNativeShape pins the output schema divergence.
// The hooks config we install registers camelCase "sessionStart" — Copilot's
// NATIVE format — whose documented output is a bare top-level
// additionalContext. Emitting Claude's hookSpecificOutput wrapper here parses
// as nothing: the hook exits 0, the user sees no error, and the session simply
// starts with no archcore context. That silence is why this is a test and not
// a comment.
func TestHandleSessionStart_CopilotNativeShape(t *testing.T) {
t.Parallel()
base := setupArchcoreDir(t)

out, err := handleSessionStart(base, "v0.0.0-test", shapeCopilotNative)
if err != nil {
t.Fatalf("handleSessionStart: %v", err)
}

var raw map[string]json.RawMessage
if err := json.Unmarshal(out, &raw); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if _, ok := raw["hookSpecificOutput"]; ok {
t.Error("hookSpecificOutput is Claude's wrapper — Copilot ignores it entirely")
}
if _, ok := raw["systemMessage"]; ok {
t.Error("Copilot's sessionStart schema has no systemMessage slot")
}
ctx, ok := raw["additionalContext"]
if !ok {
t.Fatal("missing top-level additionalContext")
}
var s string
if err := json.Unmarshal(ctx, &s); err != nil {
t.Fatalf("additionalContext must be a JSON string: %v", err)
}
if s == "" {
t.Error("additionalContext is empty — no context would reach the model")
}
}

// The other hosts must keep the Claude-compatible wrapper: same context, same
// helper, different frame. A refactor that collapsed both onto one shape would
// break whichever host it did not pick.
func TestHandleSessionStart_ClaudeCompatShapeUnchanged(t *testing.T) {
t.Parallel()
base := setupArchcoreDir(t)

out, err := handleSessionStart(base, "v0.0.0-test", shapeClaudeCompat)
if err != nil {
t.Fatalf("handleSessionStart: %v", err)
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(out, &raw); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if _, ok := raw["additionalContext"]; ok {
t.Error("top-level additionalContext is Copilot's shape, not Claude's")
}
var wrapper map[string]json.RawMessage
if err := json.Unmarshal(raw["hookSpecificOutput"], &wrapper); err != nil {
t.Fatalf("Unmarshal hookSpecificOutput: %v", err)
}
if _, ok := wrapper["additionalContext"]; !ok {
t.Error("missing additionalContext inside hookSpecificOutput")
}
}
2 changes: 1 addition & 1 deletion cmd/hooks_cursor.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ func newHooksCursorCmd(version string) *cobra.Command {
Short: "Handle Cursor hook events",
}
cmd.AddCommand(
newSessionStartHookCmd("session-start", "Handle Cursor SessionStart hook event", version),
newSessionStartHookCmd("session-start", "Handle Cursor SessionStart hook event", version, shapeClaudeCompat),
)
return cmd
}
2 changes: 1 addition & 1 deletion cmd/hooks_gemini_cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ func newHooksGeminiCLICmd(version string) *cobra.Command {
Short: "Handle Gemini CLI hook events",
}
cmd.AddCommand(
newSessionStartHookCmd("session-start", "Handle Gemini CLI SessionStart hook event", version),
newSessionStartHookCmd("session-start", "Handle Gemini CLI SessionStart hook event", version, shapeClaudeCompat),
)
return cmd
}
20 changes: 10 additions & 10 deletions cmd/hooks_session_dedup_spec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func TestHandleSessionStart_DedupesBySessionID_Spec(t *testing.T) {
stampDir := t.TempDir()

// 1. First call for session "s1": full context emitted.
first, err := handleSessionStartDeduped(base, "v0.0.0-test", "s1", stampDir)
first, err := handleSessionStartDeduped(base, "v0.0.0-test", "s1", stampDir, shapeClaudeCompat)
if err != nil {
t.Fatalf("first call: %v", err)
}
Expand All @@ -43,7 +43,7 @@ func TestHandleSessionStart_DedupesBySessionID_Spec(t *testing.T) {

// 2. Repeat for the SAME session: empty output, nil error — hosts must
// never see a failing hook here, just silence.
second, err := handleSessionStartDeduped(base, "v0.0.0-test", "s1", stampDir)
second, err := handleSessionStartDeduped(base, "v0.0.0-test", "s1", stampDir, shapeClaudeCompat)
if err != nil {
t.Fatalf("repeat call must not error: %v", err)
}
Expand All @@ -52,7 +52,7 @@ func TestHandleSessionStart_DedupesBySessionID_Spec(t *testing.T) {
}

// 3. A DIFFERENT session emits again — dedup is per-session, not global.
other, err := handleSessionStartDeduped(base, "v0.0.0-test", "s2", stampDir)
other, err := handleSessionStartDeduped(base, "v0.0.0-test", "s2", stampDir, shapeClaudeCompat)
if err != nil {
t.Fatalf("other-session call: %v", err)
}
Expand All @@ -67,7 +67,7 @@ func TestHandleSessionStart_EmptyKeyFailsOpen(t *testing.T) {
stampDir := t.TempDir()

for i := 1; i <= 2; i++ {
out, err := handleSessionStartDeduped(base, "v0.0.0-test", "", stampDir)
out, err := handleSessionStartDeduped(base, "v0.0.0-test", "", stampDir, shapeClaudeCompat)
if err != nil {
t.Fatalf("call %d: %v", i, err)
}
Expand All @@ -89,7 +89,7 @@ func TestHandleSessionStart_UnwritableStampDirFailsOpen(t *testing.T) {
stampDir := filepath.Join(parent, "stamps")

for i := 1; i <= 2; i++ {
out, err := handleSessionStartDeduped(base, "v0.0.0-test", "s1", stampDir)
out, err := handleSessionStartDeduped(base, "v0.0.0-test", "s1", stampDir, shapeClaudeCompat)
if err != nil {
t.Fatalf("call %d must stay exit-0 with unwritable stamp dir: %v", i, err)
}
Expand All @@ -104,7 +104,7 @@ func TestHandleSessionStart_ExpiredStampReEmits(t *testing.T) {
base := setupArchcoreDir(t)
stampDir := t.TempDir()

if _, err := handleSessionStartDeduped(base, "v0.0.0-test", "s1", stampDir); err != nil {
if _, err := handleSessionStartDeduped(base, "v0.0.0-test", "s1", stampDir, shapeClaudeCompat); err != nil {
t.Fatal(err)
}
// Age the stamp beyond the window: the next call must emit again. The
Expand All @@ -115,7 +115,7 @@ func TestHandleSessionStart_ExpiredStampReEmits(t *testing.T) {
t.Fatalf("aging stamp: %v", err)
}

out, err := handleSessionStartDeduped(base, "v0.0.0-test", "s1", stampDir)
out, err := handleSessionStartDeduped(base, "v0.0.0-test", "s1", stampDir, shapeClaudeCompat)
if err != nil {
t.Fatal(err)
}
Expand All @@ -141,7 +141,7 @@ func TestHandleSessionStart_ConcurrentDoubleFire_ExactlyOneEmits(t *testing.T) {
for i := range n {
wg.Go(func() {
<-start
outs[i], errs[i] = handleSessionStartDeduped(base, "v0.0.0-test", "s1", stampDir)
outs[i], errs[i] = handleSessionStartDeduped(base, "v0.0.0-test", "s1", stampDir, shapeClaudeCompat)
})
}
close(start)
Expand Down Expand Up @@ -170,15 +170,15 @@ func TestHandleSessionStart_SameSessionDifferentProjectsBothEmit(t *testing.T) {
baseB := setupArchcoreDir(t)
stampDir := t.TempDir()

outA, err := handleSessionStartDeduped(baseA, "v0.0.0-test", "s1", stampDir)
outA, err := handleSessionStartDeduped(baseA, "v0.0.0-test", "s1", stampDir, shapeClaudeCompat)
if err != nil {
t.Fatalf("project A: %v", err)
}
if len(outA) == 0 {
t.Fatal("project A must emit")
}

outB, err := handleSessionStartDeduped(baseB, "v0.0.0-test", "s1", stampDir)
outB, err := handleSessionStartDeduped(baseB, "v0.0.0-test", "s1", stampDir, shapeClaudeCompat)
if err != nil {
t.Fatalf("project B: %v", err)
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ func newInitCmd() *cobra.Command {
}

cmd.Flags().StringArrayVar(&agentFlags, "agent", nil,
"non-interactive: initialize and install hooks + MCP config + usage hint for the given agent id (repeatable; e.g. claude-code, cursor, codex-cli)")
"non-interactive: initialize and install hooks + MCP config + usage hint for the given agent id (repeatable; e.g. claude-code, cursor, codex-cli, copilot)")
cmd.Flags().StringVar(&projectFlag, "project", "",
"project root to initialize (default: current directory; env: ARCHCORE_PROJECT_ROOT)")
return cmd
Expand Down
Loading
Loading