@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.
Files changed (29) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/README.md +207 -48
  3. package/package.json +2 -2
  4. package/skills/workflow-authoring/SKILL.md +44 -14
  5. package/skills/workflow-authoring/examples/defensive-json-parsing.js +1 -1
  6. package/skills/workflow-authoring/examples/phased-budgets.js +1 -1
  7. package/skills/workflow-authoring/examples/saved-nested-workflows.js +1 -1
  8. package/skills/workflow-authoring/examples/structured-output.js +1 -1
  9. package/skills/workflow-authoring/references/capabilities.md +7 -7
  10. package/skills/workflow-authoring/references/capability-details.md +24 -10
  11. package/skills/workflow-authoring/references/debugging.md +1 -1
  12. package/skills/workflow-authoring/references/lifecycle.md +5 -1
  13. package/skills/workflow-authoring/references/quality-helpers.md +2 -2
  14. package/skills/workflow-authoring/references/registry-ownership.md +4 -4
  15. package/skills/workflow-authoring/references/review.md +8 -4
  16. package/skills/workflow-authoring/references/runtime.md +2 -2
  17. package/skills/workflow-authoring/references/specialized-helpers.md +1 -1
  18. package/skills/workflow-authoring/references/versions.md +1 -1
  19. package/skills/workflow-patterns/SKILL.md +1 -1
  20. package/skills/workflow-review/SKILL.md +6 -4
  21. package/src/builtins.ts +23 -23
  22. package/src/capabilities.ts +37 -12
  23. package/src/engine.ts +70 -26
  24. package/src/index.ts +243 -70
  25. package/src/journal.ts +0 -14
  26. package/src/rpc-client.ts +14 -1
  27. package/src/runtime.ts +452 -103
  28. package/src/strengths.ts +93 -0
  29. package/src/workflow-settings.ts +95 -1
@@ -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
+ }
@@ -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 });