Skip to content

Commit ec33eab

Browse files
committed
feat: add escalation support with clarify command
1 parent 8c51db4 commit ec33eab

5 files changed

Lines changed: 239 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [0.23.0] - 2026-03-22
6+
7+
### Added
8+
- Escalation support: parse clarification questions from VectorCourt verdicts
9+
- `SubmitClarification()` client method for POST /v1/cases/:id/clarify
10+
- `vectorpad clarify <case-id>` CLI command: interactive or `--json` pipe mode
11+
- CLI submit output shows escalation questions with impact level and defaults
12+
- TUI verdict summary shows clarification hint with case ID
13+
514
## [0.22.0] - 2026-03-21
615

716
### Added

cmd/vectorpad/main.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package main
22

33
import (
4+
"bufio"
45
"context"
56
"encoding/json"
67
"fmt"
@@ -58,6 +59,8 @@ func run(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int
5859
return runPrecedent(args[2:], stdin, stdout, stderr)
5960
case "outcome":
6061
return runOutcome(args[2:], stdout, stderr)
62+
case "clarify":
63+
return runClarify(args[2:], stdin, stdout, stderr)
6164
}
6265
}
6366

@@ -848,6 +851,22 @@ func runSubmit(args []string, stdin io.Reader, stdout io.Writer, stderr io.Write
848851
}
849852
}
850853

854+
// Check for escalation questions.
855+
escalation, caseID := tui.ParseEscalation(raw)
856+
if escalation != nil && escalation.Mode == "human_clarification" && len(escalation.Questions) > 0 {
857+
_, _ = fmt.Fprintf(stderr, "\n⚠ clarification needed (%d questions):\n", len(escalation.Questions))
858+
for i, q := range escalation.Questions {
859+
_, _ = fmt.Fprintf(stderr, " %d. [%s] %s\n", i+1, q.ImpactOnVerdict, q.Question)
860+
if q.Context != "" {
861+
_, _ = fmt.Fprintf(stderr, " %s\n", q.Context)
862+
}
863+
if q.DefaultIfUnanswered != "" {
864+
_, _ = fmt.Fprintf(stderr, " default: %s\n", q.DefaultIfUnanswered)
865+
}
866+
}
867+
_, _ = fmt.Fprintf(stderr, "\nrun: vectorpad clarify %s\n", caseID)
868+
}
869+
851870
// Pretty-print the response.
852871
var formatted []byte
853872
var pretty json.RawMessage
@@ -1207,6 +1226,128 @@ complete -c vectorpad -n '__fish_use_subcommand' -a outcome -d 'Report outcome f
12071226
complete -c vectorpad -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish'
12081227
`
12091228

1229+
func runClarify(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int {
1230+
jsonMode := false
1231+
var caseID string
1232+
1233+
for i := 0; i < len(args); i++ {
1234+
switch args[i] {
1235+
case "--json":
1236+
jsonMode = true
1237+
default:
1238+
if caseID == "" {
1239+
caseID = args[i]
1240+
}
1241+
}
1242+
}
1243+
1244+
if caseID == "" {
1245+
_, _ = fmt.Fprintln(stderr, "usage: vectorpad clarify <case-id> [--json]")
1246+
return 1
1247+
}
1248+
1249+
cfg, err := config.Load()
1250+
if err != nil {
1251+
_, _ = fmt.Fprintf(stderr, "error: %v\n", err)
1252+
return 1
1253+
}
1254+
if cfg.VectorCourt.APIKey == "" {
1255+
_, _ = fmt.Fprintln(stderr, "error: no API key configured (run: vectorpad config set vectorcourt.api_key <key>)")
1256+
return 1
1257+
}
1258+
1259+
// JSON mode: read answers from stdin.
1260+
if jsonMode {
1261+
data, err := io.ReadAll(stdin)
1262+
if err != nil {
1263+
_, _ = fmt.Fprintf(stderr, "error: %v\n", err)
1264+
return 1
1265+
}
1266+
var req vectorcourt.ClarifyRequest
1267+
if err := json.Unmarshal(data, &req); err != nil {
1268+
_, _ = fmt.Fprintf(stderr, "error: invalid JSON: %v\n", err)
1269+
return 1
1270+
}
1271+
1272+
client := vectorcourt.NewClient(cfg.Endpoint(), cfg.VectorCourt.APIKey)
1273+
_, _ = fmt.Fprintln(stderr, "submitting clarification...")
1274+
raw, err := client.SubmitClarification(context.Background(), caseID, &req)
1275+
if err != nil {
1276+
_, _ = fmt.Fprintf(stderr, "error: %v\n", err)
1277+
return 1
1278+
}
1279+
1280+
var pretty json.RawMessage
1281+
if json.Unmarshal(raw, &pretty) == nil {
1282+
if f, err := json.MarshalIndent(pretty, "", " "); err == nil {
1283+
_, _ = fmt.Fprintln(stdout, string(f))
1284+
return 0
1285+
}
1286+
}
1287+
_, _ = fmt.Fprintln(stdout, string(raw))
1288+
return 0
1289+
}
1290+
1291+
// Interactive mode: prompt for each answer.
1292+
f, ok := stdin.(*os.File)
1293+
if !ok || !isTerminal(f) {
1294+
_, _ = fmt.Fprintln(stderr, "error: interactive mode requires a terminal (use --json for pipe input)")
1295+
return 1
1296+
}
1297+
1298+
// Fetch the case to get pending questions.
1299+
// We don't have a "get case" endpoint, so prompt the user to provide question IDs.
1300+
_, _ = fmt.Fprintln(stderr, "Enter answers for case "+caseID)
1301+
_, _ = fmt.Fprintln(stderr, "Format: one answer per line as question_id=answer (empty line to submit)")
1302+
_, _ = fmt.Fprintln(stderr, "")
1303+
1304+
scanner := bufio.NewScanner(stdin)
1305+
var answers []vectorcourt.ClarificationAnswer
1306+
for {
1307+
_, _ = fmt.Fprint(stderr, "> ")
1308+
if !scanner.Scan() {
1309+
break
1310+
}
1311+
line := strings.TrimSpace(scanner.Text())
1312+
if line == "" {
1313+
break
1314+
}
1315+
parts := strings.SplitN(line, "=", 2)
1316+
if len(parts) != 2 {
1317+
_, _ = fmt.Fprintln(stderr, " invalid format, use: question_id=answer")
1318+
continue
1319+
}
1320+
answers = append(answers, vectorcourt.ClarificationAnswer{
1321+
QuestionID: strings.TrimSpace(parts[0]),
1322+
Answer: strings.TrimSpace(parts[1]),
1323+
Confidence: "firm",
1324+
})
1325+
}
1326+
1327+
if len(answers) == 0 {
1328+
_, _ = fmt.Fprintln(stderr, "no answers provided")
1329+
return 1
1330+
}
1331+
1332+
client := vectorcourt.NewClient(cfg.Endpoint(), cfg.VectorCourt.APIKey)
1333+
_, _ = fmt.Fprintf(stderr, "submitting %d answers...\n", len(answers))
1334+
raw, err := client.SubmitClarification(context.Background(), caseID, &vectorcourt.ClarifyRequest{Answers: answers})
1335+
if err != nil {
1336+
_, _ = fmt.Fprintf(stderr, "error: %v\n", err)
1337+
return 1
1338+
}
1339+
1340+
var pretty json.RawMessage
1341+
if json.Unmarshal(raw, &pretty) == nil {
1342+
if f, err := json.MarshalIndent(pretty, "", " "); err == nil {
1343+
_, _ = fmt.Fprintln(stdout, string(f))
1344+
return 0
1345+
}
1346+
}
1347+
_, _ = fmt.Fprintln(stdout, string(raw))
1348+
return 0
1349+
}
1350+
12101351
func isTerminal(f *os.File) bool {
12111352
info, err := f.Stat()
12121353
if err != nil {

internal/tui/launch.go

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import (
1010
"strings"
1111

1212
"github.com/ppiankov/vectorpad/internal/config"
13-
"github.com/ppiankov/vectorpad/internal/vectorcourt"
1413
"github.com/ppiankov/vectorpad/internal/stash"
14+
"github.com/ppiankov/vectorpad/internal/vectorcourt"
1515
)
1616

1717
// launchTarget represents a destination for the vector payload.
@@ -136,8 +136,10 @@ func stashVerdict(raw json.RawMessage, question string) {
136136
// formatVerdictSummary extracts a brief status line from the verdict JSON.
137137
func formatVerdictSummary(raw json.RawMessage, gate *vectorcourt.GateResult) string {
138138
var envelope struct {
139-
Verdict string `json:"verdict"`
140-
Status string `json:"status"`
139+
Verdict string `json:"verdict"`
140+
Status string `json:"status"`
141+
CaseID string `json:"case_id"`
142+
Escalation *vectorcourt.EscalationDecision `json:"escalation,omitempty"`
141143
}
142144
if json.Unmarshal(raw, &envelope) == nil {
143145
parts := []string{"vectorcourt"}
@@ -154,11 +156,26 @@ func formatVerdictSummary(raw json.RawMessage, gate *vectorcourt.GateResult) str
154156
if gate != nil && gate.Tier != "" {
155157
parts = append(parts, fmt.Sprintf("(tier: %s)", gate.Tier))
156158
}
159+
if envelope.Escalation != nil && envelope.Escalation.Mode == "human_clarification" {
160+
parts = append(parts, fmt.Sprintf("⚠ %d questions — run: vectorpad clarify %s", len(envelope.Escalation.Questions), envelope.CaseID))
161+
}
157162
return strings.Join(parts, " — ")
158163
}
159164
return fmt.Sprintf("vectorcourt — verdict received (%d bytes)", len(raw))
160165
}
161166

167+
// ParseEscalation extracts escalation data from the raw verdict JSON.
168+
func ParseEscalation(raw json.RawMessage) (*vectorcourt.EscalationDecision, string) {
169+
var envelope struct {
170+
CaseID string `json:"case_id"`
171+
Escalation *vectorcourt.EscalationDecision `json:"escalation,omitempty"`
172+
}
173+
if json.Unmarshal(raw, &envelope) == nil && envelope.Escalation != nil {
174+
return envelope.Escalation, envelope.CaseID
175+
}
176+
return nil, ""
177+
}
178+
162179
func (o *launchOverlay) show() {
163180
o.visible = true
164181
o.cursor = 0

internal/vectorcourt/client.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,43 @@ func (c *Client) InstantPrecedents(ctx context.Context, query string, limit int)
294294
return &result, nil
295295
}
296296

297+
// SubmitClarification sends clarification answers and triggers re-deliberation.
298+
func (c *Client) SubmitClarification(ctx context.Context, caseID string, req *ClarifyRequest) (json.RawMessage, error) {
299+
ctx, cancel := context.WithTimeout(ctx, consultTimeout)
300+
defer cancel()
301+
302+
body, err := json.Marshal(req)
303+
if err != nil {
304+
return nil, fmt.Errorf("encode request: %w", err)
305+
}
306+
307+
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint+"/v1/cases/"+caseID+"/clarify", bytes.NewReader(body))
308+
if err != nil {
309+
return nil, fmt.Errorf("create request: %w", err)
310+
}
311+
httpReq.Header.Set("Content-Type", "application/json")
312+
if c.apiKey != "" {
313+
httpReq.Header.Set(authHeader, c.apiKey)
314+
}
315+
316+
resp, err := c.http.Do(httpReq)
317+
if err != nil {
318+
return nil, fmt.Errorf("clarify request: %w", err)
319+
}
320+
defer func() { _ = resp.Body.Close() }()
321+
322+
respBody, err := io.ReadAll(resp.Body)
323+
if err != nil {
324+
return nil, fmt.Errorf("read response: %w", err)
325+
}
326+
327+
if resp.StatusCode != http.StatusOK {
328+
return nil, parseAPIError(resp.StatusCode, respBody)
329+
}
330+
331+
return json.RawMessage(respBody), nil
332+
}
333+
297334
// GetPredictionDebt fetches the prediction debt health metric.
298335
func (c *Client) GetPredictionDebt(ctx context.Context) (*PredictionDebt, error) {
299336
ctx, cancel := context.WithTimeout(ctx, accountTimeout)

internal/vectorcourt/types.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,38 @@ type SparEvent struct {
132132
Final bool `json:"final"`
133133
}
134134

135+
// EscalationDecision is the council's escalation disposition from the verdict.
136+
type EscalationDecision struct {
137+
Mode string `json:"mode"` // auto_resolve, resolve_with_caveat, human_clarification, human_approval
138+
Score float64 `json:"score"`
139+
Triggers []string `json:"triggers,omitempty"`
140+
Questions []ClarificationQuestion `json:"questions,omitempty"`
141+
}
142+
143+
// ClarificationQuestion is a structured question the council asks the human.
144+
type ClarificationQuestion struct {
145+
ID string `json:"id"`
146+
Type string `json:"type"` // constraint_missing, constraint_conflict, preference, stakes_confirm, approval, falsification
147+
Question string `json:"question"`
148+
Context string `json:"context"`
149+
ConstraintField string `json:"constraint_field,omitempty"`
150+
DefaultIfUnanswered string `json:"default_if_unanswered,omitempty"`
151+
ImpactOnVerdict string `json:"impact_on_verdict"` // high, medium, low
152+
}
153+
154+
// ClarificationAnswer captures the human's response to one clarification question.
155+
type ClarificationAnswer struct {
156+
QuestionID string `json:"question_id"`
157+
Answer string `json:"answer"`
158+
Confidence string `json:"confidence"` // firm, preferred, uncertain
159+
Notes string `json:"notes,omitempty"`
160+
}
161+
162+
// ClarifyRequest is the JSON body for POST /v1/cases/:id/clarify.
163+
type ClarifyRequest struct {
164+
Answers []ClarificationAnswer `json:"answers"`
165+
}
166+
135167
// GateResult is the outcome of a preflight gate check.
136168
type GateResult struct {
137169
Allowed bool

0 commit comments

Comments
 (0)