diff --git a/README.md b/README.md index 1cc04e4..ee97d02 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,8 @@ When chaining intent into intent, `INTENT_PIPE_FROM=intent` is used to auto-enab INTENT_PIPE_FROM=intent i --from-intent --json "if upstream output indicates failure, exit 1 else 0" ``` +The inter-intent JSON envelope also carries the upstream prompt and cwd alongside the response metadata and captured stdout, so downstream invocations can keep path context instead of treating bare filenames as if they came from the current directory. + --- ## Managing models diff --git a/docs/SPEC.md b/docs/SPEC.md index c385f9f..2517d51 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -112,7 +112,19 @@ This is the silent contract that makes `i ... | i ... | i ...` work. When `intent` writes to a pipe, it sets the env var `INTENT_PIPE_FROM=intent` for the child process. When `intent` starts and detects this var on stdin (via parent process inspection or env passthrough through `sh -c`), it auto-enables `--json` for input parsing and `--from-intent` semantics. -The wire format is `{"intent_response": ResponseObject}` — see §2. +The wire format is a JSON object with at least `intent_response`, plus execution metadata the downstream turn may need for context preservation: + +```json +{ + "intent_response": ResponseObject, + "prompt": "original natural-language request", + "cwd": "/absolute/working/directory", + "exit_code": 0, + "stdout": "captured stdout when execution happened" +} +``` + +`prompt` and `cwd` exist specifically so chained invocations can keep path and target context even when `stdout` only contains bare filenames or other abbreviated output. ## 2. Model contract diff --git a/internal/cli/intent.go b/internal/cli/intent.go index 845f0ff..09a278b 100644 --- a/internal/cli/intent.go +++ b/internal/cli/intent.go @@ -336,7 +336,7 @@ func cmdIntent(ctx context.Context, args []string) int { // --json short-circuit (no execution, just emit). if fl.json && resp.Approach != model.ApproachCommand && resp.Approach != model.ApproachScript { - emitJSON(resp, nil, 0) + emitJSON(resp, fl.prompt, nil, 0) return 0 } @@ -344,7 +344,7 @@ func cmdIntent(ctx context.Context, args []string) int { switch resp.Approach { case model.ApproachInform: if fl.json { - emitJSON(resp, []byte(resp.StdoutToUser), 0) + emitJSON(resp, fl.prompt, []byte(resp.StdoutToUser), 0) } else { fmt.Print(resp.StdoutToUser) } @@ -381,7 +381,7 @@ func cmdIntent(ctx context.Context, args []string) int { if fl.explain { if fl.json { - emitJSON(resp, nil, 0) + emitJSON(resp, fl.prompt, nil, 0) } else if !fl.quiet { renderProposal(resp, res.CacheHit, style) fmt.Fprintln(os.Stderr, style.Dim(" --explain: not executing.")) @@ -405,7 +405,7 @@ func cmdIntent(ctx context.Context, args []string) int { if !fl.quiet { fmt.Fprintln(os.Stderr, style.Dim(" --dry: not executing.")) } else if fl.json { - emitJSON(resp, nil, 0) + emitJSON(resp, fl.prompt, nil, 0) } else { // Non-TTY --dry: print the command to stdout so callers can // still capture it. @@ -510,7 +510,7 @@ execute: } if fl.json { - emitJSON(resp, stdoutBuf.Bytes(), runRes.ExitCode) + emitJSON(resp, fl.prompt, stdoutBuf.Bytes(), runRes.ExitCode) } auditEntry.UserDecision = decisionLabel(autoConfirm) @@ -597,10 +597,14 @@ func executeForBool(ctx context.Context, resp *model.Response, fl *intentFlags, return r, 1 } -func emitJSON(resp *model.Response, stdoutBytes []byte, exitCode int) { +func emitJSON(resp *model.Response, prompt string, stdoutBytes []byte, exitCode int) { out := map[string]any{ "intent_response": resp, "exit_code": exitCode, + "prompt": prompt, + } + if cwd, err := os.Getwd(); err == nil && cwd != "" { + out["cwd"] = cwd } if stdoutBytes != nil { out["stdout"] = string(stdoutBytes) @@ -696,7 +700,9 @@ func truncateForPrompt(s string, max int) string { // fromIntent is true it is labelled as upstream-intent context, and if the // payload is the JSON envelope emitted by emitJSON we unpack it into a // short semantic summary so the downstream prompt does not have to teach -// the model how to parse the envelope. +// the model how to parse the envelope. The summary includes the upstream +// natural-language prompt and cwd so chained invocations can preserve path +// context even when stdout only contains bare filenames. func formatStdinForPrompt(stdinData string, fromIntent bool) string { if stdinData == "" { return "" @@ -723,14 +729,22 @@ func summarizeIntentEnvelope(data string) (string, bool) { IntentResponse *model.Response `json:"intent_response"` ExitCode *int `json:"exit_code"` Stdout *string `json:"stdout"` + Prompt string `json:"prompt"` + Cwd string `json:"cwd"` } if err := json.Unmarshal([]byte(trimmed), &env); err != nil { return "", false } - if env.IntentResponse == nil && env.ExitCode == nil && env.Stdout == nil { + if env.IntentResponse == nil && env.ExitCode == nil && env.Stdout == nil && env.Prompt == "" && env.Cwd == "" { return "", false } var b strings.Builder + if env.Prompt != "" { + fmt.Fprintf(&b, " prompt: %s\n", env.Prompt) + } + if env.Cwd != "" { + fmt.Fprintf(&b, " cwd: %s\n", env.Cwd) + } if env.IntentResponse != nil { if env.IntentResponse.IntentSummary != "" { fmt.Fprintf(&b, " summary: %s\n", env.IntentResponse.IntentSummary) diff --git a/internal/cli/intent_test.go b/internal/cli/intent_test.go index 999803c..d8af3b4 100644 --- a/internal/cli/intent_test.go +++ b/internal/cli/intent_test.go @@ -74,6 +74,8 @@ func TestFormatStdinForPrompt_FromIntentNonJSON(t *testing.T) { func TestFormatStdinForPrompt_FromIntentEnvelopeUnpacks(t *testing.T) { envelope := `{ + "prompt": "list files in ~/dir", + "cwd": "/Users/coreyrdean/project", "intent_response": { "intent_summary": "Check disk usage", "approach": "command", @@ -90,6 +92,12 @@ func TestFormatStdinForPrompt_FromIntentEnvelopeUnpacks(t *testing.T) { if !strings.Contains(got, "[upstream intent result]") { t.Fatalf("expected envelope framing, got: %q", got) } + if !strings.Contains(got, "prompt: list files in ~/dir") { + t.Fatalf("expected prompt to be extracted, got: %q", got) + } + if !strings.Contains(got, "cwd: /Users/coreyrdean/project") { + t.Fatalf("expected cwd to be extracted, got: %q", got) + } if !strings.Contains(got, "summary: Check disk usage") { t.Fatalf("expected summary to be extracted, got: %q", got) } @@ -111,6 +119,35 @@ func TestFormatStdinForPrompt_FromIntentFallsBackOnNonEnvelopeJSON(t *testing.T) } } +func TestFormatStdinForPrompt_FromIntentEnvelopeKeepsPathContext(t *testing.T) { + envelope := `{ + "prompt": "list files in ~/dir", + "cwd": "/Users/coreyrdean/project", + "intent_response": { + "intent_summary": "List files in the requested directory.", + "approach": "command", + "command": "ls ~/dir", + "description": "List the files in ~/dir.", + "risk": "safe", + "expected_runtime": "instant", + "confidence": "high" + }, + "exit_code": 0, + "stdout": "file.md\n" + }` + got := formatStdinForPrompt(envelope, true) + for _, needle := range []string{ + "prompt: list files in ~/dir", + "cwd: /Users/coreyrdean/project", + "command: ls ~/dir", + "stdout: file.md", + } { + if !strings.Contains(got, needle) { + t.Fatalf("expected formatted upstream context to contain %q, got: %q", needle, got) + } + } +} + func TestFormatStdinForPrompt_Empty(t *testing.T) { if got := formatStdinForPrompt("", true); got != "" { t.Fatalf("expected empty string for empty stdin, got: %q", got) diff --git a/internal/cli/smoke_test.go b/internal/cli/smoke_test.go index 19b9c0c..7be6553 100644 --- a/internal/cli/smoke_test.go +++ b/internal/cli/smoke_test.go @@ -164,6 +164,14 @@ func TestMockExecuteJSON(t *testing.T) { if strings.TrimSpace(gotStdout) == "" { t.Fatalf("expected JSON field stdout to contain executed command output, got: %#v", result) } + gotPrompt, _ := result["prompt"].(string) + if gotPrompt != "list files" { + t.Fatalf("expected JSON field prompt to preserve the original request, got %#v", result) + } + gotCWD, _ := result["cwd"].(string) + if strings.TrimSpace(gotCWD) == "" { + t.Fatalf("expected JSON field cwd to preserve the execution cwd, got %#v", result) + } } func TestSafetyHardRejectDispatch(t *testing.T) {