@juicesharp/rpiv-workflow 1.14.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.
- package/LICENSE +21 -0
- package/README.md +449 -0
- package/api.ts +557 -0
- package/audit.ts +217 -0
- package/built-ins.ts +65 -0
- package/command.ts +137 -0
- package/docs/cover.png +0 -0
- package/docs/cover.svg +120 -0
- package/docs/workflow-authoring.md +629 -0
- package/docs/workflow-basics.md +122 -0
- package/docs-protocol.ts +106 -0
- package/fanout.ts +96 -0
- package/host.ts +97 -0
- package/index.ts +230 -0
- package/internal-utils.ts +69 -0
- package/internal.ts +27 -0
- package/layers.ts +33 -0
- package/lifecycle.ts +274 -0
- package/load/cache.test.ts +82 -0
- package/load/cache.ts +40 -0
- package/load/index.ts +159 -0
- package/load/merge.ts +136 -0
- package/load/normalize.ts +73 -0
- package/load/paths.ts +32 -0
- package/load/resolve-default.ts +43 -0
- package/load/shape-guards.test.ts +74 -0
- package/load/shape-guards.ts +42 -0
- package/messages.ts +185 -0
- package/outcomes/collectors/directory-path.test.ts +64 -0
- package/outcomes/collectors/directory-path.ts +40 -0
- package/outcomes/collectors/index.ts +21 -0
- package/outcomes/collectors/tool-call.test.ts +110 -0
- package/outcomes/collectors/tool-call.ts +63 -0
- package/outcomes/collectors/transcript-path.test.ts +70 -0
- package/outcomes/collectors/transcript-path.ts +53 -0
- package/outcomes/collectors/union.test.ts +59 -0
- package/outcomes/collectors/union.ts +55 -0
- package/outcomes/collectors/url.test.ts +67 -0
- package/outcomes/collectors/url.ts +45 -0
- package/outcomes/collectors/workspace-diff.test.ts +107 -0
- package/outcomes/collectors/workspace-diff.ts +123 -0
- package/outcomes/git-commit.test.ts +194 -0
- package/outcomes/git-commit.ts +192 -0
- package/outcomes/index.ts +22 -0
- package/outcomes/parsers/index.ts +11 -0
- package/outcomes/parsers/json-body.test.ts +80 -0
- package/outcomes/parsers/json-body.ts +50 -0
- package/outcomes/side-effect.ts +26 -0
- package/output-spec.ts +170 -0
- package/output.ts +98 -0
- package/package.json +83 -0
- package/preview.ts +120 -0
- package/routing.ts +79 -0
- package/runner/chain-advance.ts +185 -0
- package/runner/index.ts +7 -0
- package/runner/runner.ts +356 -0
- package/runner/script-stage.ts +240 -0
- package/runner/stage-lifecycle.ts +447 -0
- package/sessions/extraction.ts +297 -0
- package/sessions/index.ts +7 -0
- package/sessions/sessions.ts +269 -0
- package/sessions/spawn.ts +135 -0
- package/state/index.ts +27 -0
- package/state/paths.ts +46 -0
- package/state/reads.ts +190 -0
- package/state/state.ts +115 -0
- package/state/writes.ts +58 -0
- package/transcript.ts +156 -0
- package/triggers.ts +27 -0
- package/typebox-adapter.ts +48 -0
- package/types.ts +237 -0
- package/validate-output.ts +120 -0
- package/validate-workflow.ts +491 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Directory-path collector — `transcriptPathCollector` wrapped with the
|
|
3
|
+
* common `<dir>/<filename>.<ext>` regex idiom so authors don't write
|
|
4
|
+
* the regex themselves.
|
|
5
|
+
*
|
|
6
|
+
* Use when the convention is "all outputs land under one folder"
|
|
7
|
+
* (`docs/adr/`, `outputs/`, `.scratch/runs/`). Universal — every
|
|
8
|
+
* project has directories.
|
|
9
|
+
*
|
|
10
|
+
* For more exotic shapes (per-run nesting, multiple acceptable
|
|
11
|
+
* directories, custom filename rules) drop down to
|
|
12
|
+
* `transcriptPathCollector({ pattern })` and supply the regex directly.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ArtifactCollector } from "../../output-spec.js";
|
|
16
|
+
import { transcriptPathCollector } from "./transcript-path.js";
|
|
17
|
+
|
|
18
|
+
export interface DirectoryPathCollectorOpts {
|
|
19
|
+
/** cwd-relative directory the agent's announced path must sit under (e.g. `"docs/adr"`). */
|
|
20
|
+
dir: string;
|
|
21
|
+
/**
|
|
22
|
+
* Optional file extension filter (no leading dot — `"md"`, `"json"`,
|
|
23
|
+
* etc.). Defaults to any common alphanumeric extension.
|
|
24
|
+
*/
|
|
25
|
+
ext?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function directoryPathCollector(opts: DirectoryPathCollectorOpts): ArtifactCollector {
|
|
29
|
+
if (typeof opts.dir !== "string" || opts.dir.length === 0) {
|
|
30
|
+
throw new Error("directoryPathCollector: `dir` is required and must be a non-empty string");
|
|
31
|
+
}
|
|
32
|
+
const escapedDir = escapeRegex(opts.dir);
|
|
33
|
+
const extPart = opts.ext ? escapeRegex(opts.ext) : "[a-zA-Z0-9]+";
|
|
34
|
+
const pattern = new RegExp(`${escapedDir}/[\\w.-]+\\.${extPart}`, "g");
|
|
35
|
+
return transcriptPathCollector({ pattern });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function escapeRegex(s: string): string {
|
|
39
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
40
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundled collectors — universal primitives + ergonomic wrappers +
|
|
3
|
+
* composition helpers. Re-exported through `outcomes/index.ts` and
|
|
4
|
+
* surfaced to authors via the package's top-level barrel.
|
|
5
|
+
*
|
|
6
|
+
* The framework ships ONLY host-agnostic collectors — no Pi tool-name
|
|
7
|
+
* defaults, no `.rpiv/artifacts/` defaults, no domain helpers
|
|
8
|
+
* (Linear/S3/Notion). Convention layers live in sibling packages
|
|
9
|
+
* (`rpiv-pi` ships `rpivArtifactCollector` / `rpivBucketCollector`).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export { type DirectoryPathCollectorOpts, directoryPathCollector } from "./directory-path.js";
|
|
13
|
+
export { type ToolCall, type ToolCallCollectorOpts, toolCallCollector } from "./tool-call.js";
|
|
14
|
+
export { type TranscriptPathCollectorOpts, transcriptPathCollector } from "./transcript-path.js";
|
|
15
|
+
export { unionCollectors } from "./union.js";
|
|
16
|
+
export { type UrlCollectorOpts, urlCollector } from "./url.js";
|
|
17
|
+
export {
|
|
18
|
+
type WorkspaceDiffCollectorOpts,
|
|
19
|
+
type WorkspaceDiffSnapshot,
|
|
20
|
+
workspaceDiffCollector,
|
|
21
|
+
} from "./workspace-diff.js";
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { fs } from "../../handle.js";
|
|
3
|
+
import type { BranchEntry } from "../../transcript.js";
|
|
4
|
+
import { toolCallCollector } from "./tool-call.js";
|
|
5
|
+
|
|
6
|
+
const asstWithTools = (parts: Array<{ name: string; input: Record<string, unknown> }>): BranchEntry => ({
|
|
7
|
+
type: "message",
|
|
8
|
+
message: {
|
|
9
|
+
role: "assistant",
|
|
10
|
+
content: parts.map((p) => ({ type: "tool_use", name: p.name, input: p.input })),
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const ctxOf = (branch: BranchEntry[]) => ({
|
|
15
|
+
cwd: "/tmp",
|
|
16
|
+
runId: "test",
|
|
17
|
+
stageIndex: 0,
|
|
18
|
+
state: {} as never,
|
|
19
|
+
branch,
|
|
20
|
+
branchOffset: undefined,
|
|
21
|
+
snapshot: undefined,
|
|
22
|
+
skill: "test",
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("toolCallCollector", () => {
|
|
26
|
+
it("throws when match or toArtifact are missing", () => {
|
|
27
|
+
// @ts-expect-error — intentional misuse
|
|
28
|
+
expect(() => toolCallCollector({})).toThrow(/required functions/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("emits one artifact per matching tool call in branch order", async () => {
|
|
32
|
+
const collector = toolCallCollector({
|
|
33
|
+
match: (tc) => tc.name === "write_file",
|
|
34
|
+
toArtifact: (tc) => ({ handle: fs(String(tc.input.path)), role: "written" }),
|
|
35
|
+
});
|
|
36
|
+
const ctx = ctxOf([
|
|
37
|
+
asstWithTools([
|
|
38
|
+
{ name: "write_file", input: { path: "a.ts" } },
|
|
39
|
+
{ name: "read_file", input: { path: "ignored.ts" } },
|
|
40
|
+
{ name: "write_file", input: { path: "b.ts" } },
|
|
41
|
+
]),
|
|
42
|
+
]);
|
|
43
|
+
const result = await collector.collect(ctx);
|
|
44
|
+
expect(result.kind).toBe("ok");
|
|
45
|
+
if (result.kind !== "ok") return;
|
|
46
|
+
expect(result.artifacts).toEqual([
|
|
47
|
+
{ handle: { kind: "fs", path: "a.ts" }, role: "written" },
|
|
48
|
+
{ handle: { kind: "fs", path: "b.ts" }, role: "written" },
|
|
49
|
+
]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("returns ok+empty when nothing matches (runner decides whether to fatal)", async () => {
|
|
53
|
+
const collector = toolCallCollector({
|
|
54
|
+
match: (tc) => tc.name === "write_file",
|
|
55
|
+
toArtifact: (tc) => ({ handle: fs(String(tc.input.path)) }),
|
|
56
|
+
});
|
|
57
|
+
const ctx = ctxOf([asstWithTools([{ name: "read_file", input: { path: "x.ts" } }])]);
|
|
58
|
+
const result = await collector.collect(ctx);
|
|
59
|
+
expect(result.kind).toBe("ok");
|
|
60
|
+
if (result.kind !== "ok") return;
|
|
61
|
+
expect(result.artifacts).toEqual([]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("toArtifact returning undefined skips the call", async () => {
|
|
65
|
+
const collector = toolCallCollector({
|
|
66
|
+
match: () => true,
|
|
67
|
+
toArtifact: (tc) => (tc.input.path ? { handle: fs(String(tc.input.path)) } : undefined),
|
|
68
|
+
});
|
|
69
|
+
const ctx = ctxOf([
|
|
70
|
+
asstWithTools([
|
|
71
|
+
{ name: "bash", input: { cmd: "ls" } }, // no path → skipped
|
|
72
|
+
{ name: "write_file", input: { path: "a.ts" } },
|
|
73
|
+
]),
|
|
74
|
+
]);
|
|
75
|
+
const result = await collector.collect(ctx);
|
|
76
|
+
expect(result.kind === "ok" && result.artifacts).toEqual([{ handle: { kind: "fs", path: "a.ts" } }]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("respects branchOffset", async () => {
|
|
80
|
+
const collector = toolCallCollector({
|
|
81
|
+
match: (tc) => tc.name === "write_file",
|
|
82
|
+
toArtifact: (tc) => ({ handle: fs(String(tc.input.path)) }),
|
|
83
|
+
});
|
|
84
|
+
const branch = [
|
|
85
|
+
asstWithTools([{ name: "write_file", input: { path: "prior.ts" } }]),
|
|
86
|
+
asstWithTools([{ name: "write_file", input: { path: "current.ts" } }]),
|
|
87
|
+
];
|
|
88
|
+
const ctx = { ...ctxOf(branch), branchOffset: 1 };
|
|
89
|
+
const result = await collector.collect(ctx);
|
|
90
|
+
expect(result.kind === "ok" && result.artifacts).toEqual([{ handle: { kind: "fs", path: "current.ts" } }]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("multi-tool match — one collector covers write_file + edit", async () => {
|
|
94
|
+
const collector = toolCallCollector({
|
|
95
|
+
match: (tc) => tc.name === "write_file" || tc.name === "edit",
|
|
96
|
+
toArtifact: (tc) => ({ handle: fs(String(tc.input.path ?? tc.input.target_file)) }),
|
|
97
|
+
});
|
|
98
|
+
const ctx = ctxOf([
|
|
99
|
+
asstWithTools([
|
|
100
|
+
{ name: "write_file", input: { path: "new.ts" } },
|
|
101
|
+
{ name: "edit", input: { target_file: "old.ts" } },
|
|
102
|
+
]),
|
|
103
|
+
]);
|
|
104
|
+
const result = await collector.collect(ctx);
|
|
105
|
+
expect(result.kind === "ok" && result.artifacts.map((a) => a.handle)).toEqual([
|
|
106
|
+
{ kind: "fs", path: "new.ts" },
|
|
107
|
+
{ kind: "fs", path: "old.ts" },
|
|
108
|
+
]);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool-call collector — observes assistant tool_use parts in the branch
|
|
3
|
+
* and turns each into an Artifact via the author-supplied mappers.
|
|
4
|
+
*
|
|
5
|
+
* Universal: knows nothing about specific tool names. Authors wire
|
|
6
|
+
* `match(tc)` to pick which calls are interesting (often
|
|
7
|
+
* `tc.name === "write_file"`) and `toArtifact(tc)` to extract the
|
|
8
|
+
* Artifact (most commonly an `fs` handle pulled from the tool input).
|
|
9
|
+
* Multiple matching calls produce multiple artifacts in branch order.
|
|
10
|
+
*
|
|
11
|
+
* Far more reliable than transcript-text scanning: tool-use blocks
|
|
12
|
+
* are the agent's actual recorded actions, not its narration of them.
|
|
13
|
+
*
|
|
14
|
+
* Returns `ok` with an empty list when no matching calls fire — the
|
|
15
|
+
* runner's `enforceCompletionContract` then halts for produces
|
|
16
|
+
* (the stage promised an output and didn't deliver) or passes through
|
|
17
|
+
* for side-effect (chain inherits prior). The collector itself doesn't
|
|
18
|
+
* second-guess that policy.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { Artifact } from "../../handle.js";
|
|
22
|
+
import type { ArtifactCollector } from "../../output-spec.js";
|
|
23
|
+
import { defineCollector } from "../../output-spec.js";
|
|
24
|
+
import { iterToolUses } from "../../transcript.js";
|
|
25
|
+
|
|
26
|
+
export interface ToolCall {
|
|
27
|
+
name: string;
|
|
28
|
+
input: Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ToolCallCollectorOpts {
|
|
32
|
+
/**
|
|
33
|
+
* Predicate over the tool call. Return true to consider this call,
|
|
34
|
+
* false to skip. No default — match semantics are entirely
|
|
35
|
+
* caller-defined (e.g. `(tc) => tc.name === "write_file"`).
|
|
36
|
+
*/
|
|
37
|
+
match(tc: ToolCall): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Map a matched tool call to an Artifact. Return undefined to skip
|
|
40
|
+
* (useful when the tool's input doesn't carry a path, or the path
|
|
41
|
+
* fails a sanity check). The returned Artifact's handle is what
|
|
42
|
+
* downstream stages see on `output.artifacts`.
|
|
43
|
+
*/
|
|
44
|
+
toArtifact(tc: ToolCall): Artifact | undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function toolCallCollector(opts: ToolCallCollectorOpts): ArtifactCollector {
|
|
48
|
+
if (typeof opts.match !== "function" || typeof opts.toArtifact !== "function") {
|
|
49
|
+
throw new Error("toolCallCollector: `match` and `toArtifact` are required functions");
|
|
50
|
+
}
|
|
51
|
+
const { match, toArtifact } = opts;
|
|
52
|
+
return defineCollector({
|
|
53
|
+
collect: (ctx) => {
|
|
54
|
+
const artifacts: Artifact[] = [];
|
|
55
|
+
for (const tc of iterToolUses(ctx.branch, ctx.branchOffset)) {
|
|
56
|
+
if (!match(tc)) continue;
|
|
57
|
+
const artifact = toArtifact(tc);
|
|
58
|
+
if (artifact) artifacts.push(artifact);
|
|
59
|
+
}
|
|
60
|
+
return { kind: "ok", artifacts };
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { BranchEntry } from "../../transcript.js";
|
|
3
|
+
import { transcriptPathCollector } from "./transcript-path.js";
|
|
4
|
+
|
|
5
|
+
const asst = (text: string): BranchEntry => ({
|
|
6
|
+
type: "message",
|
|
7
|
+
message: { role: "assistant", content: [{ type: "text", text }] },
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const ctxOf = (branch: BranchEntry[]) => ({
|
|
11
|
+
cwd: "/tmp",
|
|
12
|
+
runId: "test",
|
|
13
|
+
stageIndex: 0,
|
|
14
|
+
state: {} as never,
|
|
15
|
+
branch,
|
|
16
|
+
branchOffset: undefined,
|
|
17
|
+
snapshot: undefined,
|
|
18
|
+
skill: "test",
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("transcriptPathCollector", () => {
|
|
22
|
+
it("throws at construction when pattern is missing", () => {
|
|
23
|
+
// @ts-expect-error — intentional misuse
|
|
24
|
+
expect(() => transcriptPathCollector({})).toThrow(/pattern.*required/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("emits one fs artifact for the last regex match in assistant text", async () => {
|
|
28
|
+
const collector = transcriptPathCollector({ pattern: /docs\/adr\/[\w-]+\.md/g });
|
|
29
|
+
const ctx = ctxOf([asst("Wrote docs/adr/0001-init.md, see docs/adr/0002-types.md")]);
|
|
30
|
+
const result = await collector.collect(ctx);
|
|
31
|
+
expect(result.kind).toBe("ok");
|
|
32
|
+
if (result.kind !== "ok") return;
|
|
33
|
+
expect(result.artifacts).toEqual([{ handle: { kind: "fs", path: "docs/adr/0002-types.md" }, role: "primary" }]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("fatals when no match is found (produces contract)", async () => {
|
|
37
|
+
const collector = transcriptPathCollector({ pattern: /docs\/adr\/[\w-]+\.md/g });
|
|
38
|
+
const ctx = ctxOf([asst("nothing here")]);
|
|
39
|
+
const result = await collector.collect(ctx);
|
|
40
|
+
expect(result.kind).toBe("fatal");
|
|
41
|
+
if (result.kind !== "fatal") return;
|
|
42
|
+
expect(result.message).toMatch(/finished without producing a path matching/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("scans reverse — returns the match from the latest assistant message", async () => {
|
|
46
|
+
const collector = transcriptPathCollector({ pattern: /docs\/[\w-]+\.md/g });
|
|
47
|
+
const ctx = ctxOf([asst("first: docs/old.md"), asst("final: docs/new.md")]);
|
|
48
|
+
const result = await collector.collect(ctx);
|
|
49
|
+
expect(result.kind === "ok" && result.artifacts[0]?.handle).toEqual({ kind: "fs", path: "docs/new.md" });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("respects branchOffset (continue-policy: skips prior-stage prefix)", async () => {
|
|
53
|
+
const collector = transcriptPathCollector({ pattern: /docs\/[\w-]+\.md/g });
|
|
54
|
+
const branch = [asst("prior: docs/old.md"), asst("current: docs/new.md")];
|
|
55
|
+
const ctx = { ...ctxOf(branch), branchOffset: 1 };
|
|
56
|
+
const result = await collector.collect(ctx);
|
|
57
|
+
expect(result.kind === "ok" && result.artifacts[0]?.handle).toEqual({ kind: "fs", path: "docs/new.md" });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("ignores user messages and non-message entries", async () => {
|
|
61
|
+
const collector = transcriptPathCollector({ pattern: /docs\/[\w-]+\.md/g });
|
|
62
|
+
const ctx = ctxOf([
|
|
63
|
+
{ type: "message", message: { role: "user", content: [{ type: "text", text: "see docs/from-user.md" }] } },
|
|
64
|
+
{ type: "thinking_level_change" },
|
|
65
|
+
asst("agent: docs/from-agent.md"),
|
|
66
|
+
]);
|
|
67
|
+
const result = await collector.collect(ctx);
|
|
68
|
+
expect(result.kind === "ok" && result.artifacts[0]?.handle).toEqual({ kind: "fs", path: "docs/from-agent.md" });
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcript-path collector — the lowest-level text-scan primitive.
|
|
3
|
+
*
|
|
4
|
+
* Scans the assistant's spoken text (in reverse) for `pattern`, emits
|
|
5
|
+
* the LAST match as a single `fs` artifact. Caller supplies the regex
|
|
6
|
+
* verbatim — the framework knows zero about any project's layout
|
|
7
|
+
* conventions. Build domain-specific collectors (rpiv-pi's
|
|
8
|
+
* `rpivArtifactCollector`, an `adrCollector` for `docs/adr/...`, etc.) by
|
|
9
|
+
* wrapping this and supplying the appropriate pattern.
|
|
10
|
+
*
|
|
11
|
+
* The pattern's `g` flag is honoured: with `g`, every occurrence in
|
|
12
|
+
* each text block is considered (helper takes the last one); without
|
|
13
|
+
* `g`, only the first per block. Authors who want N matches → N
|
|
14
|
+
* artifacts compose with `unionCollectors` or write a bespoke collector.
|
|
15
|
+
*
|
|
16
|
+
* Fatal when no match is found — produces stages that wire this
|
|
17
|
+
* promise an output, and silently returning zero artifacts hides the
|
|
18
|
+
* agent's failure mode behind a stale primary-artifact.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { fs } from "../../handle.js";
|
|
22
|
+
import type { ArtifactCollector } from "../../output-spec.js";
|
|
23
|
+
import { defineCollector } from "../../output-spec.js";
|
|
24
|
+
import { lastMatchInBranch } from "../../transcript.js";
|
|
25
|
+
|
|
26
|
+
export interface TranscriptPathCollectorOpts {
|
|
27
|
+
/**
|
|
28
|
+
* Pattern to match against assistant text. REQUIRED — the framework
|
|
29
|
+
* has no default (path layouts are project-specific). Use `g` to scan
|
|
30
|
+
* for all matches per block (helper takes the last); without `g`,
|
|
31
|
+
* only the first match per block is considered.
|
|
32
|
+
*/
|
|
33
|
+
pattern: RegExp;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function transcriptPathCollector(opts: TranscriptPathCollectorOpts): ArtifactCollector {
|
|
37
|
+
if (!(opts.pattern instanceof RegExp)) {
|
|
38
|
+
throw new Error("transcriptPathCollector: `pattern` is required and must be a RegExp");
|
|
39
|
+
}
|
|
40
|
+
const pattern = opts.pattern;
|
|
41
|
+
return defineCollector({
|
|
42
|
+
collect: (ctx) => {
|
|
43
|
+
const path = lastMatchInBranch(ctx.branch, pattern, ctx.branchOffset);
|
|
44
|
+
if (!path) {
|
|
45
|
+
return {
|
|
46
|
+
kind: "fatal",
|
|
47
|
+
message: `${ctx.skill} finished without producing a path matching ${pattern.source}`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return { kind: "ok", artifacts: [{ handle: fs(path), role: "primary" }] };
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { fs } from "../../handle.js";
|
|
3
|
+
import type { ArtifactCollector } from "../../output-spec.js";
|
|
4
|
+
import { unionCollectors } from "./union.js";
|
|
5
|
+
|
|
6
|
+
const ctxOf = () => ({
|
|
7
|
+
cwd: "/tmp",
|
|
8
|
+
runId: "test",
|
|
9
|
+
stageIndex: 0,
|
|
10
|
+
state: {} as never,
|
|
11
|
+
branch: [],
|
|
12
|
+
branchOffset: undefined,
|
|
13
|
+
snapshot: undefined,
|
|
14
|
+
skill: "test",
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const okCollector = (paths: string[]): ArtifactCollector => ({
|
|
18
|
+
collect: () => ({ kind: "ok", artifacts: paths.map((p) => ({ handle: fs(p) })) }),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const fatalCollector = (msg: string): ArtifactCollector => ({
|
|
22
|
+
collect: () => ({ kind: "fatal", message: msg }),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("unionCollectors", () => {
|
|
26
|
+
it("throws when constructed with zero collectors", () => {
|
|
27
|
+
expect(() => unionCollectors()).toThrow(/at least one collector/);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("concatenates artifacts in collector order", async () => {
|
|
31
|
+
const union = unionCollectors(okCollector(["a.ts", "b.ts"]), okCollector(["c.ts"]));
|
|
32
|
+
const result = await union.collect(ctxOf());
|
|
33
|
+
expect(
|
|
34
|
+
result.kind === "ok" && result.artifacts.map((a) => (a.handle.kind === "fs" ? a.handle.path : "")),
|
|
35
|
+
).toEqual(["a.ts", "b.ts", "c.ts"]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("returns ok+empty when every sub-collector yielded ok+empty", async () => {
|
|
39
|
+
const union = unionCollectors(okCollector([]), okCollector([]));
|
|
40
|
+
const result = await union.collect(ctxOf());
|
|
41
|
+
expect(result.kind === "ok" && result.artifacts).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("returns ok when at least one sub-collector succeeds (even if others fatal)", async () => {
|
|
45
|
+
const union = unionCollectors(fatalCollector("transcript: no match"), okCollector(["b.ts"]));
|
|
46
|
+
const result = await union.collect(ctxOf());
|
|
47
|
+
expect(
|
|
48
|
+
result.kind === "ok" && result.artifacts.map((a) => (a.handle.kind === "fs" ? a.handle.path : "")),
|
|
49
|
+
).toEqual(["b.ts"]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("returns fatal carrying the LAST fatal message when every sub-collector fataled", async () => {
|
|
53
|
+
const union = unionCollectors(fatalCollector("first failure"), fatalCollector("second failure"));
|
|
54
|
+
const result = await union.collect(ctxOf());
|
|
55
|
+
expect(result.kind).toBe("fatal");
|
|
56
|
+
if (result.kind !== "fatal") return;
|
|
57
|
+
expect(result.message).toBe("second failure");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Union collector — runs N collectors and concatenates their artifacts.
|
|
3
|
+
*
|
|
4
|
+
* Useful for the "look in transcript OR tool calls" pattern, or for
|
|
5
|
+
* combining a workspace-diff scan with a transcript URL scan. The
|
|
6
|
+
* sub-collectors run sequentially; their `snapshot` hooks are NOT
|
|
7
|
+
* threaded (each collector gets its own snapshot only if it declares
|
|
8
|
+
* one and is invoked through `OutputSpec.collector` directly — wrapping
|
|
9
|
+
* collectors inside a union loses individual snapshots today).
|
|
10
|
+
*
|
|
11
|
+
* Fatal policy: `unionCollectors` returns `fatal` only when EVERY
|
|
12
|
+
* sub-collector returned fatal (carries the last fatal message for
|
|
13
|
+
* diagnostics). One success is enough for the union to succeed —
|
|
14
|
+
* matches the "any of these channels produced the artifact" mental
|
|
15
|
+
* model the union represents.
|
|
16
|
+
*
|
|
17
|
+
* Empty artifact list from one sub-collector is treated as `ok` (it
|
|
18
|
+
* just contributes nothing to the concatenation). The union itself
|
|
19
|
+
* returns `ok` with the merged list; the runner's
|
|
20
|
+
* `enforceCompletionContract` decides whether an empty merged list is
|
|
21
|
+
* a halt (produces) or a pass-through (side-effect).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { Artifact } from "../../handle.js";
|
|
25
|
+
import type { ArtifactCollector, CollectResult } from "../../output-spec.js";
|
|
26
|
+
import { defineCollector } from "../../output-spec.js";
|
|
27
|
+
|
|
28
|
+
export function unionCollectors(...collectors: ArtifactCollector[]): ArtifactCollector {
|
|
29
|
+
if (collectors.length === 0) {
|
|
30
|
+
throw new Error("unionCollectors: at least one collector is required");
|
|
31
|
+
}
|
|
32
|
+
return defineCollector({
|
|
33
|
+
collect: async (ctx) => {
|
|
34
|
+
const all: Artifact[] = [];
|
|
35
|
+
let lastFatalMessage: string | undefined;
|
|
36
|
+
let everySubCollectorFatal = true;
|
|
37
|
+
for (const c of collectors) {
|
|
38
|
+
const result: CollectResult = await c.collect(ctx);
|
|
39
|
+
if (result.kind === "fatal") {
|
|
40
|
+
lastFatalMessage = result.message;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
everySubCollectorFatal = false;
|
|
44
|
+
all.push(...result.artifacts);
|
|
45
|
+
}
|
|
46
|
+
if (everySubCollectorFatal) {
|
|
47
|
+
return {
|
|
48
|
+
kind: "fatal",
|
|
49
|
+
message: lastFatalMessage ?? `${ctx.skill}: unionCollectors had no successful sub-collector`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return { kind: "ok", artifacts: all };
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { BranchEntry } from "../../transcript.js";
|
|
3
|
+
import { urlCollector } from "./url.js";
|
|
4
|
+
|
|
5
|
+
const asst = (text: string): BranchEntry => ({
|
|
6
|
+
type: "message",
|
|
7
|
+
message: { role: "assistant", content: [{ type: "text", text }] },
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const ctxOf = (branch: BranchEntry[]) => ({
|
|
11
|
+
cwd: "/tmp",
|
|
12
|
+
runId: "test",
|
|
13
|
+
stageIndex: 0,
|
|
14
|
+
state: {} as never,
|
|
15
|
+
branch,
|
|
16
|
+
branchOffset: undefined,
|
|
17
|
+
snapshot: undefined,
|
|
18
|
+
skill: "test",
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("urlCollector", () => {
|
|
22
|
+
it("emits a url() handle for an https URL in assistant text", async () => {
|
|
23
|
+
const collector = urlCollector();
|
|
24
|
+
const ctx = ctxOf([asst("Opened https://github.com/owner/repo/pull/42 for review.")]);
|
|
25
|
+
const result = await collector.collect(ctx);
|
|
26
|
+
expect(result.kind === "ok" && result.artifacts[0]?.handle).toEqual({
|
|
27
|
+
kind: "url",
|
|
28
|
+
href: "https://github.com/owner/repo/pull/42",
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("trims trailing prose punctuation (.,;:!?))", async () => {
|
|
33
|
+
const collector = urlCollector();
|
|
34
|
+
const ctx = ctxOf([asst("See https://example.com/page.")]);
|
|
35
|
+
const result = await collector.collect(ctx);
|
|
36
|
+
expect(result.kind === "ok" && result.artifacts[0]?.handle).toEqual({
|
|
37
|
+
kind: "url",
|
|
38
|
+
href: "https://example.com/page",
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("returns the last URL when multiple appear", async () => {
|
|
43
|
+
const collector = urlCollector();
|
|
44
|
+
const ctx = ctxOf([asst("first: https://a.com second: https://b.com")]);
|
|
45
|
+
const result = await collector.collect(ctx);
|
|
46
|
+
expect(result.kind === "ok" && result.artifacts[0]?.handle).toEqual({ kind: "url", href: "https://b.com" });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("fatals when no URL is found", async () => {
|
|
50
|
+
const collector = urlCollector();
|
|
51
|
+
const ctx = ctxOf([asst("no link here")]);
|
|
52
|
+
const result = await collector.collect(ctx);
|
|
53
|
+
expect(result.kind).toBe("fatal");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("accepts a narrower pattern (e.g. only linear.app URLs)", async () => {
|
|
57
|
+
const collector = urlCollector({ pattern: /https:\/\/linear\.app\/[^\s)]+/g });
|
|
58
|
+
const ctx = ctxOf([
|
|
59
|
+
asst("Filed https://github.com/owner/r/issues/1 and https://linear.app/team/issue/ENG-42 — see linear."),
|
|
60
|
+
]);
|
|
61
|
+
const result = await collector.collect(ctx);
|
|
62
|
+
expect(result.kind === "ok" && result.artifacts[0]?.handle).toEqual({
|
|
63
|
+
kind: "url",
|
|
64
|
+
href: "https://linear.app/team/issue/ENG-42",
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL collector — scans assistant text for a URL and emits it as a
|
|
3
|
+
* `url` handle (not `fs`). Use when the stage produces a remote
|
|
4
|
+
* reference: a Linear ticket URL, a deployed-preview link, a posted
|
|
5
|
+
* PR/comment, etc.
|
|
6
|
+
*
|
|
7
|
+
* Default `pattern` is RFC-3986-flavoured `https?://` — genuinely
|
|
8
|
+
* universal (every protocol-bearing URL the agent prints in markdown
|
|
9
|
+
* matches). Authors needing a narrower shape (only github.com, only
|
|
10
|
+
* linear.app) pass their own RegExp.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { url } from "../../handle.js";
|
|
14
|
+
import type { ArtifactCollector } from "../../output-spec.js";
|
|
15
|
+
import { defineCollector } from "../../output-spec.js";
|
|
16
|
+
import { lastMatchInBranch } from "../../transcript.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Conservative URL matcher — `https?://` plus non-whitespace, stopping
|
|
20
|
+
* at common terminator characters (`<>"'` and trailing punctuation
|
|
21
|
+
* the model often appends in prose). Not RFC-3986 strict; tuned for
|
|
22
|
+
* the assistant-prose case rather than for validating arbitrary input.
|
|
23
|
+
*/
|
|
24
|
+
const DEFAULT_URL_PATTERN = /\bhttps?:\/\/[^\s<>"'`]+[^\s<>"'`.,;:!?)\]}]/g;
|
|
25
|
+
|
|
26
|
+
export interface UrlCollectorOpts {
|
|
27
|
+
/** Override the default URL pattern (e.g. narrow to one host). */
|
|
28
|
+
pattern?: RegExp;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function urlCollector(opts: UrlCollectorOpts = {}): ArtifactCollector {
|
|
32
|
+
const pattern = opts.pattern ?? DEFAULT_URL_PATTERN;
|
|
33
|
+
return defineCollector({
|
|
34
|
+
collect: (ctx) => {
|
|
35
|
+
const href = lastMatchInBranch(ctx.branch, pattern, ctx.branchOffset);
|
|
36
|
+
if (!href) {
|
|
37
|
+
return {
|
|
38
|
+
kind: "fatal",
|
|
39
|
+
message: `${ctx.skill} finished without producing a URL matching ${pattern.source}`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return { kind: "ok", artifacts: [{ handle: url(href), role: "primary" }] };
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
}
|