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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
30 changes: 22 additions & 8 deletions internal/cli/intent.go
Original file line number Diff line number Diff line change
Expand Up @@ -336,15 +336,15 @@ 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
}

// Handle terminal approaches.
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)
}
Expand Down Expand Up @@ -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."))
Expand All @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 ""
Expand All @@ -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 == "" {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Tighten envelope detection to avoid misclassifying JSON stdin

The new envelope check now treats any --from-intent JSON payload containing just prompt or cwd as an intent envelope, which is a regression from the previous behavior that required intent_response, exit_code, or stdout. In practice, if a user manually uses --from-intent with arbitrary JSON (or pipes tool output that includes a prompt field), we will summarize only a few fields and drop the rest of the payload from model context, changing command generation behavior unexpectedly.

Useful? React with 👍 / 👎.

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)
Expand Down
37 changes: 37 additions & 0 deletions internal/cli/intent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)
}
Expand All @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions internal/cli/smoke_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading