@yycholla/pi-dynamic-workflows 3.3.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 (179) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +325 -0
  3. package/assets/readme/hero.png +0 -0
  4. package/assets/readme/hero.svg +70 -0
  5. package/assets/readme/package-cover.png +0 -0
  6. package/assets/readme/package-cover.svg +66 -0
  7. package/assets/readme/workflow.png +0 -0
  8. package/assets/readme/workflow.svg +77 -0
  9. package/dist/accept-workflow-guidance.d.ts +14 -0
  10. package/dist/accept-workflow-guidance.js +53 -0
  11. package/dist/adversarial-review.d.ts +30 -0
  12. package/dist/adversarial-review.js +107 -0
  13. package/dist/agent-history.d.ts +20 -0
  14. package/dist/agent-history.js +122 -0
  15. package/dist/agent-registry.d.ts +83 -0
  16. package/dist/agent-registry.js +190 -0
  17. package/dist/agent.d.ts +364 -0
  18. package/dist/agent.js +714 -0
  19. package/dist/builtin-commands.d.ts +19 -0
  20. package/dist/builtin-commands.js +251 -0
  21. package/dist/builtin-workflows.d.ts +45 -0
  22. package/dist/builtin-workflows.js +121 -0
  23. package/dist/code-review.d.ts +26 -0
  24. package/dist/code-review.js +181 -0
  25. package/dist/config.d.ts +37 -0
  26. package/dist/config.js +44 -0
  27. package/dist/deep-research.d.ts +30 -0
  28. package/dist/deep-research.js +124 -0
  29. package/dist/display.d.ts +134 -0
  30. package/dist/display.js +248 -0
  31. package/dist/effort-command.d.ts +28 -0
  32. package/dist/effort-command.js +68 -0
  33. package/dist/enums.d.ts +69 -0
  34. package/dist/enums.js +78 -0
  35. package/dist/errors.d.ts +113 -0
  36. package/dist/errors.js +140 -0
  37. package/dist/extension-reload.d.ts +37 -0
  38. package/dist/extension-reload.js +78 -0
  39. package/dist/fs-persistence.d.ts +63 -0
  40. package/dist/fs-persistence.js +102 -0
  41. package/dist/index.d.ts +57 -0
  42. package/dist/index.js +35 -0
  43. package/dist/logger.d.ts +21 -0
  44. package/dist/logger.js +66 -0
  45. package/dist/model-routing.d.ts +30 -0
  46. package/dist/model-routing.js +50 -0
  47. package/dist/model-spec.d.ts +29 -0
  48. package/dist/model-spec.js +252 -0
  49. package/dist/model-tier-config.d.ts +133 -0
  50. package/dist/model-tier-config.js +249 -0
  51. package/dist/run-persistence.d.ts +180 -0
  52. package/dist/run-persistence.js +294 -0
  53. package/dist/saved-commands.d.ts +28 -0
  54. package/dist/saved-commands.js +100 -0
  55. package/dist/shared-store.d.ts +98 -0
  56. package/dist/shared-store.js +212 -0
  57. package/dist/structured-output.d.ts +19 -0
  58. package/dist/structured-output.js +30 -0
  59. package/dist/task-panel.d.ts +61 -0
  60. package/dist/task-panel.js +422 -0
  61. package/dist/usage-limit-scheduler.d.ts +145 -0
  62. package/dist/usage-limit-scheduler.js +368 -0
  63. package/dist/web-tools.d.ts +20 -0
  64. package/dist/web-tools.js +120 -0
  65. package/dist/workflow-authoring-coverage.d.ts +70 -0
  66. package/dist/workflow-authoring-coverage.js +421 -0
  67. package/dist/workflow-authoring-reference.d.ts +20 -0
  68. package/dist/workflow-authoring-reference.js +156 -0
  69. package/dist/workflow-capability-contract.d.ts +131 -0
  70. package/dist/workflow-capability-contract.js +604 -0
  71. package/dist/workflow-commands.d.ts +18 -0
  72. package/dist/workflow-commands.js +260 -0
  73. package/dist/workflow-comprehension.d.ts +133 -0
  74. package/dist/workflow-comprehension.js +1321 -0
  75. package/dist/workflow-context-measurement.d.ts +72 -0
  76. package/dist/workflow-context-measurement.js +213 -0
  77. package/dist/workflow-control-tool.d.ts +30 -0
  78. package/dist/workflow-control-tool.js +176 -0
  79. package/dist/workflow-delivery-choice.d.ts +20 -0
  80. package/dist/workflow-delivery-choice.js +48 -0
  81. package/dist/workflow-editor.d.ts +93 -0
  82. package/dist/workflow-editor.js +363 -0
  83. package/dist/workflow-manager.d.ts +492 -0
  84. package/dist/workflow-manager.js +1124 -0
  85. package/dist/workflow-paths.d.ts +22 -0
  86. package/dist/workflow-paths.js +46 -0
  87. package/dist/workflow-release-gate.d.ts +39 -0
  88. package/dist/workflow-release-gate.js +309 -0
  89. package/dist/workflow-saved.d.ts +38 -0
  90. package/dist/workflow-saved.js +126 -0
  91. package/dist/workflow-settings.d.ts +70 -0
  92. package/dist/workflow-settings.js +131 -0
  93. package/dist/workflow-tool.d.ts +71 -0
  94. package/dist/workflow-tool.js +367 -0
  95. package/dist/workflow-ui.d.ts +182 -0
  96. package/dist/workflow-ui.js +1587 -0
  97. package/dist/workflow.d.ts +333 -0
  98. package/dist/workflow.js +1151 -0
  99. package/dist/workflows-models-command.d.ts +31 -0
  100. package/dist/workflows-models-command.js +156 -0
  101. package/dist/worktree.d.ts +25 -0
  102. package/dist/worktree.js +61 -0
  103. package/extensions/workflow.ts +151 -0
  104. package/package.json +104 -0
  105. package/skills/workflow-authoring/SKILL.md +30 -0
  106. package/skills/workflow-authoring/examples/adversarial-verification.js +63 -0
  107. package/skills/workflow-authoring/examples/bounded-semantic-retry.js +56 -0
  108. package/skills/workflow-authoring/examples/classify-and-act.js +67 -0
  109. package/skills/workflow-authoring/examples/fan-out-and-synthesize.js +47 -0
  110. package/skills/workflow-authoring/examples/generate-and-filter.js +68 -0
  111. package/skills/workflow-authoring/examples/loop-until-done.js +68 -0
  112. package/skills/workflow-authoring/examples/phased-budgets.js +75 -0
  113. package/skills/workflow-authoring/examples/saved-nested-workflows.js +44 -0
  114. package/skills/workflow-authoring/examples/structured-output.js +37 -0
  115. package/skills/workflow-authoring/examples/tournament.js +81 -0
  116. package/skills/workflow-authoring/examples/validated-gate.js +63 -0
  117. package/skills/workflow-authoring/references/capabilities.md +41 -0
  118. package/skills/workflow-authoring/references/capability-details.md +357 -0
  119. package/skills/workflow-authoring/references/common-helpers.md +4 -0
  120. package/skills/workflow-authoring/references/debugging.md +27 -0
  121. package/skills/workflow-authoring/references/focused-recipes.md +11 -0
  122. package/skills/workflow-authoring/references/helpers.md +9 -0
  123. package/skills/workflow-authoring/references/lifecycle.md +33 -0
  124. package/skills/workflow-authoring/references/pattern-selection.md +15 -0
  125. package/skills/workflow-authoring/references/quality-helpers.md +8 -0
  126. package/skills/workflow-authoring/references/registry-ownership.md +15 -0
  127. package/skills/workflow-authoring/references/retry-helper.md +5 -0
  128. package/skills/workflow-authoring/references/review.md +43 -0
  129. package/skills/workflow-authoring/references/runtime.md +27 -0
  130. package/skills/workflow-authoring/references/specialized-helpers.md +19 -0
  131. package/skills/workflow-authoring/references/versions.md +13 -0
  132. package/skills/workflow-patterns/SKILL.md +51 -0
  133. package/src/accept-workflow-guidance.ts +71 -0
  134. package/src/adversarial-review.ts +120 -0
  135. package/src/agent-history.ts +157 -0
  136. package/src/agent-registry.ts +221 -0
  137. package/src/agent.ts +929 -0
  138. package/src/builtin-commands.ts +286 -0
  139. package/src/builtin-workflows.ts +155 -0
  140. package/src/code-review.ts +183 -0
  141. package/src/config.ts +55 -0
  142. package/src/deep-research.ts +135 -0
  143. package/src/display.ts +367 -0
  144. package/src/effort-command.ts +87 -0
  145. package/src/enums.ts +77 -0
  146. package/src/errors.ts +199 -0
  147. package/src/extension-reload.ts +100 -0
  148. package/src/fs-persistence.ts +124 -0
  149. package/src/index.ts +176 -0
  150. package/src/logger.ts +88 -0
  151. package/src/model-routing.ts +73 -0
  152. package/src/model-spec.ts +309 -0
  153. package/src/model-tier-config.ts +296 -0
  154. package/src/run-persistence.ts +484 -0
  155. package/src/saved-commands.ts +115 -0
  156. package/src/shared-store.ts +228 -0
  157. package/src/structured-output.ts +47 -0
  158. package/src/task-panel.ts +490 -0
  159. package/src/usage-limit-scheduler.ts +432 -0
  160. package/src/web-tools.ts +124 -0
  161. package/src/workflow-authoring-coverage.ts +486 -0
  162. package/src/workflow-authoring-reference.ts +186 -0
  163. package/src/workflow-capability-contract.ts +806 -0
  164. package/src/workflow-commands.ts +287 -0
  165. package/src/workflow-comprehension.ts +1673 -0
  166. package/src/workflow-context-measurement.ts +262 -0
  167. package/src/workflow-control-tool.ts +238 -0
  168. package/src/workflow-delivery-choice.ts +69 -0
  169. package/src/workflow-editor.ts +444 -0
  170. package/src/workflow-manager.ts +1405 -0
  171. package/src/workflow-paths.ts +63 -0
  172. package/src/workflow-release-gate.ts +529 -0
  173. package/src/workflow-saved.ts +180 -0
  174. package/src/workflow-settings.ts +194 -0
  175. package/src/workflow-tool.ts +464 -0
  176. package/src/workflow-ui.ts +1789 -0
  177. package/src/workflow.ts +1615 -0
  178. package/src/workflows-models-command.ts +211 -0
  179. package/src/worktree.ts +76 -0
@@ -0,0 +1,51 @@
1
+ ---
2
+ name: workflow-patterns
3
+ description: Argument shapes for the 5 built-in workflow patterns — deep-research, adversarial-review, code-review, multi-perspective, codebase-audit — runnable via the `workflow` tool's `name` input, without slash-command syntax. Use for requests like "research X", "fact-check/adversarially review this", "review this diff/PR", "analyze from multiple perspectives", or "audit the codebase for Y". Not for authoring a new workflow script — see workflow-authoring.
4
+ metadata:
5
+ version: "3.3.0"
6
+ ---
7
+
8
+ # Built-in workflow patterns
9
+
10
+ pi-dynamic-workflows ships 5 curated, tested workflow patterns. Each is also a
11
+ slash command (`/deep-research`, `/adversarial-review`, `/code-review`,
12
+ `/multi-perspective`, `/codebase-audit`), but they are equally reachable from
13
+ the `workflow` tool directly: call it with `name` set to the pattern name
14
+ below and `args` matching its shape, instead of writing an equivalent script
15
+ from scratch. Prefer this over authoring a new script whenever the request
16
+ fits one of these shapes — the curated version is already reviewed and tested.
17
+
18
+ A project or user saved workflow of the same name always takes precedence
19
+ over a built-in of that name — on the slash command, too.
20
+
21
+ These 5 names are reachable only at the `workflow` tool's top-level `name`
22
+ input, not via the in-script `await workflow(savedName, childArgs)` helper —
23
+ that helper resolves saved workflows only. Calling `workflow('deep-research')`
24
+ from inside a script fails as an unknown saved workflow; use the top-level
25
+ `name` input instead.
26
+
27
+ ## Patterns
28
+
29
+ | `name` | When to reach for it | `args` |
30
+ | --- | --- | --- |
31
+ | `deep-research` | Research a question across the web with cross-checked sources | `{ question: string, angles?: number, minSupport?: number }` — `angles` (default 4) is the number of distinct search queries; `minSupport` (default 2) is the minimum distinct sources required for a claim to survive cross-checking |
32
+ | `adversarial-review` | Investigate a task/claim, then cross-check each finding with skeptical reviewers | `{ task: string, reviewers?: number, threshold?: number }` |
33
+ | `code-review` | Multi-angle review of a diff (correctness, reuse, simplification, efficiency, altitude) | `{ diff: string, diffSource?: string }` — get `diff` yourself first (e.g. `git diff`, `gh pr diff <n>`); this path does not fetch it for you |
34
+ | `multi-perspective` | Analyze a topic from several independent perspectives in parallel, then synthesize | `{ topic: string, perspectives?: string[] }` — omit or give fewer than 2 to use the default set (technical, product, security, user experience, maintainability) |
35
+ | `codebase-audit` | Run parallel checks against a codebase scope, then cross-validate and report | `{ scope: string, checks: string[] }` |
36
+
37
+ ## Example
38
+
39
+ ```json
40
+ { "name": "deep-research", "args": { "question": "What are the tradeoffs of X vs Y?" } }
41
+ ```
42
+
43
+ This is a `workflow` tool call, not a script — omit `script` entirely. The run
44
+ starts in the background exactly like the slash-command form; `background`,
45
+ `maxAgents`, `concurrency`, `agentRetries`, `agentTimeoutMs`, and `tokenBudget`
46
+ all still apply.
47
+
48
+ ## Writing a new workflow instead
49
+
50
+ If the request doesn't fit one of these 5 shapes, author a script with
51
+ `script` as usual — see the workflow-authoring skill.
@@ -0,0 +1,71 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { WORKFLOW_AUTHORING_FROZEN_FILES } from "./workflow-authoring-coverage.js";
5
+
6
+ const COVERAGE_MANIFEST_PATH = "src/workflow-authoring-coverage.ts";
7
+
8
+ /** A reviewed frozen-guidance hash transition recorded in the coverage manifest. */
9
+ export interface WorkflowGuidanceAcceptance {
10
+ path: string;
11
+ previousSha256: string;
12
+ sha256: string;
13
+ changed: boolean;
14
+ }
15
+
16
+ function sha256(source: string): string {
17
+ return createHash("sha256").update(source).digest("hex");
18
+ }
19
+
20
+ function manifestEntry(path: string, hash: string): string {
21
+ return ` path: ${JSON.stringify(path)},\n sha256: ${JSON.stringify(hash)},`;
22
+ }
23
+
24
+ /**
25
+ * Accepts reviewed changes to explicitly named frozen workflow-authoring files.
26
+ *
27
+ * Throws before writing when no path is supplied, a path is not frozen, a file
28
+ * is missing, or the coverage manifest no longer contains the expected entry.
29
+ */
30
+ export function acceptWorkflowGuidance(root: string, requestedPaths: readonly string[]): WorkflowGuidanceAcceptance[] {
31
+ if (requestedPaths.length === 0) {
32
+ throw new Error("Pass at least one frozen workflow-authoring path to accept.");
33
+ }
34
+
35
+ const frozenByPath: ReadonlyMap<string, (typeof WORKFLOW_AUTHORING_FROZEN_FILES)[number]> = new Map(
36
+ WORKFLOW_AUTHORING_FROZEN_FILES.map((entry) => [entry.path, entry]),
37
+ );
38
+ const paths = [...new Set(requestedPaths.map((path) => path.replace(/^\.\//, "")))];
39
+ const entries = paths.map((path) => {
40
+ const frozen = frozenByPath.get(path);
41
+ if (frozen === undefined) {
42
+ throw new Error(`${path} is not a frozen workflow-authoring guidance file.`);
43
+ }
44
+ const absolute = join(root, path);
45
+ if (!existsSync(absolute)) {
46
+ throw new Error(`Frozen workflow-authoring guidance file is missing: ${path}.`);
47
+ }
48
+ return {
49
+ path,
50
+ previousSha256: frozen.sha256,
51
+ sha256: sha256(readFileSync(absolute, "utf8")),
52
+ };
53
+ });
54
+
55
+ const manifestPath = join(root, COVERAGE_MANIFEST_PATH);
56
+ const originalManifest = readFileSync(manifestPath, "utf8");
57
+ let nextManifest = originalManifest;
58
+ for (const entry of entries) {
59
+ const previous = manifestEntry(entry.path, entry.previousSha256);
60
+ if (!nextManifest.includes(previous)) {
61
+ throw new Error(`Coverage manifest does not contain the expected frozen entry for ${entry.path}.`);
62
+ }
63
+ nextManifest = nextManifest.replace(previous, manifestEntry(entry.path, entry.sha256));
64
+ }
65
+
66
+ if (nextManifest !== originalManifest) {
67
+ writeFileSync(manifestPath, nextManifest);
68
+ }
69
+
70
+ return entries.map((entry) => ({ ...entry, changed: entry.previousSha256 !== entry.sha256 }));
71
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Adversarial review mode for workflows.
3
+ * Agents cross-check each other's findings for higher quality results.
4
+ */
5
+
6
+ export interface AdversarialReviewConfig {
7
+ /** Number of independent reviewers per finding. */
8
+ reviewerCount: number;
9
+ /** Whether to filter out findings that don't survive cross-checking. */
10
+ filterContested: boolean;
11
+ /** Minimum agreement threshold (0-1). */
12
+ agreementThreshold: number;
13
+ }
14
+
15
+ /**
16
+ * Generate an adversarial-review workflow. The script is static and reads its
17
+ * inputs from `args` (task/reviewers/threshold) — no string interpolation.
18
+ *
19
+ * Each finding is judged independently by N reviewers who are told to REFUTE it;
20
+ * a finding survives only when the share of reviewers calling it real meets the
21
+ * agreement threshold.
22
+ */
23
+ export function generateAdversarialReviewWorkflow(): string {
24
+ return `export const meta = {
25
+ name: 'adversarial_review',
26
+ description: 'Adversarial review: findings cross-checked by independent skeptics',
27
+ phases: [
28
+ { title: 'Investigate' },
29
+ { title: 'Refute' },
30
+ { title: 'Consensus' },
31
+ ],
32
+ }
33
+
34
+ const task = (args && args.task) || ''
35
+ const reviewers = (args && args.reviewers) || 2
36
+ const threshold = (args && args.threshold) || 0.5
37
+
38
+ phase('Investigate')
39
+ const investigation = await agent(
40
+ 'Investigate the following and list concrete, individually-checkable findings:\\n' + task,
41
+ { label: 'investigate', schema: { type: 'object', properties: { findings: { type: 'array', items: { type: 'string' } } }, required: ['findings'] } }
42
+ )
43
+ const findings = investigation.findings || []
44
+
45
+ phase('Refute')
46
+ const judged = await parallel(findings.map((f, i) => () =>
47
+ parallel(Array.from({ length: reviewers }, (_, r) => () =>
48
+ agent(
49
+ 'You are a skeptical reviewer. Try to REFUTE this finding for the task below. ' +
50
+ 'Default to real=false when uncertain. Investigate with the available tools if needed.\\n\\n' +
51
+ 'TASK: ' + task + '\\nFINDING: ' + f,
52
+ { label: 'refute ' + (i + 1) + '.' + (r + 1), schema: { type: 'object', properties: { real: { type: 'boolean' }, reason: { type: 'string' } }, required: ['real'] } }
53
+ )
54
+ )).then((votes) => {
55
+ const valid = votes.filter(Boolean)
56
+ const realCount = valid.filter((v) => v && v.real).length
57
+ const ratio = valid.length ? realCount / valid.length : 0
58
+ return { finding: f, realVotes: realCount, totalVotes: valid.length, survives: ratio >= threshold }
59
+ })
60
+ ))
61
+
62
+ const survivors = judged.filter((j) => j && j.survives)
63
+
64
+ phase('Consensus')
65
+ const report = await agent(
66
+ 'Write a final review report. Include ONLY the findings that survived adversarial review (listed below), ' +
67
+ 'each with a short justification. Note how many were discarded.\\n\\n' +
68
+ 'SURVIVING FINDINGS JSON:\\n' + JSON.stringify(survivors),
69
+ { label: 'consensus' }
70
+ )
71
+
72
+ return { total: findings.length, survivors, report }`;
73
+ }
74
+
75
+ /**
76
+ * Generate a multi-perspective analysis workflow.
77
+ *
78
+ * `topic` and each `perspectives` entry are user-supplied strings baked
79
+ * directly into the generated script's source, so every one is embedded via
80
+ * JSON.stringify — a proper JS string literal that can't be broken out of by
81
+ * a quote, backslash, or backtick in the value.
82
+ */
83
+ export function generateMultiPerspectiveWorkflow(topic: string, perspectives: string[]): string {
84
+ const perspectiveAgents = perspectives
85
+ .map((p, i) => {
86
+ const label =
87
+ p
88
+ .toLowerCase()
89
+ .replace(/[^a-z0-9]+/g, "-")
90
+ .replace(/^-+|-+$/g, "")
91
+ .slice(0, 20) || `perspective-${i + 1}`;
92
+ return ` () => agent(${JSON.stringify(`Analyze from ${p} perspective: `)} + topic, { label: ${JSON.stringify(label)} }),`;
93
+ })
94
+ .join("\n");
95
+
96
+ return `export const meta = {
97
+ name: 'multi_perspective_analysis',
98
+ description: ${JSON.stringify(`Analyze from ${perspectives.length} different perspectives`)},
99
+ phases: [
100
+ { title: 'Perspective Analysis' },
101
+ { title: 'Synthesis' },
102
+ ],
103
+ };
104
+
105
+ phase('Perspective Analysis');
106
+ const topic = ${JSON.stringify(topic)};
107
+ const analyses = await parallel([
108
+ ${perspectiveAgents}
109
+ ]);
110
+
111
+ phase('Synthesis');
112
+ const synthesis = await agent(
113
+ 'Synthesize these different perspectives into a balanced analysis:\\n' +
114
+ 'Analyses: ' + JSON.stringify(analyses) + '\\n' +
115
+ 'Topic: ' + topic,
116
+ { label: 'synthesizer' }
117
+ );
118
+
119
+ return { analyses, synthesis };`;
120
+ }
@@ -0,0 +1,157 @@
1
+ export type AgentHistoryRole = "user" | "assistant" | "tool";
2
+
3
+ export type AgentHistoryKind = "text" | "toolCall" | "toolResult" | "error";
4
+
5
+ export interface AgentHistoryEntry {
6
+ role: AgentHistoryRole;
7
+ kind: AgentHistoryKind;
8
+ text: string;
9
+ toolName?: string;
10
+ /** Source path for file-oriented tool calls rendered specially by the pager. */
11
+ path?: string;
12
+ /** Pi's display-oriented edit diff, preserved from EditToolDetails. */
13
+ diff?: string;
14
+ isError?: boolean;
15
+ timestamp?: number;
16
+ }
17
+
18
+ export interface AgentHistoryOptions {
19
+ maxEntries?: number;
20
+ maxTextChars?: number;
21
+ maxTotalChars?: number;
22
+ }
23
+
24
+ const DEFAULT_MAX_ENTRIES = 40;
25
+ const DEFAULT_MAX_TEXT_CHARS = 2000;
26
+ const DEFAULT_MAX_TOTAL_CHARS = 20000;
27
+
28
+ export function compactAgentHistory(messages: unknown[], options: AgentHistoryOptions = {}): AgentHistoryEntry[] {
29
+ const maxEntries = positiveInt(options.maxEntries, DEFAULT_MAX_ENTRIES);
30
+ const maxTextChars = positiveInt(options.maxTextChars, DEFAULT_MAX_TEXT_CHARS);
31
+ const maxTotalChars = positiveInt(options.maxTotalChars, DEFAULT_MAX_TOTAL_CHARS);
32
+ const entries: AgentHistoryEntry[] = [];
33
+
34
+ for (const raw of messages) {
35
+ const message = asRecord(raw);
36
+ if (!message) continue;
37
+ const role = message.role;
38
+ const timestamp = typeof message.timestamp === "number" ? message.timestamp : undefined;
39
+
40
+ if (role === "user") {
41
+ const text = textFromContent(message.content);
42
+ if (text.trim()) entries.push({ role: "user", kind: "text", text, timestamp });
43
+ continue;
44
+ }
45
+
46
+ if (role === "assistant") {
47
+ for (const part of Array.isArray(message.content) ? message.content : []) {
48
+ const block = asRecord(part);
49
+ if (!block || typeof block.type !== "string") continue;
50
+ if (block.type === "text" && typeof block.text === "string" && block.text.trim()) {
51
+ entries.push({ role: "assistant", kind: "text", text: block.text, timestamp });
52
+ } else if (block.type === "toolCall" && typeof block.name === "string") {
53
+ const args = asRecord(block.arguments);
54
+ const filePath =
55
+ (block.name === "write" || block.name === "edit") && typeof args?.path === "string" ? args.path : undefined;
56
+ const writeContent =
57
+ block.name === "write" && filePath && typeof args?.content === "string" ? args.content : undefined;
58
+ entries.push({
59
+ role: "assistant",
60
+ kind: "toolCall",
61
+ toolName: block.name,
62
+ // A write's JSON envelope is both noisy and likely to be truncated
63
+ // into invalid JSON. Preserve its source directly so the pager can
64
+ // render it as code. Edit calls retain their path so the pager can
65
+ // pair the compact call header with the result's native Pi diff.
66
+ text: writeContent ?? stringifyCompact(block.arguments ?? {}),
67
+ path: filePath,
68
+ timestamp,
69
+ });
70
+ }
71
+ }
72
+ if (typeof message.errorMessage === "string" && message.errorMessage.trim()) {
73
+ entries.push({ role: "assistant", kind: "error", text: message.errorMessage, isError: true, timestamp });
74
+ }
75
+ continue;
76
+ }
77
+
78
+ if (role === "toolResult") {
79
+ const toolName = typeof message.toolName === "string" ? message.toolName : undefined;
80
+ const text = textFromContent(message.content) || "(no text output)";
81
+ const details = asRecord(message.details);
82
+ const diff = toolName === "edit" && typeof details?.diff === "string" ? details.diff : undefined;
83
+ entries.push({
84
+ role: "tool",
85
+ kind: message.isError ? "error" : "toolResult",
86
+ toolName,
87
+ text,
88
+ diff,
89
+ isError: Boolean(message.isError),
90
+ timestamp,
91
+ });
92
+ }
93
+ }
94
+
95
+ return fitEntries(entries, maxEntries, maxTextChars, maxTotalChars);
96
+ }
97
+
98
+ function fitEntries(
99
+ entries: AgentHistoryEntry[],
100
+ maxEntries: number,
101
+ maxTextChars: number,
102
+ maxTotalChars: number,
103
+ ): AgentHistoryEntry[] {
104
+ const fitted: AgentHistoryEntry[] = [];
105
+ let total = 0;
106
+
107
+ for (const entry of entries.slice(-maxEntries).reverse()) {
108
+ const remaining = maxTotalChars - total;
109
+ if (remaining <= 0) break;
110
+
111
+ // Treat an edit diff as the entry's primary display payload. Keeping it
112
+ // within the same per-entry and total bounds prevents EditToolDetails from
113
+ // bypassing history compaction with a large changed file.
114
+ let entryBudget = Math.min(maxTextChars, remaining);
115
+ const diff = entry.diff ? truncateText(entry.diff, entryBudget) : undefined;
116
+ entryBudget -= diff?.length ?? 0;
117
+ const text = truncateText(entry.text, entryBudget);
118
+ fitted.unshift({ ...entry, text, diff });
119
+ total += text.length + (diff?.length ?? 0);
120
+ }
121
+
122
+ return fitted;
123
+ }
124
+
125
+ function textFromContent(content: unknown): string {
126
+ if (typeof content === "string") return content;
127
+ if (!Array.isArray(content)) return "";
128
+ return content
129
+ .map((part) => {
130
+ const block = asRecord(part);
131
+ return block?.type === "text" && typeof block.text === "string" ? block.text : "";
132
+ })
133
+ .filter(Boolean)
134
+ .join("");
135
+ }
136
+
137
+ function stringifyCompact(value: unknown): string {
138
+ try {
139
+ return JSON.stringify(value);
140
+ } catch {
141
+ return String(value);
142
+ }
143
+ }
144
+
145
+ function truncateText(text: string, maxChars: number): string {
146
+ if (text.length <= maxChars) return text;
147
+ if (maxChars <= 20) return text.slice(0, maxChars);
148
+ return `${text.slice(0, maxChars - 20)}... [truncated]`;
149
+ }
150
+
151
+ function positiveInt(value: number | undefined, fallback: number): number {
152
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
153
+ }
154
+
155
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
156
+ return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
157
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Named workflow subagent definitions ("agentType" registry).
3
+ *
4
+ * A workflow script can route an agent() call to a reusable, named definition:
5
+ *
6
+ * agent("audit this dir", { agentType: "security-auditor" })
7
+ *
8
+ * Definitions live as Markdown files under `.pi/agents/*.md` (project, cwd-relative)
9
+ * and `~/.pi/agent/agents/*.md` (user — `getAgentDir() + "agents"`, honoring the
10
+ * `PI_CODING_AGENT_DIR` override), matching pi-coding-agent's own built-in agent
11
+ * discovery convention. The legacy `~/.pi/agents/*.md` location is still scanned as
12
+ * a deprecated fallback (with a one-time warning) so users who followed this repo's
13
+ * earlier docs are not silently broken; the new location wins on a name collision.
14
+ * Frontmatter binds the subagent's tools, model, and a body prompt; project
15
+ * definitions win over both user-level locations on a name collision. This mirrors
16
+ * Claude Code's `.claude/agents` registry: agentType is a real binding of
17
+ * tools+model+system-prompt, not a prose hint.
18
+ *
19
+ * Bound today: `tools` (allowlist), `disallowedTools` (denylist), `model`,
20
+ * and the markdown body (`prompt`). Parsed-but-ignored for now (documented): `mcp`, `skills`, `background`.
21
+ * Wired: `isolation` ("worktree") → createWorktree() in workflow.ts.
22
+ */
23
+
24
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
25
+ import { homedir } from "node:os";
26
+ import { basename, join } from "node:path";
27
+ import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
28
+ import { AGENTS_DIR } from "./config.js";
29
+
30
+ export interface AgentDefinition {
31
+ /** Stable identity used as the `agentType` value. */
32
+ name: string;
33
+ /** One-line summary (for discoverability in the tool guideline). */
34
+ description?: string;
35
+ /** Allowlist of coding-tool names the subagent may use. Undefined = all. */
36
+ tools?: string[];
37
+ /** Denylist of coding-tool names, applied after the allowlist. */
38
+ disallowedTools?: string[];
39
+ /** Model spec (`provider/modelId` or bare id) for this subagent. */
40
+ model?: string;
41
+ /** Isolation mode. When "worktree", agents using this type run in a git worktree. */
42
+ isolation?: "worktree";
43
+ /** Markdown body, prepended to the subagent's task as role guidance. */
44
+ prompt: string;
45
+ /** Where the definition was loaded from (project wins over user). */
46
+ source: "project" | "user";
47
+ }
48
+
49
+ export type AgentRegistry = Map<string, AgentDefinition>;
50
+
51
+ function toStringArray(value: unknown): string[] | undefined {
52
+ if (value == null) return undefined;
53
+ // YAML list form: ["read", "grep"]
54
+ if (Array.isArray(value)) {
55
+ const arr = value.filter((v): v is string => typeof v === "string" && v.trim().length > 0).map((v) => v.trim());
56
+ return arr.length ? arr : undefined;
57
+ }
58
+ // Comma-separated string form: "read, grep, find" — the form pi-coding-agent's
59
+ // parseFrontmatter returns and the form the official subagent example uses.
60
+ if (typeof value === "string" && value.trim().length > 0) {
61
+ const arr = value
62
+ .split(",")
63
+ .map((s) => s.trim())
64
+ .filter(Boolean);
65
+ return arr.length ? arr : undefined;
66
+ }
67
+ return undefined;
68
+ }
69
+
70
+ /**
71
+ * Parse one agent-definition markdown file. Returns null only when there is no
72
+ * usable content (no name derivable and an empty body).
73
+ */
74
+ export function parseAgentDefinition(
75
+ content: string,
76
+ source: "project" | "user",
77
+ fileName: string,
78
+ ): AgentDefinition | null {
79
+ let parsed: { frontmatter: Record<string, unknown>; body: string };
80
+ try {
81
+ parsed = parseFrontmatter(content);
82
+ } catch {
83
+ // Malformed frontmatter: treat the whole file as a body, name from filename.
84
+ parsed = { frontmatter: {}, body: content };
85
+ }
86
+ const fm = parsed.frontmatter;
87
+ const fmName = typeof fm.name === "string" ? fm.name.trim() : "";
88
+ const name = fmName || basename(fileName).replace(/\.md$/i, "").trim();
89
+ const prompt = parsed.body.trim();
90
+ if (!name && !prompt) return null;
91
+
92
+ return {
93
+ name,
94
+ description: typeof fm.description === "string" ? fm.description.trim() || undefined : undefined,
95
+ tools: toStringArray(fm.tools),
96
+ disallowedTools: toStringArray(fm.disallowedTools),
97
+ model: typeof fm.model === "string" ? fm.model.trim() || undefined : undefined,
98
+ isolation:
99
+ typeof fm.isolation === "string" && fm.isolation.toLowerCase().trim() === "worktree" ? "worktree" : undefined,
100
+ prompt,
101
+ source,
102
+ };
103
+ }
104
+
105
+ function readDefsFromDir(dir: string, source: "project" | "user"): AgentDefinition[] {
106
+ if (!existsSync(dir)) return [];
107
+ let files: string[];
108
+ try {
109
+ files = readdirSync(dir).filter((f) => f.toLowerCase().endsWith(".md"));
110
+ } catch {
111
+ return [];
112
+ }
113
+ const defs: AgentDefinition[] = [];
114
+ for (const file of files.sort()) {
115
+ try {
116
+ const def = parseAgentDefinition(readFileSync(join(dir, file), "utf-8"), source, file);
117
+ if (def) defs.push(def);
118
+ } catch {
119
+ // Skip unreadable/invalid files; never let one bad file break the registry.
120
+ }
121
+ }
122
+ return defs;
123
+ }
124
+
125
+ /**
126
+ * Load the agent registry once for a run. Scans the project dir, then the
127
+ * user dir, then — as a deprecated fallback — the legacy user dir; the FIRST
128
+ * definition for a name wins (project > user > legacy user, then filename
129
+ * order), so a name collision is resolved deterministically and silently.
130
+ *
131
+ * When a definition is only found at the legacy location (not shadowed by
132
+ * the new user dir), a single deprecation warning is logged for this call
133
+ * telling the user to move their files — not one warning per legacy file.
134
+ *
135
+ * `opts` overrides the scanned directories (used by tests).
136
+ */
137
+ export function loadAgentRegistry(
138
+ cwd: string,
139
+ opts?: { projectDir?: string; userDir?: string; legacyUserDir?: string },
140
+ ): AgentRegistry {
141
+ const projectDir = opts?.projectDir ?? join(cwd, AGENTS_DIR);
142
+ // User-level definitions live under the agent dir (e.g. ~/.pi/agent/agents/),
143
+ // matching the convention used by pi-coding-agent's built-in agent discovery
144
+ // and the official subagent extension example. Reading getAgentDir() also
145
+ // honors the PI_CODING_AGENT_DIR env override.
146
+ const userDir = opts?.userDir ?? join(getAgentDir(), "agents");
147
+ // Deprecated: this repo's docs used to point users at ~/.pi/agents/ before
148
+ // pi-coding-agent's convention (~/.pi/agent/agents/) was known. Keep scanning
149
+ // it as a fallback so those files don't silently stop resolving.
150
+ const legacyUserDir = opts?.legacyUserDir ?? join(homedir(), AGENTS_DIR);
151
+
152
+ const registry: AgentRegistry = new Map();
153
+ for (const def of readDefsFromDir(projectDir, "project")) {
154
+ if (def.name && !registry.has(def.name)) registry.set(def.name, def);
155
+ }
156
+ if (userDir !== projectDir) {
157
+ for (const def of readDefsFromDir(userDir, "user")) {
158
+ if (def.name && !registry.has(def.name)) registry.set(def.name, def);
159
+ }
160
+ }
161
+ if (legacyUserDir !== projectDir && legacyUserDir !== userDir) {
162
+ let warnedLegacy = false;
163
+ for (const def of readDefsFromDir(legacyUserDir, "user")) {
164
+ if (def.name && !registry.has(def.name)) {
165
+ registry.set(def.name, def);
166
+ if (!warnedLegacy) {
167
+ console.warn(
168
+ `[agent-registry] Loaded agent definition(s) from the deprecated location "${legacyUserDir}". ` +
169
+ `Move them to "${userDir}" — the old location may stop being read in a future release.`,
170
+ );
171
+ warnedLegacy = true;
172
+ }
173
+ }
174
+ }
175
+ }
176
+ return registry;
177
+ }
178
+
179
+ /** Resolve an agentType name to its definition, or undefined if not registered. */
180
+ export function resolveAgentType(name: string | undefined, registry: AgentRegistry): AgentDefinition | undefined {
181
+ if (!name) return undefined;
182
+ return registry.get(name);
183
+ }
184
+
185
+ /**
186
+ * Apply a definition's tool policy to a tool list: keep only allowlisted names
187
+ * (when an allowlist is given), then drop any denylisted names. Generic over any
188
+ * object with a `name` so it is unit-testable without real ToolDefinitions.
189
+ */
190
+ export function applyToolPolicy<T extends { name: string }>(tools: T[], allow?: string[], deny?: string[]): T[] {
191
+ let out = tools;
192
+ if (allow?.length) {
193
+ const allowSet = new Set(allow);
194
+ out = out.filter((t) => allowSet.has(t.name));
195
+ }
196
+ if (deny?.length) {
197
+ const denySet = new Set(deny);
198
+ out = out.filter((t) => !denySet.has(t.name));
199
+ }
200
+ return out;
201
+ }
202
+
203
+ /**
204
+ * A stable identity string for a resolved definition, folded into the resume
205
+ * call-hash so editing an agent `.md` invalidates that call's cached result.
206
+ */
207
+ export function agentDefinitionKey(def: AgentDefinition | undefined): string | null {
208
+ if (!def) return null;
209
+ return JSON.stringify({
210
+ tools: def.tools ?? null,
211
+ disallowedTools: def.disallowedTools ?? null,
212
+ model: def.model ?? null,
213
+ isolation: def.isolation ?? null,
214
+ prompt: def.prompt,
215
+ });
216
+ }
217
+
218
+ /** List registered agent types for discoverability in the tool guideline. */
219
+ export function listAgentTypes(registry: AgentRegistry): Array<{ name: string; description?: string }> {
220
+ return [...registry.values()].map((d) => ({ name: d.name, description: d.description }));
221
+ }