Skip to content

Commit bab23d4

Browse files
authored
feat: integrate Reflect with Rivet, Resolve, and Record (#357)
* test: accept legacy Cloud capability in contract stub * fix: select Reflect keychain before credential status reads * fix: print one response and aggregate stream usage * chore: remove GitNexus requirements * fix: finish setup with a model selection * feat: clarify first-run readiness and terminal prompts * fix: guide setup to a verified model * feat: run Resolve tickets through Reflect and Record * feat: optionally publish verified ticket work as draft PR * feat: build from request across local factory products * feat: verify Reflect work through Rivet * chore: fix format, help golden, and module drift - apply gofumpt to chat view and work command - update root help golden for the work command - assert output in work E2E wrapper tests - promote go-keyring to a direct dependency after go work sync * fix: satisfy lint checks in work command - lowercase error strings while keeping product names - check the Fprintln result - annotate intentional external command execution for gosec - use CommandContext for git invocations
1 parent eac4c29 commit bab23d4

36 files changed

Lines changed: 1531 additions & 420 deletions

‎.gitignore‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,6 @@ reflect-sec105b.log
5959
*.test
6060

6161
# Agent instructions: this repo is AGENTS.md-only.
62-
# `gitnexus analyze` regenerates a root CLAUDE.md that would shadow AGENTS.md
63-
# for tools that prefer it; keep the regenerated copy out of git.
6462
/CLAUDE.md
6563

6664
# Local module overlay for building against a sibling checkout (e.g. relay).

‎AGENTS.md‎

Lines changed: 0 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -198,51 +198,6 @@ and facade integration. Concentrate AI is a pay-as-you-go gateway implemented
198198
with its native Responses API (`/v1/responses`) under the
199199
`concentrate-payg` deployment.
200200

201-
<!-- gitnexus:start -->
202-
## GitNexus — Code Intelligence
203-
204-
This project is indexed by GitNexus as **reflect** (97743 symbols, 322940 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
205-
206-
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).
207-
208-
## Always Do
209-
210-
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
211-
- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: `detect_changes({scope: "compare", base_ref: "main"})`.
212-
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
213-
- When exploring unfamiliar code, use `query({search_query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
214-
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
215-
- For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`).
216-
217-
## Never Do
218-
219-
- NEVER edit a function, class, or method without first running `impact` on it.
220-
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
221-
- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph.
222-
- NEVER commit changes without running `detect_changes()` to check affected scope.
223-
224-
## Resources
225-
226-
| Resource | Use for |
227-
|----------|---------|
228-
| `gitnexus://repo/reflect/context` | Codebase overview, check index freshness |
229-
| `gitnexus://repo/reflect/clusters` | All functional areas |
230-
| `gitnexus://repo/reflect/processes` | All execution flows |
231-
| `gitnexus://repo/reflect/process/{name}` | Step-by-step execution trace |
232-
233-
## CLI
234-
235-
| Task | Read this skill file |
236-
|------|---------------------|
237-
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
238-
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
239-
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
240-
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
241-
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
242-
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
243-
244-
<!-- gitnexus:end -->
245-
246201
### Workspace workflow (sibling repos)
247202

248203
reflect depends on ecosystem repos (`relay`, etc.) as independent sibling repos in the `graycode-eco` workspace. Reflect's `go.work` lists them as `../<repo>`, so local changes in any sibling are automatically picked up by reflect. Each sibling is its own git repo, versioned and released independently.

‎README.md‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,39 @@ checklist: credentials → model → catalog → ecosystem.
107107
See [docs/SECURITY-DEVELOPER.md](docs/SECURITY-DEVELOPER.md) for the credential model.
108108
Do not put API keys in shell env or `.env` for reflect.
109109

110+
### Build from a request
111+
112+
Build the sibling CLIs once, then run the workflow against a clean Git repository:
113+
114+
```bash
115+
(cd ../resolve && go build -o resolve ./cmd/resolve)
116+
(cd ../record && go build -o record ./cmd/record)
117+
(cd ../recall && go build -o recall ./cmd/recall)
118+
(cd ../rally && go build -o rally ./cmd/rally)
119+
(cd ../rivet && go build -o rivet ./cmd/rivet)
120+
./reflect work --request 'Build the requested feature' --repo /path/to/project \
121+
--resolve-bin ../resolve/resolve --record-bin ../record/record \
122+
--recall-bin ../recall/recall --rally-bin ../rally/rally \
123+
--rivet-bin ../rivet/rivet --verify 'go test ./...'
124+
```
125+
126+
`reflect work` creates a Resolve work item automatically, reads Recall context,
127+
creates a branch and worktree, and asks Reflect to implement the request through
128+
Relay. It verifies the resulting commit, creates a Record checkpoint, stores
129+
a sourced Recall outcome, and posts Rally updates. The item moves to
130+
`in_review`; the output prints the worktree, checkpoint, and memory IDs. You
131+
can also pass an existing Resolve ID instead of `--request`. Failed runs keep
132+
the worktree for inspection. Add `--pr` to push the verified branch to `origin` and
133+
open a draft GitHub PR through `gh`; without it, the work stays local.
134+
With `--rivet-bin`, verification keeps a full raw log outside the worktree and
135+
shows a compact result. Use `--verify-block NAME` instead of `--verify` to
136+
verify and run a Rivet block already committed in the target repository.
137+
138+
The default `--auto semi` allows file edits but denies unattended shell tool
139+
calls. Use `--auto full` when the agent needs shell tools and you trust the
140+
ticket and repository. See [docs/WORKFLOW.md](docs/WORKFLOW.md) for the full
141+
local workflow and its current limits.
142+
110143
Optional: install the current `main` with the Go toolchain. Historical
111144
higher-numbered hawk tags mean `@latest` cannot be relied on for the reset
112145
version series. Use `@main` or an explicit Reflect release tag.

‎cmd/chat.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -541,7 +541,7 @@ func (m *chatModel) refreshInputPlaceholder() {
541541
}
542542

543543
func (m chatModel) Init() tea.Cmd {
544-
cmds := []tea.Cmd{initTerminalMouseCmd(m.mouseEnabled()), promptKeepAliveCmd(), eyeBlinkTickCmd()}
544+
cmds := []tea.Cmd{initTerminalMouseCmd(m.mouseEnabled()), promptKeepAliveCmd()}
545545
if m.mascotEnabled {
546546
cmds = append(cmds, emitReflectMascotCmd())
547547
}

‎cmd/chat_print.go‎

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ func runPrint(text string) error {
7676
var printed strings.Builder
7777
var countdownShown bool
7878
var lastUsage *engine.StreamUsage
79-
turns := 0
79+
turns := 1 // One print invocation is one user turn, even if usage arrives in parts.
8080
started := time.Now()
8181
for ev := range ch {
8282
switch ev.Type {
@@ -114,8 +114,7 @@ func runPrint(text string) error {
114114
}
115115
case "usage":
116116
if ev.Usage != nil {
117-
lastUsage = ev.Usage
118-
turns++
117+
lastUsage = mergePrintUsage(lastUsage, ev.Usage)
119118
}
120119
if outputFormat == "stream-json" && ev.Usage != nil {
121120
writePrintUsageEvent(sessionID, ev.Usage)
@@ -128,7 +127,7 @@ func runPrint(text string) error {
128127
case "done":
129128
switch outputFormat {
130129
case "text":
131-
printTextResponse(printed.String())
130+
finishPrintTextResponse(printed.String())
132131
printTextUsageFooter(lastUsage, started, turns, effectiveModel)
133132
case "transcript":
134133
printTranscript(sessionID, effectiveModel, printed.String(), lastUsage, started, turns)
@@ -148,7 +147,7 @@ func runPrint(text string) error {
148147
}
149148
switch outputFormat {
150149
case "text":
151-
printTextResponse(printed.String())
150+
finishPrintTextResponse(printed.String())
152151
printTextUsageFooter(lastUsage, started, turns, effectiveModel)
153152
case "json":
154153
writePrintResult(printed.String(), sessionID, sess, false, nil)
@@ -161,6 +160,40 @@ func runPrint(text string) error {
161160
return nil
162161
}
163162

163+
// Anthropic streams input and output usage in separate events. The footer and
164+
// transcript show the total for this invocation, not only the last event.
165+
func mergePrintUsage(total, next *engine.StreamUsage) *engine.StreamUsage {
166+
if next == nil {
167+
return total
168+
}
169+
if total == nil {
170+
total = &engine.StreamUsage{}
171+
}
172+
total.PromptTokens += next.PromptTokens
173+
total.CompletionTokens += next.CompletionTokens
174+
total.CacheReadTokens += next.CacheReadTokens
175+
total.CacheWriteTokens += next.CacheWriteTokens
176+
if next.Provider != "" {
177+
total.Provider = next.Provider
178+
}
179+
if next.Model != "" {
180+
total.Model = next.Model
181+
}
182+
return total
183+
}
184+
185+
// Plain text has already been streamed to stdout; only a trailing newline may
186+
// be needed. Markdown mode buffers content so it can be rendered once here.
187+
func finishPrintTextResponse(response string) {
188+
if printMarkdown {
189+
printTextResponse(response)
190+
return
191+
}
192+
if !strings.HasSuffix(response, "\n") {
193+
fmt.Println()
194+
}
195+
}
196+
164197
func writePrintUsageEvent(sessionID string, usage *engine.StreamUsage) {
165198
usageEvent := map[string]int{
166199
"prompt_tokens": usage.PromptTokens,

‎cmd/chat_print_usage_test.go‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,3 +119,32 @@ func TestPrintTextUsageFooter(t *testing.T) {
119119
}
120120
})
121121
}
122+
123+
func TestMergePrintUsageAcrossProviderEvents(t *testing.T) {
124+
got := mergePrintUsage(nil, &engine.StreamUsage{PromptTokens: 11, CacheReadTokens: 90})
125+
got = mergePrintUsage(got, &engine.StreamUsage{CompletionTokens: 42, CacheWriteTokens: 7})
126+
if got.PromptTokens != 11 || got.CompletionTokens != 42 || got.CacheReadTokens != 90 || got.CacheWriteTokens != 7 {
127+
t.Fatalf("partial provider usage was lost: %+v", got)
128+
}
129+
}
130+
131+
func TestFinishPrintTextResponseDoesNotRepeatStreamedContent(t *testing.T) {
132+
oldMarkdown := printMarkdown
133+
printMarkdown = false
134+
defer func() { printMarkdown = oldMarkdown }()
135+
oldStdout := os.Stdout
136+
r, w, err := os.Pipe()
137+
if err != nil {
138+
t.Fatal(err)
139+
}
140+
os.Stdout = w
141+
defer func() { os.Stdout = oldStdout }()
142+
finishPrintTextResponse("Hello")
143+
_ = w.Close()
144+
buf := make([]byte, 64)
145+
n, _ := r.Read(buf)
146+
_ = r.Close()
147+
if got := string(buf[:n]); got != "\n" {
148+
t.Fatalf("completion repeated streamed text: %q", got)
149+
}
150+
}

‎cmd/chat_view.go‎

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -533,7 +533,7 @@ func (m chatModel) terminalView(content string) tea.View {
533533
// timeoutAt is non-zero, a visual countdown bar is shown above the options
534534
// so the user can see how long they have to decide before the prompt auto-dismisses.
535535
func renderPermissionBox(summary string, width int, timeoutAt time.Time) string {
536-
title := lipgloss.NewStyle().Foreground(warnAmber).Bold(true).Render(icons.Alert() + " Permission required")
536+
title := lipgloss.NewStyle().Foreground(warnAmber).Bold(true).Render("Permission request")
537537
// Multi-line body: risk badge, action summary, short "why" (from FormatPermissionDisplay).
538538
bodyWidth := width - 10
539539
if bodyWidth < 20 {
@@ -552,13 +552,18 @@ func renderPermissionBox(summary string, width int, timeoutAt time.Time) string
552552
}
553553
}
554554
body := lipgloss.JoinVertical(lipgloss.Left, bodyParts...)
555-
options := lipgloss.NewStyle().Foreground(reflectColor).Render(strings.Join([]string{
556-
"[y] allow once [n] deny once",
557-
"[s] allow this action for session",
558-
"[d] deny this tool for session",
559-
"[p] allow exact for project [x] deny exact for project",
560-
}, "\n"))
561-
hint := lipgloss.NewStyle().Foreground(textMuted).Render("Session rules reset on exit · Project rules persist · Esc denies · 5m timeout")
555+
primary := "[y] Allow once [n] Deny"
556+
secondary := "Remember this action: [s] Session [p] Project\nBlock: [d] Tool for session [x] Action for project"
557+
if width < 60 {
558+
primary = "[y] Allow once\n[n] Deny"
559+
secondary = "Allow action: [s] Session [p] Project\nBlock: [d] Tool/session [x] Action/project"
560+
}
561+
options := lipgloss.JoinVertical(
562+
lipgloss.Left,
563+
lipgloss.NewStyle().Foreground(reflectColor).Bold(true).Render(primary),
564+
lipgloss.NewStyle().Foreground(textMuted).Render(secondary),
565+
)
566+
hint := lipgloss.NewStyle().Foreground(textMuted).Render("Session rules end on exit · Project rules persist · Esc denies · 5m timeout")
562567

563568
return renderPromptCard(title, body, options, hint, warnAmber, width, timeoutAt)
564569
}
@@ -567,13 +572,13 @@ func renderPermissionBox(summary string, width int, timeoutAt time.Time) string
567572
// normal permission prompt, while making the extra high-risk checkpoint
568573
// visually distinct.
569574
func renderApprovalBox(summary string, width int, timeoutAt time.Time) string {
570-
title := lipgloss.NewStyle().Foreground(errorCoral).Bold(true).Render(icons.Alert() + " High-risk approval required")
575+
title := lipgloss.NewStyle().Foreground(errorCoral).Bold(true).Render("High-risk action")
571576
body := renderPromptBody(summary, width)
572-
options := lipgloss.NewStyle().Foreground(reflectColor).Render(strings.Join([]string{
573-
"[y] approve once [n] deny once",
574-
"[s] approve category for session",
575-
"[5] approve next 5 actions",
576-
}, "\n"))
577+
options := lipgloss.JoinVertical(
578+
lipgloss.Left,
579+
lipgloss.NewStyle().Foreground(reflectColor).Bold(true).Render("[y] Approve once [n] Deny"),
580+
lipgloss.NewStyle().Foreground(textMuted).Render("[s] Approve category for session\n[5] Approve next 5 actions"),
581+
)
577582
hint := lipgloss.NewStyle().Foreground(textMuted).Render("Esc denies · expires after 5 minutes")
578583
return renderPromptCard(title, body, options, hint, errorCoral, width, timeoutAt)
579584
}

‎cmd/chat_welcome.go‎

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -112,20 +112,9 @@ func buildWelcomeMessageWithSnapshotAndMascot(sess *engine.Session, sessionID st
112112
if err != nil {
113113
workspace = "."
114114
}
115-
details := []string{heading, ansiDim + selection + ansiReset, ansiDim + shortenHomePath(workspace) + ansiReset, ""}
116-
mascot := reflectRobotArtLines()
117-
if width-indent >= 50 {
118-
for i, art := range mascot {
119-
b.WriteString(line(ansiOrange + art + ansiReset + " " + details[i]))
120-
}
121-
} else {
122-
for _, art := range mascot {
123-
b.WriteString(line(ansiOrange + art + ansiReset))
124-
}
125-
for _, detail := range details[:3] {
126-
b.WriteString(line(detail))
127-
}
128-
}
115+
b.WriteString(line(heading))
116+
b.WriteString(line(ansiDim + shortenHomePath(workspace) + ansiReset))
117+
b.WriteString(line(ansiDim + selection + ansiReset))
129118
b.WriteString("\n")
130119
if indicators := welcomeIndicatorRow(skillsCount, snapshot.agentsOK, mcpCount, ansiDim, ansiGrayDim, ansiReset, "", ""); indicators != "" {
131120
b.WriteString(line(indicators))

‎cmd/permission_view_test.go‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,16 @@ import (
1111
func TestRenderPermissionBoxKeepsApprovalActionsReadable(t *testing.T) {
1212
got := renderPermissionBox("Bash [HIGH risk]\nrun git status\nWhy: execute a command", 80, time.Now().Add(time.Minute))
1313
for _, want := range []string{
14-
"[y] allow once [n] deny once",
15-
"[s] allow this action for session",
16-
"[d] deny this tool for session",
17-
"[p] allow exact for project [x] deny exact for project",
14+
"[y] Allow once [n] Deny",
15+
"Remember this action: [s] Session [p] Project",
16+
"Block: [d] Tool for session [x] Action for project",
1817
"Esc denies",
1918
} {
2019
if !strings.Contains(got, want) {
2120
t.Fatalf("permission card missing %q: %q", want, got)
2221
}
2322
}
24-
if strings.Count(got, "allow once") != 1 {
23+
if strings.Count(got, "Allow once") != 1 {
2524
t.Fatalf("permission action should be rendered once: %q", got)
2625
}
2726
}
@@ -40,9 +39,9 @@ func TestRenderPermissionBoxFitsNarrowTerminals(t *testing.T) {
4039
func TestRenderApprovalBoxKeepsKeyboardActionsReadable(t *testing.T) {
4140
got := renderApprovalBox("network: curl https://example.com", 80, time.Now().Add(time.Minute))
4241
for _, want := range []string{
43-
"[y] approve once [n] deny once",
44-
"[s] approve category for session",
45-
"[5] approve next 5 actions",
42+
"[y] Approve once [n] Deny",
43+
"[s] Approve category for session",
44+
"[5] Approve next 5 actions",
4645
"Esc denies",
4746
} {
4847
if !strings.Contains(got, want) {

‎cmd/prompt_input.go‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ func (p promptInput) readLine(prompt string) (string, error) {
5353

5454
func configureInteractivePrompts(sess *engine.Session, input promptInput) {
5555
sess.PermSvc().SetPermissionFn(func(req safety.PermissionRequest) {
56-
answer, err := input.readLine(fmt.Sprintf("\nAllow %s: %s [y/N, s=session, d=deny session, p=allow project, x=deny project] ", req.ToolName, req.Summary))
56+
answer, err := input.readLine(formatInteractivePermissionPrompt(req))
5757
if err != nil {
5858
_, _ = fmt.Fprintf(os.Stderr, "\nAllow %s: %s (denied: %v)\n", req.ToolName, req.Summary, err)
5959
resolvePermissionResponse(&req, false)
@@ -94,3 +94,7 @@ func configureInteractivePrompts(sess *engine.Session, input promptInput) {
9494
return input.readLine(fmt.Sprintf("\n%s\n> ", question))
9595
})
9696
}
97+
98+
func formatInteractivePermissionPrompt(req safety.PermissionRequest) string {
99+
return fmt.Sprintf("\nPermission request\n%s\n\n[y] Allow once [n] Deny\n[s] Allow action for session [p] Allow action for project\n[d] Block tool for session [x] Block action for project\nChoice (default deny): ", safety.FormatPermissionDisplay(req.ToolName, req.Summary))
100+
}

0 commit comments

Comments
 (0)