@robhowley/pi-structured-return 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/extensions/structured-return/examples/.pi/machine-readable.json +18 -0
  4. package/extensions/structured-return/examples/.pi/parsers/foo-cli.ts +25 -0
  5. package/extensions/structured-return/src/.tmp/vitest-report.xml +55 -0
  6. package/extensions/structured-return/src/config/project-config.ts +10 -0
  7. package/extensions/structured-return/src/config/registry.ts +89 -0
  8. package/extensions/structured-return/src/index.test.ts +98 -0
  9. package/extensions/structured-return/src/index.ts +161 -0
  10. package/extensions/structured-return/src/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  11. package/extensions/structured-return/src/parsers/eslint-json.test.ts +60 -0
  12. package/extensions/structured-return/src/parsers/eslint-json.ts +33 -0
  13. package/extensions/structured-return/src/parsers/junit-xml.test.ts +169 -0
  14. package/extensions/structured-return/src/parsers/junit-xml.ts +97 -0
  15. package/extensions/structured-return/src/parsers/minitest-text.test.ts +110 -0
  16. package/extensions/structured-return/src/parsers/minitest-text.ts +99 -0
  17. package/extensions/structured-return/src/parsers/pytest-json-report.test.ts +76 -0
  18. package/extensions/structured-return/src/parsers/pytest-json-report.ts +41 -0
  19. package/extensions/structured-return/src/parsers/rspec-json.test.ts +126 -0
  20. package/extensions/structured-return/src/parsers/rspec-json.ts +76 -0
  21. package/extensions/structured-return/src/parsers/ruff-json.test.ts +57 -0
  22. package/extensions/structured-return/src/parsers/ruff-json.ts +37 -0
  23. package/extensions/structured-return/src/parsers/tail-fallback.test.ts +41 -0
  24. package/extensions/structured-return/src/parsers/tail-fallback.ts +20 -0
  25. package/extensions/structured-return/src/parsers/vitest-json.test.ts +118 -0
  26. package/extensions/structured-return/src/parsers/vitest-json.ts +60 -0
  27. package/extensions/structured-return/src/storage/log-store.ts +23 -0
  28. package/extensions/structured-return/src/types.ts +55 -0
  29. package/extensions/structured-return/src/ui/widget.ts +3 -0
  30. package/extensions/structured-return/tsconfig.json +11 -0
  31. package/extensions/structured-return/vitest.config.ts +9 -0
  32. package/package.json +62 -0
  33. package/skills/structured-return/SKILL.md +67 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rob Howley
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,131 @@
1
+ # pi-structured-return
2
+
3
+ Structured command execution for pi agents: compact results for the model, full logs for humans.
4
+
5
+ Cross-platform Pi package that combines:
6
+ - a `structured-return` skill for choosing compact / machine-readable command forms
7
+ - a `structured-return` extension that captures output, stores artifacts, applies parsers, and falls back to tail + log path
8
+
9
+ ## Token reduction
10
+
11
+ Tool output is designed for humans: source diffs, line annotations, timing breakdowns, absolute paths repeated on every line. Useful on a terminal. Expensive in a model context, especially on failure when output is most verbose and the model needs to act fast.
12
+
13
+ Test runners: 3 tests, 1 passing, 1 assertion failure, 1 unexpected error.
14
+ Linters: 1 unused variable warning in a single file.
15
+
16
+ | Parser | Raw (tokens) | Structured (tokens) | Reduction | Notes |
17
+ |---|---|---|---|---|
18
+ | `pytest-json-report` | 446 | 59 | **87%** | verbose output with source snippets and summary footer |
19
+ | `vitest-json` | 348 | 75 | **78%** | source diff with inline arrows and ANSI color codes per failure |
20
+ | `rspec-json` | 212 | 55 | **74%** | default output with backtrace |
21
+ | `junit-xml` | 263 | 81 | **69%** | gradle console output with build lifecycle noise |
22
+ | `minitest-text` | 168 | 59 | **65%** | default output with backtrace |
23
+ | `ruff-json` | 107 | 52 | **51%** | source context + help text per error |
24
+ | `eslint-json` | 64 | 59 | **8%** | already compact formatter |
25
+
26
+ Tokens counted with `cl100k_base` (tiktoken). Linter output is more compact than test runner output to begin with, so the baseline reduction is lower. The numbers above are measured against a single file with a single error — a conservative lower bound. Both ruff and eslint repeat absolute file paths per error in their raw output, so reduction grows as violations spread across more files.
27
+
28
+ ## Built-in parsers
29
+ - `pytest-json-report`
30
+ - `ruff-json` (`ruff check` only — `ruff format` has no json support)
31
+ - `eslint-json`
32
+ - `vitest-json`
33
+ - `rspec-json`
34
+ - `minitest-text` (parses default minitest output — no flags or reporters needed)
35
+ - `junit-xml` (JUnit XML — covers pytest `--junitxml`, Gradle, Maven, Jest with `jest-junit`, Go with `go-junit-report`, and any other tool that emits the JUnit XML schema)
36
+
37
+ ## Agentic loops
38
+
39
+ The token table above measures a single run. In an agentic loop the cost compounds — every tool result accumulates in context for the life of the task.
40
+
41
+ This applies to any loop: fixing a failing test suite, implementing a feature end-to-end, working through a migration, performance tuning execution times. The agent runs a command, reads the result, makes a change, runs it again. Each iteration adds another tool result to the window. With a noisy CLI that means paying for the same verbose boilerplate on every pass, and the agent has to hold all of it to reason about what changed.
42
+
43
+ A parser converts each result to a one- or two-line signal. Over 15 iterations, the difference isn't 80 tokens vs 15 tokens — it's 1,200 tokens vs 225, on a single command, for a single task.
44
+
45
+ ## Project-local extension
46
+
47
+ Built-in parsers cover common tools. For everything else — internal CLIs, custom test runners, proprietary lint tools — add a `.pi/structured-return.json` to your project root.
48
+
49
+ **Why:** keeps token costs low for tools the built-ins don't know about, without forking the package.
50
+
51
+ **Two options:**
52
+
53
+ ### 1. Re-use a built-in parser
54
+
55
+ Route a project-specific command to an existing parser. Use this when your tool's output already matches a supported format (e.g. a test runner that emits JUnit XML).
56
+
57
+ ```json
58
+ // .pi/structured-return.json
59
+ {
60
+ "parsers": [
61
+ {
62
+ "id": "acme-tests",
63
+ "match": { "argvIncludes": ["acme", "test"] },
64
+ "parseAs": "junit-xml"
65
+ }
66
+ ]
67
+ }
68
+ ```
69
+
70
+ ### 2. Write a custom parser
71
+
72
+ Point to a local `.ts` file for tools with unique output formats.
73
+
74
+ ```json
75
+ // .pi/structured-return.json
76
+ {
77
+ "parsers": [
78
+ {
79
+ "id": "foo-json",
80
+ "match": { "argvIncludes": ["foo-cli", "check"] },
81
+ "module": "parsers/foo-cli.js"
82
+ }
83
+ ]
84
+ }
85
+ ```
86
+
87
+ ```ts
88
+ // .pi/parsers/foo-cli.ts
89
+ import fs from "node:fs";
90
+ import type { RunContext } from "@robhowley/pi-structured-return/types";
91
+
92
+ export default {
93
+ id: "foo-json",
94
+ async parse(ctx: RunContext) {
95
+ const data = JSON.parse(fs.readFileSync(ctx.stdoutPath, "utf8"));
96
+ return {
97
+ tool: "foo-cli",
98
+ status: data.ok ? "pass" : "fail",
99
+ summary: data.ok ? "passed" : `${data.errors.length} errors`,
100
+ failures: data.errors.map((e, i) => ({ id: e.id ?? `error-${i}`, file: e.file, line: e.line, message: e.message })),
101
+ logPath: ctx.logPath,
102
+ };
103
+ },
104
+ };
105
+ ```
106
+
107
+ The parser receives a `RunContext` (command, argv, cwd, stdout/stderr paths, artifact paths, log path) and returns a `ParsedResult`. Match rules support `argvIncludes` (array of required tokens) or `regex` (tested against the full argv string).
108
+
109
+ ## Slash commands
110
+ - `/sr-parsers` — list all registered parsers (built-in and project-local) with their match rules and targets
111
+
112
+ ## Structured result schema
113
+
114
+ Every parser returns the same shape. The model always knows where to look.
115
+
116
+ | Field | Type | Description |
117
+ |---|---|---|
118
+ | `tool` | `string` | Name of the tool that ran (`eslint`, `pytest`, etc.) |
119
+ | `exitCode` | `number` | Raw process exit code |
120
+ | `status` | `pass \| fail \| error` | Normalized outcome |
121
+ | `summary` | `string` | One-line human+model readable result (`3 failed, 12 passed`) |
122
+ | `cwd` | `string` | Working directory — anchor for resolving relative paths in failures |
123
+ | `failures` | `{ id, file?, line?, message?, rule? }[]` | Per-failure details with relative file paths |
124
+ | `artifact` | `string?` | Path to the saved report file, if one was written |
125
+ | `logPath` | `string` | Path to full stdout+stderr log |
126
+ | `rawTail` | `string?` | Last 200 lines of log, included on fallback when no parser matched |
127
+
128
+ ## Design
129
+
130
+ `structured_return` is a separate tool, not a wrapper around `bash`. Intercepting `bash` to silently rewrite commands would override a primitive the model and platform both rely on. Pi's philosophy is to extend rather than obfuscate: features are built on top of the platform, not hidden inside it. A dedicated tool honors that. It adds to the available surface, keeps `bash` honest, and leaves the choice explicit. The skill guides the model toward it; nothing is hijacked to get there.
131
+
@@ -0,0 +1,18 @@
1
+ {
2
+ "parsers": [
3
+ {
4
+ "id": "acme-junit",
5
+ "match": {
6
+ "argvIncludes": ["acme", "test"]
7
+ },
8
+ "parseAs": "junit-xml"
9
+ },
10
+ {
11
+ "id": "foo-json",
12
+ "match": {
13
+ "argvIncludes": ["foo-cli", "check"]
14
+ },
15
+ "module": "parsers/foo-cli.js"
16
+ }
17
+ ]
18
+ }
@@ -0,0 +1,25 @@
1
+ import fs from "node:fs";
2
+ import type { RunContext } from "../../../src/types.js";
3
+
4
+ type FooError = { id?: string; file?: string; line?: number; message?: string };
5
+ type FooResult = { ok: boolean; errors?: FooError[] };
6
+
7
+ export default {
8
+ id: "foo-json",
9
+ async parse(ctx: RunContext) {
10
+ const stdout = fs.readFileSync(ctx.stdoutPath, "utf8");
11
+ const data = JSON.parse(stdout) as FooResult;
12
+ return {
13
+ tool: "foo-cli",
14
+ status: data.ok ? "pass" : "fail",
15
+ summary: data.ok ? "foo-cli passed" : `${data.errors?.length ?? 0} foo-cli errors`,
16
+ failures: (data.errors ?? []).map((e: FooError, i: number) => ({
17
+ id: e.id ?? `error-${i + 1}`,
18
+ file: e.file,
19
+ line: e.line,
20
+ message: e.message,
21
+ })),
22
+ logPath: ctx.logPath,
23
+ };
24
+ },
25
+ };
@@ -0,0 +1,55 @@
1
+ <?xml version="1.0" encoding="UTF-8" ?>
2
+ <testsuites name="vitest tests" tests="21" failures="0" errors="0" time="0.014556541">
3
+ <testsuite name="index.test.ts" timestamp="2026-03-16T17:34:52.759Z" hostname="Roberts-MBP" tests="9" failures="0" errors="0" skipped="0" time="0.002309">
4
+ <testcase classname="index.test.ts" name="stripCdPrefix &gt; strips cd /path &amp;&amp; prefix" time="0.000854833">
5
+ </testcase>
6
+ <testcase classname="index.test.ts" name="stripCdPrefix &gt; leaves commands without cd unchanged" time="0.000108208">
7
+ </testcase>
8
+ <testcase classname="index.test.ts" name="stripCdPrefix &gt; handles paths with no trailing space variations" time="0.000063708">
9
+ </testcase>
10
+ <testcase classname="index.test.ts" name="formatResult &gt; includes cwd when set" time="0.000125458">
11
+ </testcase>
12
+ <testcase classname="index.test.ts" name="formatResult &gt; omits cwd line when not set" time="0.000084875">
13
+ </testcase>
14
+ <testcase classname="index.test.ts" name="formatResult &gt; renders relative paths in failure lines" time="0.000090792">
15
+ </testcase>
16
+ <testcase classname="index.test.ts" name="finalizeResult &gt; status error with exit code 0 flips to pass" time="0.000076667">
17
+ </testcase>
18
+ <testcase classname="index.test.ts" name="finalizeResult &gt; status error with non-zero exit code stays error" time="0.000056917">
19
+ </testcase>
20
+ <testcase classname="index.test.ts" name="finalizeResult &gt; attaches cwd to result" time="0.000058875">
21
+ </testcase>
22
+ </testsuite>
23
+ <testsuite name="parsers/eslint-json.test.ts" timestamp="2026-03-16T17:34:52.759Z" hostname="Roberts-MBP" tests="3" failures="0" errors="0" skipped="0" time="0.003122">
24
+ <testcase classname="parsers/eslint-json.test.ts" name="eslint-json parser &gt; multiple errors across multiple files → correct relative paths, correct failure count, status fail" time="0.001832041">
25
+ </testcase>
26
+ <testcase classname="parsers/eslint-json.test.ts" name="eslint-json parser &gt; no errors → empty failures, status pass" time="0.000366459">
27
+ </testcase>
28
+ <testcase classname="parsers/eslint-json.test.ts" name="eslint-json parser &gt; empty stdout → no crash, status pass" time="0.000289">
29
+ </testcase>
30
+ </testsuite>
31
+ <testsuite name="parsers/pytest-json-report.test.ts" timestamp="2026-03-16T17:34:52.760Z" hostname="Roberts-MBP" tests="4" failures="0" errors="0" skipped="0" time="0.003528333">
32
+ <testcase classname="parsers/pytest-json-report.test.ts" name="pytest-json-report parser &gt; mix of passed and failed tests → correct counts, status fail" time="0.001658708">
33
+ </testcase>
34
+ <testcase classname="parsers/pytest-json-report.test.ts" name="pytest-json-report parser &gt; all passing → status pass, summary reflects passed count" time="0.00033575">
35
+ </testcase>
36
+ <testcase classname="parsers/pytest-json-report.test.ts" name="pytest-json-report parser &gt; failed test with longrepr → first line surfaced as message" time="0.000256959">
37
+ </testcase>
38
+ <testcase classname="parsers/pytest-json-report.test.ts" name="pytest-json-report parser &gt; missing artifact file → throws" time="0.000615667">
39
+ </testcase>
40
+ </testsuite>
41
+ <testsuite name="parsers/ruff-json.test.ts" timestamp="2026-03-16T17:34:52.760Z" hostname="Roberts-MBP" tests="3" failures="0" errors="0" skipped="0" time="0.003317375">
42
+ <testcase classname="parsers/ruff-json.test.ts" name="ruff-json parser &gt; multiple errors across multiple files → correct relative paths, rule code mapped to rule, status fail" time="0.00188625">
43
+ </testcase>
44
+ <testcase classname="parsers/ruff-json.test.ts" name="ruff-json parser &gt; no errors → empty failures, status pass" time="0.000399625">
45
+ </testcase>
46
+ <testcase classname="parsers/ruff-json.test.ts" name="ruff-json parser &gt; empty stdout → no crash, status pass" time="0.00028175">
47
+ </testcase>
48
+ </testsuite>
49
+ <testsuite name="parsers/tail-fallback.test.ts" timestamp="2026-03-16T17:34:52.760Z" hostname="Roberts-MBP" tests="2" failures="0" errors="0" skipped="0" time="0.002279833">
50
+ <testcase classname="parsers/tail-fallback.test.ts" name="tail-fallback parser &gt; any command → status error, summary contains log path" time="0.001251">
51
+ </testcase>
52
+ <testcase classname="parsers/tail-fallback.test.ts" name="tail-fallback parser &gt; long stdout → tail is bounded to last 200 lines" time="0.000405417">
53
+ </testcase>
54
+ </testsuite>
55
+ </testsuites>
@@ -0,0 +1,10 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import type { ParserConfigFile, ParserRegistration } from "../types";
4
+
5
+ export function loadProjectConfig(cwd: string): ParserRegistration[] {
6
+ const configPath = path.join(cwd, ".pi", "structured-return.json");
7
+ if (!fs.existsSync(configPath)) return [];
8
+ const data = JSON.parse(fs.readFileSync(configPath, "utf8")) as ParserConfigFile;
9
+ return Array.isArray(data.parsers) ? data.parsers : [];
10
+ }
@@ -0,0 +1,89 @@
1
+ import path from "node:path";
2
+ import type { ParserModule, ParserRegistration } from "../types";
3
+ import pytestJsonReport from "../parsers/pytest-json-report";
4
+ import ruffJson from "../parsers/ruff-json";
5
+ import eslintJson from "../parsers/eslint-json";
6
+ import vitestJson from "../parsers/vitest-json";
7
+ import rspecJson from "../parsers/rspec-json";
8
+ import minitestText from "../parsers/minitest-text";
9
+ import junitXml from "../parsers/junit-xml";
10
+ import tailFallback from "../parsers/tail-fallback";
11
+
12
+ const builtIns: Record<string, ParserModule> = {
13
+ "pytest-json-report": pytestJsonReport,
14
+ "ruff-json": ruffJson,
15
+ "eslint-json": eslintJson,
16
+ "vitest-json": vitestJson,
17
+ "rspec-json": rspecJson,
18
+ "minitest-text": minitestText,
19
+ "junit-xml": junitXml,
20
+ "tail-fallback": tailFallback,
21
+ };
22
+
23
+ /** Built-in detection patterns — fire when no explicit parseAs or project registration matched. */
24
+ const AUTO_DETECT: Array<{ parserId: string; detect: (argv: string[]) => boolean }> = [
25
+ {
26
+ parserId: "eslint-json",
27
+ detect: (argv) => argv.includes("eslint") && argv.includes("-f") && argv.includes("json"),
28
+ },
29
+ {
30
+ parserId: "ruff-json",
31
+ detect: (argv) =>
32
+ argv.includes("ruff") &&
33
+ argv.some((a) => a === "--output-format=json" || a === "json") &&
34
+ (argv.includes("--output-format=json") || argv.includes("--output-format")),
35
+ },
36
+ {
37
+ parserId: "pytest-json-report",
38
+ detect: (argv) => argv.includes("pytest") && argv.includes("--json-report"),
39
+ },
40
+ {
41
+ parserId: "vitest-json",
42
+ detect: (argv) => argv.includes("vitest") && argv.includes("--reporter=json"),
43
+ },
44
+ {
45
+ parserId: "rspec-json",
46
+ detect: (argv) =>
47
+ argv.includes("rspec") &&
48
+ (argv.includes("--format=json") || (argv.includes("--format") && argv.includes("json"))),
49
+ },
50
+ {
51
+ parserId: "junit-xml",
52
+ detect: (argv) =>
53
+ argv.some((a) => a.startsWith("--junitxml") || a.startsWith("--junit-xml") || a === "--format=junit"),
54
+ },
55
+ ];
56
+
57
+ export async function resolveParser(opts: {
58
+ cwd: string;
59
+ parseAs?: string;
60
+ argv: string[];
61
+ registrations: ParserRegistration[];
62
+ }): Promise<ParserModule> {
63
+ if (opts.parseAs && builtIns[opts.parseAs]) return builtIns[opts.parseAs];
64
+
65
+ const matched = opts.registrations.find((reg) => matches(reg, opts.argv));
66
+ if (matched?.parseAs && builtIns[matched.parseAs]) return builtIns[matched.parseAs];
67
+ if (matched?.module) {
68
+ const modulePath = path.resolve(opts.cwd, ".pi", matched.module);
69
+ const loaded = await import(modulePath);
70
+ return loaded.default ?? loaded;
71
+ }
72
+
73
+ const autoMatched = AUTO_DETECT.find((d) => d.detect(opts.argv));
74
+ if (autoMatched) return builtIns[autoMatched.parserId];
75
+
76
+ return builtIns["tail-fallback"];
77
+ }
78
+
79
+ function matches(reg: ParserRegistration, argv: string[]): boolean {
80
+ if (reg.match?.argvIncludes?.length) {
81
+ const ok = reg.match.argvIncludes.every((token) => argv.includes(token));
82
+ if (!ok) return false;
83
+ }
84
+ if (reg.match?.regex) {
85
+ const text = argv.join(" ");
86
+ if (!new RegExp(reg.match.regex).test(text)) return false;
87
+ }
88
+ return Boolean(reg.match?.argvIncludes?.length || reg.match?.regex);
89
+ }
@@ -0,0 +1,98 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { stripCdPrefix, formatResult, finalizeResult } from "./index";
3
+
4
+ describe("stripCdPrefix", () => {
5
+ it("strips cd /path && prefix", () => {
6
+ expect(stripCdPrefix("cd /some/path && npx eslint . -f json")).toBe("npx eslint . -f json");
7
+ });
8
+
9
+ it("leaves commands without cd unchanged", () => {
10
+ expect(stripCdPrefix("npx eslint . -f json")).toBe("npx eslint . -f json");
11
+ });
12
+
13
+ it("handles paths with no trailing space variations", () => {
14
+ expect(stripCdPrefix("cd /a/b/c &&npx eslint .")).toBe("npx eslint .");
15
+ });
16
+ });
17
+
18
+ describe("formatResult", () => {
19
+ it("includes cwd when set", () => {
20
+ const result = formatResult({
21
+ tool: "eslint",
22
+ exitCode: 1,
23
+ status: "fail",
24
+ summary: "1 lint errors",
25
+ cwd: "/project",
26
+ failures: [],
27
+ });
28
+ expect(result).toContain("cwd: /project");
29
+ });
30
+
31
+ it("omits cwd line when not set", () => {
32
+ const result = formatResult({
33
+ tool: "eslint",
34
+ exitCode: 0,
35
+ status: "pass",
36
+ summary: "no lint errors",
37
+ });
38
+ expect(result).not.toContain("cwd:");
39
+ });
40
+
41
+ it("renders relative paths in failure lines", () => {
42
+ const result = formatResult({
43
+ tool: "eslint",
44
+ exitCode: 1,
45
+ status: "fail",
46
+ summary: "1 lint errors",
47
+ cwd: "/project",
48
+ failures: [
49
+ { id: "src/foo.ts:10:rule", file: "src/foo.ts", line: 10, message: "Unexpected any.", rule: "no-explicit-any" },
50
+ ],
51
+ });
52
+ expect(result).toContain("src/foo.ts:10");
53
+ expect(result).not.toContain("/project/src/foo.ts");
54
+ });
55
+ });
56
+
57
+ describe("finalizeResult", () => {
58
+ it("status error with exit code 0 flips to pass", () => {
59
+ const result = finalizeResult(
60
+ {
61
+ tool: "unknown",
62
+ status: "error",
63
+ summary: "no parser matched; returning tail + log path",
64
+ logPath: "/log",
65
+ },
66
+ 0,
67
+ "/log",
68
+ "/project"
69
+ );
70
+ expect(result.status).toBe("pass");
71
+ expect(result.summary).toBe("command completed; no parser matched");
72
+ });
73
+
74
+ it("status error with non-zero exit code stays error", () => {
75
+ const result = finalizeResult(
76
+ {
77
+ tool: "unknown",
78
+ status: "error",
79
+ summary: "no parser matched; returning tail + log path",
80
+ logPath: "/log",
81
+ },
82
+ 1,
83
+ "/log",
84
+ "/project"
85
+ );
86
+ expect(result.status).toBe("error");
87
+ });
88
+
89
+ it("attaches cwd to result", () => {
90
+ const result = finalizeResult(
91
+ { tool: "eslint", status: "pass", summary: "no lint errors", logPath: "/log" },
92
+ 0,
93
+ "/log",
94
+ "/project"
95
+ );
96
+ expect(result.cwd).toBe("/project");
97
+ });
98
+ });
@@ -0,0 +1,161 @@
1
+ import { spawn } from "node:child_process";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { Type } from "@sinclair/typebox";
5
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
6
+ import { Text } from "@mariozechner/pi-tui";
7
+ import type { ObservedRunArgs, ParsedResult, RunContext } from "./types";
8
+ import { ensureRunDir, writeRunArtifacts } from "./storage/log-store";
9
+ import { loadProjectConfig } from "./config/project-config";
10
+ import { resolveParser } from "./config/registry";
11
+
12
+ const BUILT_IN_PARSER_IDS = ["pytest-json-report", "ruff-json", "eslint-json", "vitest-json", "tail-fallback"];
13
+
14
+ export default function structuredReturn(pi: ExtensionAPI) {
15
+ pi.registerCommand("sr-parsers", {
16
+ description: "List all structured-return parsers: built-ins and project-local registrations",
17
+ handler: async (_args, ctx) => {
18
+ const lines: string[] = ["structured-return parsers", ""];
19
+
20
+ lines.push("built-in:");
21
+ for (const id of BUILT_IN_PARSER_IDS) {
22
+ lines.push(` ${id}`);
23
+ }
24
+
25
+ const projectRegistrations = loadProjectConfig(ctx.cwd);
26
+ lines.push("");
27
+ lines.push("project-local (.pi/structured-return.json):");
28
+ if (projectRegistrations.length === 0) {
29
+ lines.push(" (none)");
30
+ } else {
31
+ for (const reg of projectRegistrations) {
32
+ const via = reg.parseAs ? `→ ${reg.parseAs}` : reg.module ? `→ module: ${reg.module}` : "";
33
+ const match = reg.match?.argvIncludes
34
+ ? `argv includes [${reg.match.argvIncludes.join(", ")}]`
35
+ : reg.match?.regex
36
+ ? `regex: ${reg.match.regex}`
37
+ : "(no match rule)";
38
+ lines.push(` ${reg.id} ${match} ${via}`);
39
+ }
40
+ }
41
+
42
+ ctx.ui.notify(lines.join("\n"), "info");
43
+ },
44
+ });
45
+
46
+ pi.on("before_agent_start", async (event) => {
47
+ return {
48
+ systemPrompt:
49
+ event.systemPrompt +
50
+ "\n\nWhen running lint, test, build, or other shell commands, load and follow the `structured-return` skill before choosing how to invoke them.",
51
+ };
52
+ });
53
+
54
+ pi.registerTool({
55
+ name: "structured_return",
56
+ label: "Structured Return",
57
+ description:
58
+ "Run a command, store full logs, apply an explicit or registered parser when available, and fall back to tail + log path.",
59
+ parameters: Type.Object({
60
+ command: Type.String(),
61
+ cwd: Type.Optional(Type.String()),
62
+ parseAs: Type.Optional(Type.String()),
63
+ artifactPaths: Type.Optional(Type.Array(Type.String())),
64
+ }),
65
+ async execute(
66
+ _toolCallId: string,
67
+ args: ObservedRunArgs,
68
+ _signal: AbortSignal | undefined,
69
+ _onUpdate: unknown,
70
+ ctx: ExtensionContext
71
+ ) {
72
+ const cwd = args.cwd ?? ctx.cwd ?? process.cwd();
73
+ const runDir = ensureRunDir(cwd);
74
+ const runId = randomUUID();
75
+ const argv = shellSplit(args.command);
76
+ const { stdout, stderr, exitCode } = await runCommand(args.command, cwd);
77
+ const logs = writeRunArtifacts(runDir, runId, stdout, stderr);
78
+ const artifactPaths = (args.artifactPaths ?? []).map((p) => (path.isAbsolute(p) ? p : path.join(cwd, p)));
79
+ const runCtx: RunContext = {
80
+ command: args.command,
81
+ argv,
82
+ cwd,
83
+ artifactPaths,
84
+ ...logs,
85
+ };
86
+ const registrations = loadProjectConfig(cwd);
87
+ const parser = await resolveParser({ cwd, parseAs: args.parseAs, argv, registrations });
88
+ const parsed = await parser.parse(runCtx);
89
+ const result = finalizeResult(parsed, exitCode, logs.logPath, cwd);
90
+
91
+ return {
92
+ content: [{ type: "text" as const, text: `${stripCdPrefix(args.command)} → ${formatResult(result)}` }],
93
+ details: { exitCode, logPath: logs.logPath, parser: parser.id },
94
+ };
95
+ },
96
+ renderCall(args: ObservedRunArgs) {
97
+ return new Text(`structured_return ${args.command}`, 0, 0);
98
+ },
99
+ renderResult(result: { content?: Array<{ type: string; text?: string }> }) {
100
+ const text = result?.content?.[0]?.text ?? "structured_return complete";
101
+ return new Text(text, 0, 0);
102
+ },
103
+ });
104
+ }
105
+
106
+ export function formatResult(result: ParsedResult): string {
107
+ const lines: string[] = [];
108
+ if (result.cwd) lines.push(`cwd: ${result.cwd}`);
109
+ lines.push(result.summary);
110
+ for (const f of result.failures ?? []) {
111
+ const location = [f.file, f.line].filter(Boolean).join(":");
112
+ const rule = f.rule ? ` [${f.rule}]` : "";
113
+ lines.push(` ${location} ${f.message ?? ""}${rule}`);
114
+ }
115
+ return lines.join("\n");
116
+ }
117
+
118
+ export function stripCdPrefix(command: string): string {
119
+ return command.replace(/^cd\s+\S+\s*&&\s*/, "");
120
+ }
121
+
122
+ function shellSplit(command: string): string[] {
123
+ return command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g)?.map((s) => s.replace(/^['"]|['"]$/g, "")) ?? [];
124
+ }
125
+
126
+ function runCommand(command: string, cwd: string): Promise<{ stdout: string; stderr: string; exitCode: number }> {
127
+ return new Promise((resolve) => {
128
+ const proc = spawn(command, { cwd, shell: true, stdio: ["ignore", "pipe", "pipe"] });
129
+ let stdout = "";
130
+ let stderr = "";
131
+ proc.stdout.on("data", (d) => {
132
+ stdout += d.toString();
133
+ });
134
+ proc.stderr.on("data", (d) => {
135
+ stderr += d.toString();
136
+ });
137
+ proc.on("close", (code) => resolve({ stdout, stderr, exitCode: code ?? 1 }));
138
+ });
139
+ }
140
+
141
+ export function finalizeResult(
142
+ result: Omit<ParsedResult, "exitCode">,
143
+ exitCode: number,
144
+ logPath: string,
145
+ cwd: string
146
+ ): ParsedResult {
147
+ if (result.status === "error" && exitCode === 0) {
148
+ return {
149
+ ...result,
150
+ exitCode,
151
+ cwd,
152
+ status: "pass",
153
+ summary:
154
+ result.summary === "no parser matched; returning tail + log path"
155
+ ? "command completed; no parser matched"
156
+ : result.summary,
157
+ logPath,
158
+ };
159
+ }
160
+ return { ...result, exitCode, cwd, logPath };
161
+ }
@@ -0,0 +1 @@
1
+ {"version":"4.1.0","results":[[":parsers/pytest-json-report.test.ts",{"duration":8.454000000000008,"failed":false}],[":parsers/ruff-json.test.ts",{"duration":2.925207999999998,"failed":false}],[":index.test.ts",{"duration":2.2829590000000053,"failed":false}],[":parsers/eslint-json.test.ts",{"duration":3.558458999999999,"failed":false}],[":parsers/tail-fallback.test.ts",{"duration":3.911292000000003,"failed":false}],[":parsers/vitest-json.test.ts",{"duration":7.248625000000004,"failed":false}],[":parsers/rspec-json.test.ts",{"duration":7.745584000000008,"failed":false}],[":parsers/junit-xml.test.ts",{"duration":6.386042000000003,"failed":false}],[":parsers/minitest-text.test.ts",{"duration":6.155249999999995,"failed":false}]]}