@zachwill/pi-orchestrate 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.
- package/LICENSE +21 -0
- package/README.md +94 -0
- package/examples/workers/investigator.md +41 -0
- package/examples/workers/scout.md +36 -0
- package/examples/workers/worker.md +44 -0
- package/extension/catalog.ts +372 -0
- package/extension/contract.ts +70 -0
- package/extension/delivery.ts +196 -0
- package/extension/domain.ts +335 -0
- package/extension/host.ts +107 -0
- package/extension/index.ts +176 -0
- package/extension/presentation.ts +629 -0
- package/extension/runtime.ts +1193 -0
- package/extension/scheduler.ts +66 -0
- package/extension/tools.ts +559 -0
- package/extension/worker-session.ts +526 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zach Williams
|
|
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,94 @@
|
|
|
1
|
+
# Pi Orchestrate
|
|
2
|
+
|
|
3
|
+
[`@zachwill/pi-orchestrate`](https://www.npmjs.com/package/@zachwill/pi-orchestrate) adds concurrent worker orchestration to [Pi](https://pi.dev). It helps a parent agent **delegate → dispatch → deliver**: delegate bounded work, dispatch independent tasks as one wave, then deliver one aggregate result to the parent.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install npm:@zachwill/pi-orchestrate
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Pi packages execute with your system permissions. Review the package and worker definitions before trusting them.
|
|
12
|
+
|
|
13
|
+
## Public tools
|
|
14
|
+
|
|
15
|
+
Pi Orchestrate adds exactly five tools:
|
|
16
|
+
|
|
17
|
+
- `orchestrate` dispatches 1–12 tasks with `orchestrate({ tasks: [{ worker, title, instructions }] })`.
|
|
18
|
+
- `orchestration_status` inspects the trusted catalog, catalog diagnostics, waves, and worker states without exposing full task instructions.
|
|
19
|
+
- `worker_send` sends follow-up instructions to an owned reusable worker in the `ready` state.
|
|
20
|
+
- `worker_abort` stops owned active work that is no longer needed.
|
|
21
|
+
- `worker_close` closes an owned reusable worker in the `ready` state.
|
|
22
|
+
|
|
23
|
+
An `orchestrate` wave first performs atomic input, catalog, and model preflight. No worker starts if any task fails that preflight. After acceptance, worker resources start independently: a resource startup failure becomes that worker's `failed` result and does not roll back or stop its peers.
|
|
24
|
+
|
|
25
|
+
All tasks in a wave execute concurrently, including a full 12-task wave. Pi Orchestrate applies no hidden concurrency throttle. The aggregate result preserves task order.
|
|
26
|
+
|
|
27
|
+
For asynchronous behavior, `orchestrate` or `worker_send` must be the sole tool call in its assistant message. Mixing either with any sibling tool call makes its wave inline and blocking. Inline work receives the parent turn's cancellation signal. Accepted async work does not retain that signal and continues independently.
|
|
28
|
+
|
|
29
|
+
A successful async call yields the parent turn until aggregate results arrive. Do not poll `orchestration_status` for completion. If the owning session becomes inactive, its workers continue and completed results remain queued. Those results become available only when that exact owning session resumes; they are never delivered to another session.
|
|
30
|
+
|
|
31
|
+
Worker IDs identify live worker sessions. A reusable worker keeps the same worker ID across `worker_send` follow-ups, with each follow-up result belonging to a new wave. One-shot workers finish as `completed`. Reusable workers deliver as `ready` and wait for `worker_send` or `worker_close`. Use `worker_abort` only for active work, not to close a ready worker.
|
|
32
|
+
|
|
33
|
+
## Parent orchestration contract
|
|
34
|
+
|
|
35
|
+
Pi Orchestrate automatically injects the authoritative orchestration contract and trusted worker catalog into the parent system prompt. In summary, the parent owns the task end to end:
|
|
36
|
+
|
|
37
|
+
1. Keep trivial or tightly coupled work in the parent session.
|
|
38
|
+
2. Give every delegated task a full brief: objective, paths and scope, forbidden actions, context, constraints, observable success, checks, and expected output.
|
|
39
|
+
3. Dispatch every known independent task in one `orchestrate` wave, up to 12 tasks.
|
|
40
|
+
4. Make an async `orchestrate` or `worker_send` call the sole tool call in its assistant message, then yield after acceptance.
|
|
41
|
+
5. Review delivered evidence and changes, resolve conflicts, integrate deliberately, and run the relevant verification.
|
|
42
|
+
6. Deliver the final answer from the parent session.
|
|
43
|
+
|
|
44
|
+
Workers provide evidence or bounded changes. They do not replace parent judgment.
|
|
45
|
+
|
|
46
|
+
## Trusted worker catalog
|
|
47
|
+
|
|
48
|
+
Definitions are loaded by name with this precedence:
|
|
49
|
+
|
|
50
|
+
1. Package workers in [`examples/workers/`](examples/workers/) are active fallbacks automatically.
|
|
51
|
+
2. User definitions in `~/.pi/agent/pi-orchestrate/workers/*.md` override package fallbacks by name.
|
|
52
|
+
3. Project definitions in `<project>/.pi/pi-orchestrate/workers/*.md` override user and package definitions by name, but only after Pi trusts the project.
|
|
53
|
+
|
|
54
|
+
An untrusted project contributes no worker definitions. Review project definitions as part of Pi's normal project-trust flow before enabling them.
|
|
55
|
+
|
|
56
|
+
All three package fallbacks intentionally omit `model`, so they portably inherit the parent model active at dispatch. User and trusted project overrides are the model-specialization points: add `model` to an override only when that worker needs a specific provider/model.
|
|
57
|
+
|
|
58
|
+
To customize a fallback, create a Markdown definition manually at the user or project path with the same filename and `name`. The linked package fallbacks are templates. This instruction does not assume your shell is inside a source checkout or that an npm-installed package has a particular current working directory.
|
|
59
|
+
|
|
60
|
+
## Worker definitions
|
|
61
|
+
|
|
62
|
+
A worker is a strict Markdown system prompt. Its basename must equal its `name`:
|
|
63
|
+
|
|
64
|
+
```md
|
|
65
|
+
---
|
|
66
|
+
name: reviewer
|
|
67
|
+
description: Reviews a bounded change and returns evidence.
|
|
68
|
+
tools: read, grep, find, ls, bash
|
|
69
|
+
lifecycle: reusable
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
You are a review worker. Inspect only the assigned scope and return concise findings with file paths.
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Frontmatter supports these fields:
|
|
76
|
+
|
|
77
|
+
- `name` and `description` are required.
|
|
78
|
+
- `tools` and `lifecycle` are required. Grant the smallest useful tool set.
|
|
79
|
+
- `model` is optional. When omitted, the worker inherits the parent model active when dispatched.
|
|
80
|
+
- `thinking`, `skills`, and `compaction` are optional.
|
|
81
|
+
- `lifecycle` must be exactly `one-shot` or `reusable`.
|
|
82
|
+
- The Markdown body must be nonempty.
|
|
83
|
+
|
|
84
|
+
Unknown fields, invalid values, filename/name mismatches, and empty bodies invalidate a definition. A read-only prompt is not enforcement when its tools can write.
|
|
85
|
+
|
|
86
|
+
## Lifecycle and process limits
|
|
87
|
+
|
|
88
|
+
Use one-shot workers for bounded investigation, review, and implementation. Use reusable workers only when follow-up continuity matters. Reusable workers remain live in memory while the Pi process is running; they do not survive process exit. Close ready reusable workers when the conversation is complete.
|
|
89
|
+
|
|
90
|
+
## Isolation, trust, and writes
|
|
91
|
+
|
|
92
|
+
Worker sessions are isolated from the parent's conversational context, but they run in-process and are not sandboxes. They share the parent process's filesystem and environment permissions. Treat worker prompts, optional skills, models, and tool grants as trusted code.
|
|
93
|
+
|
|
94
|
+
Pi Orchestrate performs no automatic filesystem writes. A worker writes only when its instructions and granted tools cause it to do so. Parallel workers must have non-overlapping write scopes, and the parent must inspect and verify their changes.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: investigator
|
|
3
|
+
description: Read-only worker for cross-file investigation, comparison, and evidence-based synthesis.
|
|
4
|
+
thinking: medium
|
|
5
|
+
tools: read, grep, find, ls, bash
|
|
6
|
+
lifecycle: one-shot
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
You are an investigator worker. Trace a bounded question across the relevant files and return a synthesis the parent orchestrator can use without repeating your exploration. Deliver your output in the same language as the assignment.
|
|
10
|
+
|
|
11
|
+
Do not modify files. Use bash only for read-only commands. Do not run builds, tests, or commands that mutate state.
|
|
12
|
+
|
|
13
|
+
Read enough to distinguish confirmed behavior from inference, then stop. Focus on relationships, ownership, data flow, trade-offs, and risks that directly affect the assigned question.
|
|
14
|
+
|
|
15
|
+
## Output
|
|
16
|
+
|
|
17
|
+
### Scope
|
|
18
|
+
|
|
19
|
+
- What you investigated
|
|
20
|
+
- What remained outside scope
|
|
21
|
+
|
|
22
|
+
### Findings
|
|
23
|
+
|
|
24
|
+
For each finding:
|
|
25
|
+
|
|
26
|
+
- `path/to/file#L10-L20` or `symbolName` in `path/to/file`
|
|
27
|
+
- Finding: what exists or happens
|
|
28
|
+
- Evidence: why it is confirmed
|
|
29
|
+
- Relevance: why it matters
|
|
30
|
+
|
|
31
|
+
### Synthesis
|
|
32
|
+
|
|
33
|
+
Explain the system shape or conclusion supported by the findings.
|
|
34
|
+
|
|
35
|
+
### Gaps
|
|
36
|
+
|
|
37
|
+
Include only unresolved questions that materially affect implementation or review.
|
|
38
|
+
|
|
39
|
+
### Start Here
|
|
40
|
+
|
|
41
|
+
Name the first files or symbols the parent should inspect next.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: scout
|
|
3
|
+
description: Fast read-only worker for tiny factual probes and bounded repository reconnaissance.
|
|
4
|
+
thinking: medium
|
|
5
|
+
tools: read, grep, find, ls, bash
|
|
6
|
+
lifecycle: one-shot
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
You are a scout worker. Investigate one narrow question quickly and return evidence the parent orchestrator can use without repeating your search. Deliver your output in the same language as the assignment.
|
|
10
|
+
|
|
11
|
+
Do not modify files. Use bash only for read-only commands. Do not run builds, tests, or commands that mutate state.
|
|
12
|
+
|
|
13
|
+
Accept only bounded discovery: one path range, symbol, command output, inventory, comparison, or existence check. If the assignment requires broad synthesis, architecture judgment, planning, or implementation, stop and recommend the investigator or implementation worker.
|
|
14
|
+
|
|
15
|
+
## Output
|
|
16
|
+
|
|
17
|
+
### Scope
|
|
18
|
+
|
|
19
|
+
- What you inspected
|
|
20
|
+
- What you did not inspect
|
|
21
|
+
|
|
22
|
+
### Findings
|
|
23
|
+
|
|
24
|
+
For each finding:
|
|
25
|
+
|
|
26
|
+
- `path/to/file#L10-L20` or `symbolName` in `path/to/file`
|
|
27
|
+
- Finding: concrete fact
|
|
28
|
+
- Relevance: why it matters
|
|
29
|
+
|
|
30
|
+
### Gaps
|
|
31
|
+
|
|
32
|
+
Include only unresolved questions that materially affect the parent task.
|
|
33
|
+
|
|
34
|
+
### Start Here
|
|
35
|
+
|
|
36
|
+
Name the first file or symbol the parent should inspect next.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: worker
|
|
3
|
+
description: Implementation worker for bounded code changes with explicit scope and acceptance criteria.
|
|
4
|
+
thinking: medium
|
|
5
|
+
tools: read, bash, edit, write, grep, find, ls
|
|
6
|
+
lifecycle: one-shot
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
You are an implementation worker. Complete the assigned change within its stated scope and report the result to the parent orchestrator in the same language as the assignment.
|
|
10
|
+
|
|
11
|
+
## Working rules
|
|
12
|
+
|
|
13
|
+
- Read project convention files and nearby code before editing.
|
|
14
|
+
- Reuse existing helpers and patterns instead of duplicating them.
|
|
15
|
+
- Change only files required by the assignment.
|
|
16
|
+
- Keep the implementation direct and remove unused code introduced by your work.
|
|
17
|
+
- Do not commit, push, or perform destructive operations unless explicitly assigned.
|
|
18
|
+
- Stop and report a blocker when a required decision is unclear.
|
|
19
|
+
|
|
20
|
+
## Verification
|
|
21
|
+
|
|
22
|
+
Run the narrowest relevant lint, type-check, test, or build commands. Fix only failures caused by your changes and distinguish pre-existing failures with concrete evidence.
|
|
23
|
+
|
|
24
|
+
## Output
|
|
25
|
+
|
|
26
|
+
### Completed
|
|
27
|
+
|
|
28
|
+
Concise description of the result.
|
|
29
|
+
|
|
30
|
+
### Files Changed
|
|
31
|
+
|
|
32
|
+
- `path/to/file` — what changed
|
|
33
|
+
|
|
34
|
+
### Verification
|
|
35
|
+
|
|
36
|
+
Commands run and their results.
|
|
37
|
+
|
|
38
|
+
### Blockers
|
|
39
|
+
|
|
40
|
+
Include only when work could not be completed.
|
|
41
|
+
|
|
42
|
+
### Observations
|
|
43
|
+
|
|
44
|
+
Include only relevant out-of-scope issues that were not changed.
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CONFIG_DIR_NAME,
|
|
3
|
+
getAgentDir,
|
|
4
|
+
parseFrontmatter,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { lstatSync, readdirSync, readFileSync } from "node:fs";
|
|
7
|
+
import { basename, extname, join } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import type {
|
|
10
|
+
CatalogDiagnostic,
|
|
11
|
+
SupportedToolName,
|
|
12
|
+
WorkerCatalog,
|
|
13
|
+
WorkerDefinition,
|
|
14
|
+
WorkerLifecycle,
|
|
15
|
+
WorkerSourceKind,
|
|
16
|
+
} from "./domain.js";
|
|
17
|
+
import { isSupportedToolName } from "./domain.js";
|
|
18
|
+
|
|
19
|
+
const MAX_WORKER_BYTES = 64 * 1024;
|
|
20
|
+
const KNOWN_FIELDS = new Set([
|
|
21
|
+
"name",
|
|
22
|
+
"description",
|
|
23
|
+
"model",
|
|
24
|
+
"thinking",
|
|
25
|
+
"tools",
|
|
26
|
+
"skills",
|
|
27
|
+
"compaction",
|
|
28
|
+
"lifecycle",
|
|
29
|
+
]);
|
|
30
|
+
const THINKING_LEVELS: ReadonlySet<string> = new Set([
|
|
31
|
+
"off",
|
|
32
|
+
"minimal",
|
|
33
|
+
"low",
|
|
34
|
+
"medium",
|
|
35
|
+
"high",
|
|
36
|
+
"xhigh",
|
|
37
|
+
"max",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
function isThinkingLevel(value: string): value is NonNullable<WorkerDefinition["thinking"]> {
|
|
41
|
+
return THINKING_LEVELS.has(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CatalogFileStat {
|
|
45
|
+
readonly size: number;
|
|
46
|
+
isFile(): boolean;
|
|
47
|
+
isSymbolicLink(): boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CatalogFileSystem {
|
|
51
|
+
readDirectory(directory: string): readonly string[];
|
|
52
|
+
inspect(path: string): CatalogFileStat;
|
|
53
|
+
readText(path: string): string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface DiscoverWorkerCatalogOptions {
|
|
57
|
+
readonly cwd: string;
|
|
58
|
+
readonly projectTrusted: boolean;
|
|
59
|
+
readonly packageRoot?: string;
|
|
60
|
+
readonly agentDir?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface CatalogSource {
|
|
64
|
+
readonly kind: WorkerSourceKind;
|
|
65
|
+
readonly directory: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface WorkerFrontmatter {
|
|
69
|
+
readonly name?: unknown;
|
|
70
|
+
readonly description?: unknown;
|
|
71
|
+
readonly model?: unknown;
|
|
72
|
+
readonly thinking?: unknown;
|
|
73
|
+
readonly tools?: unknown;
|
|
74
|
+
readonly skills?: unknown;
|
|
75
|
+
readonly compaction?: unknown;
|
|
76
|
+
readonly lifecycle?: unknown;
|
|
77
|
+
readonly [field: string]: unknown;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const productionFileSystem: CatalogFileSystem = {
|
|
81
|
+
readDirectory: (directory) => readdirSync(directory),
|
|
82
|
+
inspect: (path) => lstatSync(path),
|
|
83
|
+
readText: (path) => readFileSync(path, "utf8"),
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
function diagnostic(
|
|
87
|
+
source: WorkerSourceKind,
|
|
88
|
+
filePath: string,
|
|
89
|
+
message: string,
|
|
90
|
+
): CatalogDiagnostic {
|
|
91
|
+
return { severity: "error", source, filePath, message };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function compareText(left: string, right: string): number {
|
|
95
|
+
if (left < right) return -1;
|
|
96
|
+
if (left > right) return 1;
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isMissingPath(error: unknown): boolean {
|
|
101
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function requiredString(value: unknown, field: string): string {
|
|
105
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
106
|
+
throw new Error(`frontmatter field '${field}' must be a non-empty string`);
|
|
107
|
+
}
|
|
108
|
+
return value.trim();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function optionalString(value: unknown, field: string): string | undefined {
|
|
112
|
+
if (value === undefined) return undefined;
|
|
113
|
+
return requiredString(value, field);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function stringList(value: unknown, field: string, required: boolean): string[] | undefined {
|
|
117
|
+
if (value === undefined) {
|
|
118
|
+
if (required) throw new Error(`frontmatter field '${field}' is required`);
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const values = typeof value === "string" ? value.split(",").map((item) => item.trim()) : value;
|
|
123
|
+
if (!Array.isArray(values) || values.length === 0) {
|
|
124
|
+
throw new Error(`frontmatter field '${field}' must be a non-empty comma string or string array`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const strings: string[] = [];
|
|
128
|
+
for (const item of values) {
|
|
129
|
+
if (typeof item !== "string" || item === "") {
|
|
130
|
+
throw new Error(`frontmatter field '${field}' must be a non-empty comma string or string array`);
|
|
131
|
+
}
|
|
132
|
+
strings.push(item);
|
|
133
|
+
}
|
|
134
|
+
return strings;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function parseTools(value: unknown): SupportedToolName[] {
|
|
138
|
+
const tools: SupportedToolName[] = [];
|
|
139
|
+
for (const tool of stringList(value, "tools", true) ?? []) {
|
|
140
|
+
if (!isSupportedToolName(tool)) throw new Error(`unsupported tool '${tool}'`);
|
|
141
|
+
tools.push(tool);
|
|
142
|
+
}
|
|
143
|
+
return tools;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function parseOptionalBoolean(value: unknown, field: string): boolean | undefined {
|
|
147
|
+
if (value === undefined) return undefined;
|
|
148
|
+
if (typeof value !== "boolean") {
|
|
149
|
+
throw new Error(`frontmatter field '${field}' must be a boolean`);
|
|
150
|
+
}
|
|
151
|
+
return value;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function parseLifecycle(value: unknown): WorkerLifecycle {
|
|
155
|
+
if (value !== "one-shot" && value !== "reusable") {
|
|
156
|
+
throw new Error("frontmatter field 'lifecycle' must be 'one-shot' or 'reusable'");
|
|
157
|
+
}
|
|
158
|
+
return value;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function parseCompaction(value: unknown): WorkerDefinition["compaction"] {
|
|
162
|
+
if (value === undefined) return undefined;
|
|
163
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
164
|
+
throw new Error("frontmatter field 'compaction' must be a mapping");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const fields = Object.keys(value).sort(compareText);
|
|
168
|
+
const unknownFields = fields.filter(
|
|
169
|
+
(field) => field !== "enabled" && field !== "reserveTokens" && field !== "keepRecentTokens",
|
|
170
|
+
);
|
|
171
|
+
if (unknownFields.length > 0) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`unknown compaction field${unknownFields.length === 1 ? "" : "s"}: ${unknownFields.join(", ")}`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const enabledValue = "enabled" in value ? value.enabled : undefined;
|
|
178
|
+
const reserveTokens = "reserveTokens" in value ? value.reserveTokens : undefined;
|
|
179
|
+
const keepRecentTokens = "keepRecentTokens" in value ? value.keepRecentTokens : undefined;
|
|
180
|
+
const enabled = parseOptionalBoolean(enabledValue, "compaction.enabled");
|
|
181
|
+
|
|
182
|
+
if (
|
|
183
|
+
reserveTokens !== undefined &&
|
|
184
|
+
(typeof reserveTokens !== "number" || !Number.isSafeInteger(reserveTokens) || reserveTokens < 0)
|
|
185
|
+
) {
|
|
186
|
+
throw new Error("frontmatter field 'compaction.reserveTokens' must be a non-negative integer");
|
|
187
|
+
}
|
|
188
|
+
if (
|
|
189
|
+
keepRecentTokens !== undefined &&
|
|
190
|
+
(typeof keepRecentTokens !== "number" || !Number.isSafeInteger(keepRecentTokens) || keepRecentTokens < 0)
|
|
191
|
+
) {
|
|
192
|
+
throw new Error("frontmatter field 'compaction.keepRecentTokens' must be a non-negative integer");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { enabled, reserveTokens, keepRecentTokens };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function parseWorker(
|
|
199
|
+
filePath: string,
|
|
200
|
+
source: WorkerSourceKind,
|
|
201
|
+
content: string,
|
|
202
|
+
): WorkerDefinition {
|
|
203
|
+
let parsed: ReturnType<typeof parseFrontmatter<WorkerFrontmatter>>;
|
|
204
|
+
try {
|
|
205
|
+
parsed = parseFrontmatter<WorkerFrontmatter>(content);
|
|
206
|
+
} catch {
|
|
207
|
+
throw new Error("frontmatter is not valid YAML");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const { frontmatter, body } = parsed;
|
|
211
|
+
if (typeof frontmatter !== "object" || frontmatter === null || Array.isArray(frontmatter)) {
|
|
212
|
+
throw new Error("frontmatter must be a mapping");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const unknownFields = Object.keys(frontmatter)
|
|
216
|
+
.filter((field) => !KNOWN_FIELDS.has(field))
|
|
217
|
+
.sort(compareText);
|
|
218
|
+
if (unknownFields.length > 0) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`unknown frontmatter field${unknownFields.length === 1 ? "" : "s"}: ${unknownFields.join(", ")}`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const name = requiredString(frontmatter.name, "name");
|
|
225
|
+
const expectedName = basename(filePath, extname(filePath));
|
|
226
|
+
if (name !== expectedName) {
|
|
227
|
+
throw new Error(`frontmatter name '${name}' must match basename '${expectedName}'`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const description = requiredString(frontmatter.description, "description");
|
|
231
|
+
const model = optionalString(frontmatter.model, "model");
|
|
232
|
+
if (model !== undefined && !/^[^/\s]+\/\S+$/.test(model)) {
|
|
233
|
+
throw new Error("frontmatter field 'model' must use provider/model format");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const thinking = optionalString(frontmatter.thinking, "thinking");
|
|
237
|
+
if (thinking !== undefined && !isThinkingLevel(thinking)) {
|
|
238
|
+
throw new Error(`unsupported thinking level '${thinking}'`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const tools = parseTools(frontmatter.tools);
|
|
242
|
+
const skills = stringList(frontmatter.skills, "skills", false);
|
|
243
|
+
const compaction = parseCompaction(frontmatter.compaction);
|
|
244
|
+
const lifecycle = parseLifecycle(frontmatter.lifecycle);
|
|
245
|
+
if (body.trim() === "") throw new Error("worker prompt body must not be empty");
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
name,
|
|
249
|
+
description,
|
|
250
|
+
model:
|
|
251
|
+
model === undefined
|
|
252
|
+
? undefined
|
|
253
|
+
: {
|
|
254
|
+
provider: model.slice(0, model.indexOf("/")),
|
|
255
|
+
modelId: model.slice(model.indexOf("/") + 1),
|
|
256
|
+
},
|
|
257
|
+
thinking,
|
|
258
|
+
tools,
|
|
259
|
+
skills: skills ?? [],
|
|
260
|
+
compaction,
|
|
261
|
+
lifecycle,
|
|
262
|
+
systemPrompt: body,
|
|
263
|
+
source: { kind: source, filePath },
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function sourceDirectories(options: DiscoverWorkerCatalogOptions): CatalogSource[] {
|
|
268
|
+
const packageRoot = options.packageRoot ?? fileURLToPath(new URL("..", import.meta.url));
|
|
269
|
+
const agentDir = options.agentDir ?? getAgentDir();
|
|
270
|
+
const sources: CatalogSource[] = [
|
|
271
|
+
{ kind: "package", directory: join(packageRoot, "examples", "workers") },
|
|
272
|
+
{ kind: "user", directory: join(agentDir, "pi-orchestrate", "workers") },
|
|
273
|
+
];
|
|
274
|
+
|
|
275
|
+
if (options.projectTrusted) {
|
|
276
|
+
sources.push({
|
|
277
|
+
kind: "project",
|
|
278
|
+
directory: join(options.cwd, CONFIG_DIR_NAME, "pi-orchestrate", "workers"),
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
return sources;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function discoverSource(
|
|
285
|
+
fileSystem: CatalogFileSystem,
|
|
286
|
+
source: CatalogSource,
|
|
287
|
+
): { workers: WorkerDefinition[]; diagnostics: CatalogDiagnostic[] } {
|
|
288
|
+
let entries: readonly string[];
|
|
289
|
+
try {
|
|
290
|
+
entries = fileSystem.readDirectory(source.directory);
|
|
291
|
+
} catch (error) {
|
|
292
|
+
if (isMissingPath(error)) return { workers: [], diagnostics: [] };
|
|
293
|
+
return {
|
|
294
|
+
workers: [],
|
|
295
|
+
diagnostics: [diagnostic(source.kind, source.directory, "could not read worker directory")],
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const workers: WorkerDefinition[] = [];
|
|
300
|
+
const diagnostics: CatalogDiagnostic[] = [];
|
|
301
|
+
const markdownEntries = entries
|
|
302
|
+
.filter((entry) => extname(entry) === ".md" && basename(entry) === entry)
|
|
303
|
+
.sort(compareText);
|
|
304
|
+
|
|
305
|
+
for (const entry of markdownEntries) {
|
|
306
|
+
const filePath = join(source.directory, entry);
|
|
307
|
+
let stat: CatalogFileStat;
|
|
308
|
+
try {
|
|
309
|
+
stat = fileSystem.inspect(filePath);
|
|
310
|
+
} catch {
|
|
311
|
+
diagnostics.push(diagnostic(source.kind, filePath, "could not inspect worker file"));
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
316
|
+
diagnostics.push(
|
|
317
|
+
diagnostic(source.kind, filePath, "worker file must be a regular non-symlink file"),
|
|
318
|
+
);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (stat.size > MAX_WORKER_BYTES) {
|
|
322
|
+
diagnostics.push(
|
|
323
|
+
diagnostic(source.kind, filePath, `worker file exceeds ${MAX_WORKER_BYTES} bytes`),
|
|
324
|
+
);
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
let content: string;
|
|
329
|
+
try {
|
|
330
|
+
content = fileSystem.readText(filePath);
|
|
331
|
+
} catch {
|
|
332
|
+
diagnostics.push(diagnostic(source.kind, filePath, "could not read worker file"));
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (Buffer.byteLength(content, "utf8") > MAX_WORKER_BYTES) {
|
|
337
|
+
diagnostics.push(
|
|
338
|
+
diagnostic(source.kind, filePath, `worker file exceeds ${MAX_WORKER_BYTES} bytes`),
|
|
339
|
+
);
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
try {
|
|
344
|
+
workers.push(parseWorker(filePath, source.kind, content));
|
|
345
|
+
} catch (error) {
|
|
346
|
+
const message = error instanceof Error ? error.message : "invalid worker definition";
|
|
347
|
+
diagnostics.push(diagnostic(source.kind, filePath, message));
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return { workers, diagnostics };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function createWorkerCatalogDiscovery(fileSystem: CatalogFileSystem) {
|
|
355
|
+
return (options: DiscoverWorkerCatalogOptions): WorkerCatalog => {
|
|
356
|
+
const workersByName = new Map<string, WorkerDefinition>();
|
|
357
|
+
const diagnostics: CatalogDiagnostic[] = [];
|
|
358
|
+
|
|
359
|
+
for (const source of sourceDirectories(options)) {
|
|
360
|
+
const discovered = discoverSource(fileSystem, source);
|
|
361
|
+
for (const worker of discovered.workers) workersByName.set(worker.name, worker);
|
|
362
|
+
diagnostics.push(...discovered.diagnostics);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const workers = [...workersByName.values()].sort((left, right) =>
|
|
366
|
+
compareText(left.name, right.name),
|
|
367
|
+
);
|
|
368
|
+
return { workers, diagnostics };
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export const discoverWorkerCatalog = createWorkerCatalogDiscovery(productionFileSystem);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { WorkerCatalog } from "./domain.js";
|
|
2
|
+
|
|
3
|
+
const CONTRACT_START = "<!-- pi-orchestrate:contract:start -->";
|
|
4
|
+
const CONTRACT_END = "<!-- pi-orchestrate:contract:end -->";
|
|
5
|
+
|
|
6
|
+
function contractWorkers(catalog: WorkerCatalog) {
|
|
7
|
+
return [...catalog.workers].sort((left, right) => {
|
|
8
|
+
if (left.name < right.name) return -1;
|
|
9
|
+
if (left.name > right.name) return 1;
|
|
10
|
+
return 0;
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function formatCatalog(catalog: WorkerCatalog): string {
|
|
15
|
+
const workers = contractWorkers(catalog);
|
|
16
|
+
if (workers.length === 0) return "- No trusted workers are available for this session.";
|
|
17
|
+
|
|
18
|
+
return workers
|
|
19
|
+
.map(
|
|
20
|
+
(worker) =>
|
|
21
|
+
`- \`${worker.name}\` [${worker.source.kind}] (${worker.lifecycle}): ${worker.description}`,
|
|
22
|
+
)
|
|
23
|
+
.join("\n");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function buildContract(catalog: WorkerCatalog): string {
|
|
27
|
+
return `${CONTRACT_START}
|
|
28
|
+
## Pi Orchestrate Contract
|
|
29
|
+
|
|
30
|
+
You are the parent orchestrator and own the task end to end.
|
|
31
|
+
|
|
32
|
+
- Keep trivial or tightly coupled work in the parent. Use as many useful workers as independent scopes justify.
|
|
33
|
+
- Delegate every known independent scope in one concurrent \`orchestrate\` wave with \`{ tasks: [{ worker, title, instructions }] }\`. A wave accepts at most 12 tasks, and all tasks execute concurrently with no hidden throttle.
|
|
34
|
+
- Give every worker a full brief: objective; paths/scope; forbidden actions; context; constraints; observable success; checks; expected output.
|
|
35
|
+
- Input, catalog, and model preflight is atomic before any worker starts. Resource startup failures become per-worker failed results and do not roll back peers.
|
|
36
|
+
- For asynchronous behavior, \`orchestrate\` or \`worker_send\` must be the sole tool call in its assistant message. Mixing either with any sibling tool call makes its wave inline and blocking.
|
|
37
|
+
- After an accepted async wave, yield the parent turn until aggregate results arrive. Do not duplicate delegated work or poll \`orchestration_status\` for completion.
|
|
38
|
+
- The parent synthesizes worker results, reviews their evidence and changes, resolves conflicts, integrates the final result, and runs the relevant verification before declaring completion.
|
|
39
|
+
- Prefer one-shot workers. Use \`worker_send\` for follow-up work on a ready reusable worker, \`worker_close\` when that ready worker is finished, and \`worker_abort\` only when active work must stop.
|
|
40
|
+
- The public tools are \`orchestrate\`, \`orchestration_status\`, \`worker_send\`, \`worker_abort\`, and \`worker_close\`.
|
|
41
|
+
|
|
42
|
+
### Trusted worker catalog
|
|
43
|
+
|
|
44
|
+
Source labels show where each trusted definition came from; later catalog sources have already overridden earlier definitions with the same name.
|
|
45
|
+
|
|
46
|
+
${formatCatalog(catalog)}
|
|
47
|
+
${CONTRACT_END}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function appendOrchestratorContract(
|
|
51
|
+
systemPrompt: string,
|
|
52
|
+
catalog: WorkerCatalog,
|
|
53
|
+
): string {
|
|
54
|
+
const section = buildContract(catalog);
|
|
55
|
+
const start = systemPrompt.indexOf(CONTRACT_START);
|
|
56
|
+
if (start >= 0) {
|
|
57
|
+
const end = systemPrompt.indexOf(CONTRACT_END, start);
|
|
58
|
+
if (end >= 0) {
|
|
59
|
+
return `${systemPrompt.slice(0, start)}${section}${systemPrompt.slice(end + CONTRACT_END.length)}`;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const separator =
|
|
64
|
+
systemPrompt.length === 0 || systemPrompt.endsWith("\n\n")
|
|
65
|
+
? ""
|
|
66
|
+
: systemPrompt.endsWith("\n")
|
|
67
|
+
? "\n"
|
|
68
|
+
: "\n\n";
|
|
69
|
+
return `${systemPrompt}${separator}${section}`;
|
|
70
|
+
}
|