@signalridge/pi-workflows 1.5.0 → 1.7.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/CHANGELOG.md +227 -0
- package/README.md +207 -48
- package/package.json +2 -2
- package/skills/workflow-authoring/SKILL.md +44 -14
- package/skills/workflow-authoring/examples/defensive-json-parsing.js +1 -1
- package/skills/workflow-authoring/examples/phased-budgets.js +1 -1
- package/skills/workflow-authoring/examples/saved-nested-workflows.js +1 -1
- package/skills/workflow-authoring/examples/structured-output.js +1 -1
- package/skills/workflow-authoring/references/capabilities.md +7 -7
- package/skills/workflow-authoring/references/capability-details.md +24 -10
- package/skills/workflow-authoring/references/debugging.md +1 -1
- package/skills/workflow-authoring/references/lifecycle.md +5 -1
- package/skills/workflow-authoring/references/quality-helpers.md +2 -2
- package/skills/workflow-authoring/references/registry-ownership.md +4 -4
- package/skills/workflow-authoring/references/review.md +8 -4
- package/skills/workflow-authoring/references/runtime.md +2 -2
- package/skills/workflow-authoring/references/specialized-helpers.md +1 -1
- package/skills/workflow-authoring/references/versions.md +1 -1
- package/skills/workflow-patterns/SKILL.md +1 -1
- package/skills/workflow-review/SKILL.md +6 -4
- package/src/builtins.ts +23 -23
- package/src/capabilities.ts +37 -12
- package/src/engine.ts +70 -26
- package/src/index.ts +243 -70
- package/src/journal.ts +0 -14
- package/src/rpc-client.ts +14 -1
- package/src/runtime.ts +452 -103
- package/src/strengths.ts +93 -0
- package/src/workflow-settings.ts +95 -1
package/src/strengths.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* strengths.ts — the workflow strength vocabulary.
|
|
3
|
+
*
|
|
4
|
+
* A workflow speaks one word about cost: how much effort a step deserves. It
|
|
5
|
+
* does not speak "tier". A script names a strength, the user's `strengths`
|
|
6
|
+
* table says which Agent tier each strength runs on, and pi-subagents resolves
|
|
7
|
+
* that tier the way it resolves every other one.
|
|
8
|
+
*
|
|
9
|
+
* The vocabulary is closed on purpose. An open one would have no typo to catch
|
|
10
|
+
* — every misspelling would be a legal strength nobody had configured, which
|
|
11
|
+
* dispatches silently instead of failing — and `/workflows strength` could not
|
|
12
|
+
* show a complete list of what there is to configure. Three effort levels are
|
|
13
|
+
* what a workflow can meaningfully distinguish; a fourth would be a model
|
|
14
|
+
* policy wearing a strength's name.
|
|
15
|
+
*
|
|
16
|
+
* Deliberately not derived from the shipped scripts. The set is the contract
|
|
17
|
+
* a script author writes against, so it has to be listed where the author and
|
|
18
|
+
* the settings menu can both read it, not inferred from whichever words the
|
|
19
|
+
* built-ins happen to use this release.
|
|
20
|
+
*
|
|
21
|
+
* There is deliberately no *default* strength either. A call that names none
|
|
22
|
+
* dispatches with no tier at all and takes the agent's ordinary default — its
|
|
23
|
+
* own frontmatter tier, then the host's configured one — exactly as an unmapped
|
|
24
|
+
* strength does, and exactly as every non-workflow Agent spawn does. Defaulting
|
|
25
|
+
* to `medium` was tried and is wrong: a tier a call requests outranks the agent
|
|
26
|
+
* type's own (`selectAgentTier` in pi-subagents: `call` before `frontmatter`),
|
|
27
|
+
* so on a stock install — where the shipped table maps `medium -> medium` —
|
|
28
|
+
* every unlabelled dispatch would have pinned `medium` over whatever the agent
|
|
29
|
+
* declared. `Explore` ships with `tier: low` precisely so read-only search stays
|
|
30
|
+
* cheap; a default strength would have silently overruled it, and re-priced in
|
|
31
|
+
* the wrong direction the one agent this indirection exists to leave alone.
|
|
32
|
+
* Nothing is lost: a script that named no strength stated no opinion, and the
|
|
33
|
+
* host's ordinary precedence is the right answer to that. What the table must
|
|
34
|
+
* reach is every call a script did label, and the shipped scripts label all of
|
|
35
|
+
* theirs.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** Every strength a script may name, cheapest first. */
|
|
39
|
+
export const WORKFLOW_STRENGTHS = ["low", "medium", "high"] as const;
|
|
40
|
+
|
|
41
|
+
export type WorkflowStrength = (typeof WORKFLOW_STRENGTHS)[number];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A strength -> Agent tier table.
|
|
45
|
+
*
|
|
46
|
+
* Keyed on the closed vocabulary rather than on `string`, because every table
|
|
47
|
+
* that reaches a run has already passed {@link isWorkflowStrength} on each key:
|
|
48
|
+
* the settings layer drops the rest at read, and `runWorkflow` drops them again
|
|
49
|
+
* for callers that reach it through JS without types. Partial because an
|
|
50
|
+
* omitted strength is a real state — unmapped, taking the agent's own default —
|
|
51
|
+
* and not a hole.
|
|
52
|
+
*/
|
|
53
|
+
export type WorkflowStrengthTable = Readonly<Partial<Record<WorkflowStrength, string>>>;
|
|
54
|
+
|
|
55
|
+
export function isWorkflowStrength(value: unknown): value is WorkflowStrength {
|
|
56
|
+
return typeof value === "string" && (WORKFLOW_STRENGTHS as readonly string[]).includes(value);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The vocabulary as a bare list, for error messages and menus. */
|
|
60
|
+
export const WORKFLOW_STRENGTH_LIST = WORKFLOW_STRENGTHS.join(", ");
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The table a host uses before anyone configures one: each strength on the
|
|
64
|
+
* catalogue tier that shares its name, and only where the host defines one.
|
|
65
|
+
*
|
|
66
|
+
* Shipped as a *table*, not as a resolution rule, and that distinction is the
|
|
67
|
+
* whole design. A rule saying "an unmapped strength falls back to the tier of
|
|
68
|
+
* the same name" would be unremovable — the way the old passthrough was — so a
|
|
69
|
+
* fan-out could never be re-priced without dragging every ordinary spawn that
|
|
70
|
+
* names the same tier along with it. A default table is just the first entry in
|
|
71
|
+
* the same slot the user writes, so replacing it is one command, and the user's
|
|
72
|
+
* table still remains the only thing that binds a strength to a tier.
|
|
73
|
+
*
|
|
74
|
+
* Computed against the live catalogue rather than hardcoded, because a shipped
|
|
75
|
+
* constant would be an assertion about someone else's tier names — exactly the
|
|
76
|
+
* claim the retired `shippedScript` flag existed to walk back. On a host whose
|
|
77
|
+
* tiers are called `cheap`/`deep` this yields nothing, every strength is
|
|
78
|
+
* unmapped, and no run start complains about configuration the user never
|
|
79
|
+
* wrote. On a stock install it yields the identity table, so the shipped
|
|
80
|
+
* scripts' `low`/`medium` distinctions mean something out of the box instead of
|
|
81
|
+
* collapsing onto the managed default — which is the more expensive rung.
|
|
82
|
+
*
|
|
83
|
+
* An unknown catalogue yields nothing for the same reason: assert only what can
|
|
84
|
+
* be checked.
|
|
85
|
+
*/
|
|
86
|
+
export function defaultStrengthTable(knownTiers: ReadonlySet<string> | undefined): WorkflowStrengthTable {
|
|
87
|
+
const table: Partial<Record<WorkflowStrength, string>> = {};
|
|
88
|
+
if (!knownTiers) return table;
|
|
89
|
+
for (const strength of WORKFLOW_STRENGTHS) {
|
|
90
|
+
if (knownTiers.has(strength)) table[strength] = strength;
|
|
91
|
+
}
|
|
92
|
+
return table;
|
|
93
|
+
}
|
package/src/workflow-settings.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
/** Small, package-owned workflow presentation settings. */
|
|
1
|
+
/** Small, package-owned workflow presentation and routing settings. */
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { basename, join, resolve } from "node:path";
|
|
5
5
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { isManagedAgentTier } from "@signalridge/pi-subagents-protocol";
|
|
7
|
+
import { isWorkflowStrength, type WorkflowStrength, type WorkflowStrengthTable } from "./strengths.js";
|
|
6
8
|
|
|
7
9
|
export type WorkflowProgressMode = "compact" | "detailed";
|
|
8
10
|
|
|
@@ -12,6 +14,80 @@ export interface WorkflowSettings {
|
|
|
12
14
|
effort?: "off" | "high" | "ultra";
|
|
13
15
|
keywordTriggerWord?: string;
|
|
14
16
|
keywordTriggerEnabled?: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Which Agent tier each workflow strength runs on: all of workflow routing.
|
|
19
|
+
*
|
|
20
|
+
* A script names a strength — one of `low`/`medium`/`high`, this package's
|
|
21
|
+
* own word for how much effort a step deserves — and this table is the only
|
|
22
|
+
* thing that binds one to an Agent tier. A strength it does not define
|
|
23
|
+
* dispatches with no tier at all and takes the agent's ordinary default.
|
|
24
|
+
*
|
|
25
|
+
* Absent entirely, the run uses `defaultStrengthTable()`: each strength on
|
|
26
|
+
* the catalogue tier of the same name, wherever this host defines one. That
|
|
27
|
+
* default is a table and not a fallback rule, so writing one here replaces it
|
|
28
|
+
* outright and a strength this file omits stays unmapped even on a host that
|
|
29
|
+
* defines a tier of the same name. Which is what keeps a 26-agent fan-out
|
|
30
|
+
* re-priceable without dragging the Explore agent and everything else that
|
|
31
|
+
* names the same tier: point the strength elsewhere, do not edit the tier.
|
|
32
|
+
*
|
|
33
|
+
* This is not the retired `workflow.tiers` key and must not become it. That
|
|
34
|
+
* one carried its own model and thinking values, which made it a second model
|
|
35
|
+
* policy resolved by a second resolver. Every value here is a *key in the
|
|
36
|
+
* host's one catalogue* and nothing else: pi-subagents still owns every model,
|
|
37
|
+
* every thinking level, and the only `resolveAgentTier()`. What this table
|
|
38
|
+
* decides is which key a workflow asks for, and pi-subagents cannot tell that
|
|
39
|
+
* choice apart from a script that named the key itself. No entry here may ever
|
|
40
|
+
* grow a `model` or `thinking` field — that is the line between this and the
|
|
41
|
+
* key that was deleted, and the name no longer states it, so this does.
|
|
42
|
+
*
|
|
43
|
+
* Any key the host defines is a legal value. There is no shortlist: the target
|
|
44
|
+
* is checked against the live catalogue at run start, so a profile added to
|
|
45
|
+
* subagents.json is usable here the moment it exists.
|
|
46
|
+
*
|
|
47
|
+
* A project that defines this table replaces the global one outright rather
|
|
48
|
+
* than merging into it, so a project can shorten the table and not only
|
|
49
|
+
* extend it; a project that omits it inherits the global table unchanged.
|
|
50
|
+
*/
|
|
51
|
+
strengths?: WorkflowStrengthTable;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Keep the entries a run could actually honor: a known strength, a usable key.
|
|
56
|
+
*
|
|
57
|
+
* The key side is the closed vocabulary, not a shape test. A word outside it
|
|
58
|
+
* can never match what a script asks for, so storing one would put a line in
|
|
59
|
+
* the file that looks like policy and can never fire. The value side is only a
|
|
60
|
+
* shape test, because the catalogue belongs to the host and may gain the tier
|
|
61
|
+
* later — the live check happens at run start.
|
|
62
|
+
*
|
|
63
|
+
* A strength mapped to its own spelling is kept, unlike every earlier revision
|
|
64
|
+
* of this function. It stopped being a no-op when the passthrough went away:
|
|
65
|
+
* `low → low` is the only way a written table can say "run this on the
|
|
66
|
+
* catalogue tier that happens to share the name" — the shipped default says it
|
|
67
|
+
* for a file that is absent, not for one that is present and omits the entry —
|
|
68
|
+
* so dropping it as redundant would silently mean the opposite.
|
|
69
|
+
*
|
|
70
|
+
* Malformed entries are dropped rather than tombstoned, which is what every
|
|
71
|
+
* other key in this file does — the catalogue that owns tombstones is the one
|
|
72
|
+
* in pi-subagents, and inventing a second tombstone vocabulary for this table
|
|
73
|
+
* would be a heavier promise than the table is worth.
|
|
74
|
+
*/
|
|
75
|
+
function sanitizeStrengths(raw: unknown): WorkflowStrengthTable | undefined {
|
|
76
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
|
77
|
+
// An empty table is kept, because it is a statement: "map nothing; every
|
|
78
|
+
// strength takes the agent's default". It became sayable — and worth saying —
|
|
79
|
+
// once an absent key started meaning the shipped default instead of that. A
|
|
80
|
+
// table whose every entry was dropped is not the same statement; it is a
|
|
81
|
+
// broken file, and storing it as an explicit "map nothing" would promote a
|
|
82
|
+
// typo to a policy, so that one still yields undefined below.
|
|
83
|
+
if (Object.keys(raw).length === 0) return {};
|
|
84
|
+
const strengths: Partial<Record<WorkflowStrength, string>> = {};
|
|
85
|
+
// No entry cap: the vocabulary is closed, so the table cannot outgrow it.
|
|
86
|
+
for (const [strength, tier] of Object.entries(raw as Record<string, unknown>)) {
|
|
87
|
+
if (!isWorkflowStrength(strength) || !isManagedAgentTier(tier)) continue;
|
|
88
|
+
strengths[strength] = tier;
|
|
89
|
+
}
|
|
90
|
+
return Object.keys(strengths).length > 0 ? strengths : undefined;
|
|
15
91
|
}
|
|
16
92
|
|
|
17
93
|
const DEFAULT_SETTINGS: Required<
|
|
@@ -35,6 +111,7 @@ function readSettings(path: string): WorkflowSettings {
|
|
|
35
111
|
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
36
112
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
37
113
|
const value = parsed as Record<string, unknown>;
|
|
114
|
+
const strengths = sanitizeStrengths(value.strengths);
|
|
38
115
|
return {
|
|
39
116
|
...(value.progressMode === "compact" || value.progressMode === "detailed"
|
|
40
117
|
? { progressMode: value.progressMode }
|
|
@@ -55,6 +132,7 @@ function readSettings(path: string): WorkflowSettings {
|
|
|
55
132
|
...(typeof value.keywordTriggerEnabled === "boolean"
|
|
56
133
|
? { keywordTriggerEnabled: value.keywordTriggerEnabled }
|
|
57
134
|
: {}),
|
|
135
|
+
...(strengths ? { strengths } : {}),
|
|
58
136
|
};
|
|
59
137
|
} catch {
|
|
60
138
|
return {};
|
|
@@ -69,7 +147,22 @@ export function loadWorkflowSettings(
|
|
|
69
147
|
return { ...DEFAULT_SETTINGS, ...readSettings(globalPath), ...readSettings(projectPath) };
|
|
70
148
|
}
|
|
71
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Only what this project's own file says, with no global or default layered in.
|
|
152
|
+
*
|
|
153
|
+
* What a command has to edit, because {@link saveWorkflowSettings} writes the
|
|
154
|
+
* project file wholesale: handed the merged view, it would copy every global
|
|
155
|
+
* value and every default into the project on the first edit, and the project
|
|
156
|
+
* would stop tracking the global file from then on. That is loud for
|
|
157
|
+
* `strengths`, which a project replaces outright rather than merging.
|
|
158
|
+
*/
|
|
159
|
+
export function loadProjectWorkflowSettings(cwd: string = process.cwd()): WorkflowSettings {
|
|
160
|
+
return readSettings(join(projectDirectory(cwd), "settings.json"));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Replace this project's settings file. Pass project scope, never the merged view. */
|
|
72
164
|
export function saveWorkflowSettings(settings: WorkflowSettings, cwd: string = process.cwd()): void {
|
|
165
|
+
const savedStrengths = sanitizeStrengths(settings.strengths);
|
|
73
166
|
const safe: WorkflowSettings = {
|
|
74
167
|
...(settings.progressMode === "compact" || settings.progressMode === "detailed"
|
|
75
168
|
? { progressMode: settings.progressMode }
|
|
@@ -90,6 +183,7 @@ export function saveWorkflowSettings(settings: WorkflowSettings, cwd: string = p
|
|
|
90
183
|
...(typeof settings.keywordTriggerEnabled === "boolean"
|
|
91
184
|
? { keywordTriggerEnabled: settings.keywordTriggerEnabled }
|
|
92
185
|
: {}),
|
|
186
|
+
...(savedStrengths ? { strengths: savedStrengths } : {}),
|
|
93
187
|
};
|
|
94
188
|
const directory = projectDirectory(cwd);
|
|
95
189
|
mkdirSync(directory, { recursive: true });
|