ultimate-pi 0.3.1 → 0.4.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 (114) hide show
  1. package/.agents/skills/harness-decisions/SKILL.md +37 -0
  2. package/.agents/skills/harness-governor/SKILL.md +1 -1
  3. package/.agents/skills/harness-orchestration/SKILL.md +54 -0
  4. package/.agents/skills/harness-plan/SKILL.md +4 -3
  5. package/.agents/skills/harness-sentrux-setup/SKILL.md +57 -0
  6. package/.agents/skills/scrapling-web/SKILL.md +93 -0
  7. package/.pi/PACKAGING.md +2 -2
  8. package/.pi/SYSTEM.md +13 -15
  9. package/.pi/agents/harness/adversary.md +3 -0
  10. package/.pi/agents/harness/evaluator.md +3 -0
  11. package/.pi/agents/harness/executor.md +4 -1
  12. package/.pi/agents/harness/meta-optimizer.md +2 -1
  13. package/.pi/agents/harness/planner.md +22 -1
  14. package/.pi/agents/harness/sentrux-bootstrap.md +42 -0
  15. package/.pi/agents/harness/tie-breaker.md +2 -0
  16. package/.pi/extensions/harness-ask-user.ts +74 -0
  17. package/.pi/extensions/harness-subagents.ts +9 -0
  18. package/.pi/extensions/lib/ask-user/dialog.ts +260 -0
  19. package/.pi/extensions/lib/ask-user/fallback.ts +78 -0
  20. package/.pi/extensions/lib/ask-user/render.ts +66 -0
  21. package/.pi/extensions/lib/ask-user/schema.ts +69 -0
  22. package/.pi/extensions/lib/ask-user/types.ts +41 -0
  23. package/.pi/extensions/lib/ask-user/validate-core.mjs +79 -0
  24. package/.pi/extensions/lib/ask-user/validate.ts +92 -0
  25. package/.pi/extensions/lib/harness-subagents/agent-loader.ts +126 -0
  26. package/.pi/extensions/lib/harness-subagents/agent-manifest.ts +119 -0
  27. package/.pi/extensions/lib/harness-subagents/agent-parser.ts +87 -0
  28. package/.pi/extensions/lib/harness-subagents/blackboard-tool.ts +118 -0
  29. package/.pi/extensions/lib/harness-subagents/blackboard.ts +175 -0
  30. package/.pi/extensions/lib/harness-subagents/spawn-policy.ts +27 -0
  31. package/.pi/extensions/lib/harness-subagents/types-blackboard.ts +27 -0
  32. package/.pi/extensions/lib/harness-subagents/vendored/agent-manager.ts +553 -0
  33. package/.pi/extensions/lib/harness-subagents/vendored/agent-runner.ts +637 -0
  34. package/.pi/extensions/lib/harness-subagents/vendored/agent-types.ts +175 -0
  35. package/.pi/extensions/lib/harness-subagents/vendored/context.ts +59 -0
  36. package/.pi/extensions/lib/harness-subagents/vendored/cross-extension-rpc.ts +134 -0
  37. package/.pi/extensions/lib/harness-subagents/vendored/custom-agents.ts +5 -0
  38. package/.pi/extensions/lib/harness-subagents/vendored/default-agents.ts +123 -0
  39. package/.pi/extensions/lib/harness-subagents/vendored/env.ts +43 -0
  40. package/.pi/extensions/lib/harness-subagents/vendored/group-join.ts +144 -0
  41. package/.pi/extensions/lib/harness-subagents/vendored/index.ts +2447 -0
  42. package/.pi/extensions/lib/harness-subagents/vendored/invocation-config.ts +52 -0
  43. package/.pi/extensions/lib/harness-subagents/vendored/memory.ts +182 -0
  44. package/.pi/extensions/lib/harness-subagents/vendored/model-resolver.ts +92 -0
  45. package/.pi/extensions/lib/harness-subagents/vendored/output-file.ts +115 -0
  46. package/.pi/extensions/lib/harness-subagents/vendored/prompts.ts +103 -0
  47. package/.pi/extensions/lib/harness-subagents/vendored/schedule-store.ts +177 -0
  48. package/.pi/extensions/lib/harness-subagents/vendored/schedule.ts +416 -0
  49. package/.pi/extensions/lib/harness-subagents/vendored/settings.ts +210 -0
  50. package/.pi/extensions/lib/harness-subagents/vendored/skill-loader.ts +108 -0
  51. package/.pi/extensions/lib/harness-subagents/vendored/types.ts +187 -0
  52. package/.pi/extensions/lib/harness-subagents/vendored/ui/agent-widget.ts +637 -0
  53. package/.pi/extensions/lib/harness-subagents/vendored/ui/conversation-viewer.ts +324 -0
  54. package/.pi/extensions/lib/harness-subagents/vendored/ui/schedule-menu.ts +110 -0
  55. package/.pi/extensions/lib/harness-subagents/vendored/usage.ts +71 -0
  56. package/.pi/extensions/lib/harness-subagents/vendored/worktree.ts +195 -0
  57. package/.pi/harness/README.md +2 -1
  58. package/.pi/harness/agents.manifest.json +80 -0
  59. package/.pi/harness/docs/adrs/0009-sentrux-rules-lifecycle.md +9 -5
  60. package/.pi/harness/env.harness.template +28 -0
  61. package/.pi/harness/sentrux/architecture.manifest.json +6 -1
  62. package/.pi/prompts/harness-auto.md +2 -2
  63. package/.pi/prompts/harness-plan.md +2 -2
  64. package/.pi/prompts/harness-router-tune.md +2 -2
  65. package/.pi/prompts/harness-run.md +1 -0
  66. package/.pi/prompts/harness-setup.md +178 -339
  67. package/.pi/scripts/README.md +6 -1
  68. package/.pi/scripts/harness-agents-manifest.mjs +123 -0
  69. package/.pi/scripts/harness-cli-verify.sh +60 -11
  70. package/.pi/scripts/harness-generate-model-router.mjs +242 -0
  71. package/.pi/scripts/harness-graphify-bootstrap.sh +1 -6
  72. package/.pi/scripts/harness-resolve-up-pkg.mjs +71 -0
  73. package/.pi/scripts/harness-seed-project-contracts.mjs +33 -1
  74. package/.pi/scripts/harness-sentrux-bootstrap.mjs +146 -0
  75. package/.pi/scripts/harness-sync-env.mjs +148 -0
  76. package/.pi/scripts/harness-verify.mjs +19 -0
  77. package/.pi/scripts/harness-web-search.md +33 -0
  78. package/.pi/scripts/harness-web.py +177 -0
  79. package/.pi/scripts/harness_web/__init__.py +1 -0
  80. package/.pi/scripts/harness_web/config.py +80 -0
  81. package/.pi/scripts/harness_web/output.py +55 -0
  82. package/.pi/scripts/harness_web/scrape.py +120 -0
  83. package/.pi/scripts/harness_web/search_ddg.py +106 -0
  84. package/.pi/scripts/release.sh +338 -0
  85. package/.pi/scripts/sentrux-rules-sync.mjs +29 -7
  86. package/.pi/settings.example.json +0 -1
  87. package/.sentrux/rules.toml +1 -1
  88. package/AGENTS.md +1 -1
  89. package/CHANGELOG.md +12 -0
  90. package/THIRD_PARTY_NOTICES.md +22 -0
  91. package/package.json +12 -9
  92. package/.agents/skills/firecrawl/SKILL.md +0 -150
  93. package/.agents/skills/firecrawl/rules/install.md +0 -82
  94. package/.agents/skills/firecrawl/rules/security.md +0 -26
  95. package/.agents/skills/firecrawl-agent/SKILL.md +0 -57
  96. package/.agents/skills/firecrawl-build-interact/SKILL.md +0 -67
  97. package/.agents/skills/firecrawl-build-onboarding/SKILL.md +0 -102
  98. package/.agents/skills/firecrawl-build-onboarding/references/auth-flow.md +0 -39
  99. package/.agents/skills/firecrawl-build-onboarding/references/project-setup.md +0 -20
  100. package/.agents/skills/firecrawl-build-onboarding/references/sdk-installation.md +0 -17
  101. package/.agents/skills/firecrawl-build-scrape/SKILL.md +0 -68
  102. package/.agents/skills/firecrawl-build-search/SKILL.md +0 -68
  103. package/.agents/skills/firecrawl-crawl/SKILL.md +0 -58
  104. package/.agents/skills/firecrawl-download/SKILL.md +0 -69
  105. package/.agents/skills/firecrawl-interact/SKILL.md +0 -83
  106. package/.agents/skills/firecrawl-map/SKILL.md +0 -50
  107. package/.agents/skills/firecrawl-parse/SKILL.md +0 -61
  108. package/.agents/skills/firecrawl-scrape/SKILL.md +0 -68
  109. package/.agents/skills/firecrawl-search/SKILL.md +0 -59
  110. package/firecrawl/.env.template +0 -62
  111. package/firecrawl/README.md +0 -49
  112. package/firecrawl/docker-compose.yaml +0 -201
  113. package/firecrawl/searxng/searxng.env +0 -3
  114. package/firecrawl/searxng/settings.yml +0 -85
@@ -0,0 +1,260 @@
1
+ import type { ExtensionUIContext } from "@mariozechner/pi-coding-agent";
2
+ import {
3
+ Editor,
4
+ type EditorTheme,
5
+ Key,
6
+ matchesKey,
7
+ truncateToWidth,
8
+ } from "@mariozechner/pi-tui";
9
+ import type { AskResponse, DialogResult, ValidatedAskParams } from "./types.js";
10
+
11
+ type DisplayOption = {
12
+ title: string;
13
+ description?: string;
14
+ isFreeform?: boolean;
15
+ };
16
+
17
+ interface CustomAnswer {
18
+ response: AskResponse;
19
+ }
20
+
21
+ function withTimeout<T>(
22
+ promise: Promise<T | null>,
23
+ ms: number | undefined,
24
+ ): Promise<T | null> {
25
+ if (!ms) return promise;
26
+ return Promise.race([
27
+ promise,
28
+ new Promise<null>((resolve) => {
29
+ setTimeout(() => resolve(null), ms);
30
+ }),
31
+ ]);
32
+ }
33
+
34
+ export async function runAskDialog(
35
+ ui: ExtensionUIContext,
36
+ validated: ValidatedAskParams,
37
+ ): Promise<DialogResult> {
38
+ const { question, context, options, allowMultiple, allowFreeform } =
39
+ validated;
40
+
41
+ const displayOptions: DisplayOption[] = [...options];
42
+ if (allowFreeform) {
43
+ displayOptions.push({
44
+ title: "Type something…",
45
+ isFreeform: true,
46
+ });
47
+ }
48
+
49
+ // Freeform-only: no listed options
50
+ if (displayOptions.length === 0) {
51
+ const text = await ui.input(question, "");
52
+ if (!text?.trim()) {
53
+ return { response: null, cancelled: true };
54
+ }
55
+ return {
56
+ response: { kind: "freeform", text: text.trim() },
57
+ cancelled: false,
58
+ };
59
+ }
60
+
61
+ const result = await withTimeout(
62
+ ui.custom<CustomAnswer | null>((tui, theme, _kb, done) => {
63
+ let optionIndex = 0;
64
+ let editMode = false;
65
+ const selected = new Set<number>();
66
+ let cachedLines: string[] | undefined;
67
+
68
+ const editorTheme: EditorTheme = {
69
+ borderColor: (s) => theme.fg("accent", s),
70
+ selectList: {
71
+ selectedPrefix: (t) => theme.fg("accent", t),
72
+ selectedText: (t) => theme.fg("accent", t),
73
+ description: (t) => theme.fg("muted", t),
74
+ scrollInfo: (t) => theme.fg("dim", t),
75
+ noMatch: (t) => theme.fg("warning", t),
76
+ },
77
+ };
78
+ const editor = new Editor(tui, editorTheme);
79
+
80
+ editor.onSubmit = (value) => {
81
+ const trimmed = value.trim();
82
+ if (trimmed) {
83
+ done({ response: { kind: "freeform", text: trimmed } });
84
+ } else {
85
+ editMode = false;
86
+ editor.setText("");
87
+ refresh();
88
+ }
89
+ };
90
+
91
+ function refresh() {
92
+ cachedLines = undefined;
93
+ tui.requestRender();
94
+ }
95
+
96
+ function submitSelection() {
97
+ if (allowMultiple) {
98
+ const titles = [...selected]
99
+ .sort((a, b) => a - b)
100
+ .map((i) => displayOptions[i].title)
101
+ .filter((t) => t !== "Type something…");
102
+ if (titles.length === 0) return;
103
+ done({ response: { kind: "selection", selections: titles } });
104
+ return;
105
+ }
106
+ const opt = displayOptions[optionIndex];
107
+ if (opt.isFreeform) {
108
+ editMode = true;
109
+ refresh();
110
+ return;
111
+ }
112
+ done({
113
+ response: { kind: "selection", selections: [opt.title] },
114
+ });
115
+ }
116
+
117
+ function handleInput(data: string) {
118
+ if (editMode) {
119
+ if (matchesKey(data, Key.escape)) {
120
+ editMode = false;
121
+ editor.setText("");
122
+ refresh();
123
+ return;
124
+ }
125
+ editor.handleInput(data);
126
+ refresh();
127
+ return;
128
+ }
129
+
130
+ if (matchesKey(data, Key.up)) {
131
+ optionIndex = Math.max(0, optionIndex - 1);
132
+ refresh();
133
+ return;
134
+ }
135
+ if (matchesKey(data, Key.down)) {
136
+ optionIndex = Math.min(displayOptions.length - 1, optionIndex + 1);
137
+ refresh();
138
+ return;
139
+ }
140
+
141
+ if (allowMultiple && matchesKey(data, Key.space)) {
142
+ const opt = displayOptions[optionIndex];
143
+ if (!opt.isFreeform) {
144
+ if (selected.has(optionIndex)) {
145
+ selected.delete(optionIndex);
146
+ } else {
147
+ selected.add(optionIndex);
148
+ }
149
+ refresh();
150
+ }
151
+ return;
152
+ }
153
+
154
+ if (matchesKey(data, Key.enter)) {
155
+ submitSelection();
156
+ return;
157
+ }
158
+
159
+ if (matchesKey(data, Key.escape)) {
160
+ done(null);
161
+ }
162
+ }
163
+
164
+ function render(width: number): string[] {
165
+ if (cachedLines) return cachedLines;
166
+
167
+ const lines: string[] = [];
168
+ const add = (s: string) => lines.push(truncateToWidth(s, width));
169
+ const useOverlay = validated.displayMode !== "inline";
170
+
171
+ if (useOverlay) {
172
+ add(theme.fg("accent", "─".repeat(width)));
173
+ }
174
+
175
+ if (context) {
176
+ for (const line of context.split("\n")) {
177
+ add(theme.fg("muted", ` ${line}`));
178
+ }
179
+ lines.push("");
180
+ }
181
+
182
+ add(theme.fg("text", ` ${question}`));
183
+ lines.push("");
184
+
185
+ for (let i = 0; i < displayOptions.length; i++) {
186
+ const opt = displayOptions[i];
187
+ const isFreeform = opt.isFreeform === true;
188
+ const focused = i === optionIndex;
189
+ const checked = selected.has(i);
190
+ let prefix = " ";
191
+ if (allowMultiple && !isFreeform) {
192
+ prefix = checked ? theme.fg("accent", "[x] ") : "[ ] ";
193
+ } else if (focused) {
194
+ prefix = theme.fg("accent", "> ");
195
+ }
196
+
197
+ const num = `${i + 1}. `;
198
+ const label = opt.title;
199
+ if (isFreeform && editMode && focused) {
200
+ add(prefix + theme.fg("accent", `${num}${label} ✎`));
201
+ } else if (focused && !allowMultiple) {
202
+ add(prefix + theme.fg("accent", `${num}${label}`));
203
+ } else {
204
+ add(`${prefix}${theme.fg("text", `${num}${label}`)}`);
205
+ }
206
+
207
+ if (opt.description) {
208
+ add(` ${theme.fg("muted", opt.description)}`);
209
+ }
210
+ }
211
+
212
+ if (editMode) {
213
+ lines.push("");
214
+ add(theme.fg("muted", " Your answer:"));
215
+ for (const line of editor.render(width - 2)) {
216
+ add(` ${line}`);
217
+ }
218
+ }
219
+
220
+ lines.push("");
221
+ if (editMode) {
222
+ add(theme.fg("dim", " Enter to submit • Esc to go back"));
223
+ } else if (allowMultiple) {
224
+ add(
225
+ theme.fg(
226
+ "dim",
227
+ " ↑↓ navigate • Space toggle • Enter confirm • Esc cancel",
228
+ ),
229
+ );
230
+ } else {
231
+ add(
232
+ theme.fg("dim", " ↑↓ navigate • Enter to select • Esc to cancel"),
233
+ );
234
+ }
235
+
236
+ if (useOverlay) {
237
+ add(theme.fg("accent", "─".repeat(width)));
238
+ }
239
+
240
+ cachedLines = lines;
241
+ return lines;
242
+ }
243
+
244
+ return {
245
+ render,
246
+ invalidate: () => {
247
+ cachedLines = undefined;
248
+ },
249
+ handleInput,
250
+ };
251
+ }),
252
+ validated.timeout,
253
+ );
254
+
255
+ if (!result) {
256
+ return { response: null, cancelled: true };
257
+ }
258
+
259
+ return { response: result.response, cancelled: false };
260
+ }
@@ -0,0 +1,78 @@
1
+ import type { ExtensionUIContext } from "@mariozechner/pi-coding-agent";
2
+ import type { DialogResult, ValidatedAskParams } from "./types.js";
3
+
4
+ export async function runAskFallback(
5
+ ui: ExtensionUIContext,
6
+ validated: ValidatedAskParams,
7
+ ): Promise<DialogResult> {
8
+ const { question, context, options, allowMultiple, allowFreeform } =
9
+ validated;
10
+
11
+ const title = context ? `${context}\n\n${question}` : question;
12
+ const labels = options.map((o) => o.title);
13
+
14
+ if (labels.length === 0) {
15
+ if (!allowFreeform) {
16
+ return { response: null, cancelled: true };
17
+ }
18
+ const text = await ui.input(title, "");
19
+ if (!text?.trim()) {
20
+ return { response: null, cancelled: true };
21
+ }
22
+ return {
23
+ response: { kind: "freeform", text: text.trim() },
24
+ cancelled: false,
25
+ };
26
+ }
27
+
28
+ if (allowMultiple) {
29
+ const selections: string[] = [];
30
+ const remaining = [...labels];
31
+ while (remaining.length > 0) {
32
+ const pick = await ui.select(
33
+ selections.length === 0
34
+ ? title
35
+ : `${title}\n(selected: ${selections.join(", ")})`,
36
+ [...remaining, "(done selecting)"],
37
+ );
38
+ if (!pick) {
39
+ return { response: null, cancelled: true };
40
+ }
41
+ if (pick === "(done selecting)") {
42
+ break;
43
+ }
44
+ selections.push(pick);
45
+ const idx = remaining.indexOf(pick);
46
+ if (idx >= 0) remaining.splice(idx, 1);
47
+ }
48
+ if (selections.length === 0) {
49
+ return { response: null, cancelled: true };
50
+ }
51
+ return {
52
+ response: { kind: "selection", selections },
53
+ cancelled: false,
54
+ };
55
+ }
56
+
57
+ const choices = allowFreeform ? [...labels, "Type something…"] : labels;
58
+ const picked = await ui.select(title, choices);
59
+ if (!picked) {
60
+ return { response: null, cancelled: true };
61
+ }
62
+
63
+ if (picked === "Type something…") {
64
+ const text = await ui.input(question, "");
65
+ if (!text?.trim()) {
66
+ return { response: null, cancelled: true };
67
+ }
68
+ return {
69
+ response: { kind: "freeform", text: text.trim() },
70
+ cancelled: false,
71
+ };
72
+ }
73
+
74
+ return {
75
+ response: { kind: "selection", selections: [picked] },
76
+ cancelled: false,
77
+ };
78
+ }
@@ -0,0 +1,66 @@
1
+ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
2
+ import type {
3
+ Theme,
4
+ ToolRenderResultOptions,
5
+ } from "@mariozechner/pi-coding-agent";
6
+ import { Text } from "@mariozechner/pi-tui";
7
+ import type { AskToolDetails } from "./types.js";
8
+
9
+ export function renderAskCall(
10
+ args: Record<string, unknown>,
11
+ theme: Theme,
12
+ ): Text {
13
+ let text =
14
+ theme.fg("toolTitle", theme.bold("ask_user ")) +
15
+ theme.fg("muted", String(args.question ?? ""));
16
+ if (args.context) {
17
+ text += `\n${theme.fg("dim", ` ${String(args.context)}`)}`;
18
+ }
19
+ const opts = Array.isArray(args.options) ? args.options : [];
20
+ if (opts.length) {
21
+ const labels = opts.map((o: unknown) =>
22
+ typeof o === "string" ? o : ((o as { title?: string })?.title ?? "?"),
23
+ );
24
+ const numbered = labels.map((o, i) => `${i + 1}. ${o}`);
25
+ if (args.allowFreeform !== false) {
26
+ numbered.push(`${numbered.length + 1}. Type something…`);
27
+ }
28
+ text += `\n${theme.fg("dim", ` Options: ${numbered.join(", ")}`)}`;
29
+ }
30
+ return new Text(text, 0, 0);
31
+ }
32
+
33
+ export function renderAskResult(
34
+ result: AgentToolResult<unknown>,
35
+ _options: ToolRenderResultOptions,
36
+ theme: Theme,
37
+ ): Text {
38
+ const details = result.details as AskToolDetails | undefined;
39
+ if (!details) {
40
+ const block = result.content[0];
41
+ return new Text(block?.type === "text" ? block.text : "", 0, 0);
42
+ }
43
+
44
+ if (details.cancelled || !details.response) {
45
+ return new Text(theme.fg("warning", "Cancelled"), 0, 0);
46
+ }
47
+
48
+ if (details.response.kind === "freeform") {
49
+ return new Text(
50
+ theme.fg("success", "✓ ") +
51
+ theme.fg("muted", "(wrote) ") +
52
+ theme.fg("accent", details.response.text),
53
+ 0,
54
+ 0,
55
+ );
56
+ }
57
+
58
+ const sel = details.response.selections;
59
+ const display =
60
+ sel.length === 1 ? sel[0] : sel.map((s, i) => `${i + 1}. ${s}`).join(", ");
61
+ return new Text(
62
+ theme.fg("success", "✓ ") + theme.fg("accent", display),
63
+ 0,
64
+ 0,
65
+ );
66
+ }
@@ -0,0 +1,69 @@
1
+ import { type TUnsafe, Type } from "@sinclair/typebox";
2
+
3
+ /** Google-safe string enum (flat enum, not Type.Union of literals). */
4
+ function StringEnum<T extends string[]>(
5
+ values: T,
6
+ options?: { description?: string },
7
+ ): TUnsafe<T[number]> {
8
+ return Type.Unsafe({
9
+ type: "string",
10
+ enum: [...values],
11
+ ...(options?.description ? { description: options.description } : {}),
12
+ });
13
+ }
14
+
15
+ const OptionSchema = Type.Union([
16
+ Type.String({ description: "Option label" }),
17
+ Type.Object({
18
+ title: Type.String({ description: "Short option label" }),
19
+ description: Type.Optional(
20
+ Type.String({ description: "Optional explanation" }),
21
+ ),
22
+ }),
23
+ ]);
24
+
25
+ export const AskUserParamsSchema = Type.Object({
26
+ question: Type.String({ description: "The question to ask the user" }),
27
+ context: Type.Optional(
28
+ Type.String({ description: "Context shown above the question" }),
29
+ ),
30
+ options: Type.Optional(
31
+ Type.Array(OptionSchema, {
32
+ description: "Multiple-choice options (min 2 if provided)",
33
+ }),
34
+ ),
35
+ allowMultiple: Type.Optional(
36
+ Type.Boolean({
37
+ description: "Allow selecting more than one option (Space to toggle)",
38
+ default: false,
39
+ }),
40
+ ),
41
+ allowFreeform: Type.Optional(
42
+ Type.Boolean({
43
+ description: 'Append "Type something…" freeform row',
44
+ default: true,
45
+ }),
46
+ ),
47
+ displayMode: Type.Optional(
48
+ StringEnum(["overlay", "inline"] as const, {
49
+ description: "overlay = modal (default), inline = in transcript flow",
50
+ }),
51
+ ),
52
+ timeout: Type.Optional(
53
+ Type.Number({
54
+ description: "Auto-cancel after N milliseconds",
55
+ minimum: 1,
56
+ }),
57
+ ),
58
+ });
59
+
60
+ export const PROMPT_SNIPPET =
61
+ "Structured user input for ambiguous or high-impact harness decisions";
62
+
63
+ export const PROMPT_GUIDELINES = [
64
+ "Use ask_user when requirements are ambiguous, conflicting, or high-impact — never guess on harness forks (Firecrawl mode, .env creation, scope, risk, merge policy).",
65
+ "Prefer one focused question per call with 2–4 clear options and short descriptions for trade-offs.",
66
+ "If the user cancels (Esc), stop and report needs_clarification; do not proceed with assumptions.",
67
+ "Do not stack redundant ask_user calls — combine related choices when phase 2 batch mode exists.",
68
+ "Independent evaluator/adversary agents must not call ask_user; emit human_required and let the orchestrator ask.",
69
+ ];
@@ -0,0 +1,41 @@
1
+ export interface NormalizedOption {
2
+ title: string;
3
+ description?: string;
4
+ }
5
+
6
+ export type AskResponse =
7
+ | { kind: "selection"; selections: string[] }
8
+ | { kind: "freeform"; text: string };
9
+
10
+ export interface AskToolDetails {
11
+ question: string;
12
+ context?: string;
13
+ options: string[];
14
+ response: AskResponse | null;
15
+ cancelled: boolean;
16
+ }
17
+
18
+ export interface AskUserParams {
19
+ question: string;
20
+ context?: string;
21
+ options?: Array<string | { title: string; description?: string }>;
22
+ allowMultiple?: boolean;
23
+ allowFreeform?: boolean;
24
+ displayMode?: "overlay" | "inline";
25
+ timeout?: number;
26
+ }
27
+
28
+ export interface ValidatedAskParams {
29
+ question: string;
30
+ context?: string;
31
+ options: NormalizedOption[];
32
+ allowMultiple: boolean;
33
+ allowFreeform: boolean;
34
+ displayMode: "overlay" | "inline";
35
+ timeout?: number;
36
+ }
37
+
38
+ export interface DialogResult {
39
+ response: AskResponse | null;
40
+ cancelled: boolean;
41
+ }
@@ -0,0 +1,79 @@
1
+ /** Keep in sync with validate.ts — test entrypoint for node --test */
2
+
3
+ /**
4
+ * @param {string | { title: string, description?: string }} raw
5
+ */
6
+ export function normalizeOption(raw) {
7
+ if (typeof raw === "string") {
8
+ return { title: raw.trim() };
9
+ }
10
+ return {
11
+ title: raw.title.trim(),
12
+ description: raw.description?.trim() || undefined,
13
+ };
14
+ }
15
+
16
+ /** @param {import('./types.js').AskUserParams} params */
17
+ export function validateAskParams(params) {
18
+ const question = params.question?.trim();
19
+ if (!question) {
20
+ return "ask_user: question is required";
21
+ }
22
+
23
+ const options = (params.options ?? [])
24
+ .map(normalizeOption)
25
+ .filter((o) => o.title);
26
+ if (options.length > 0 && options.length < 2) {
27
+ return "ask_user: provide at least 2 options, or omit options for freeform-only";
28
+ }
29
+
30
+ const allowFreeform = params.allowFreeform !== false;
31
+ if (options.length === 0 && !allowFreeform) {
32
+ return "ask_user: options required when allowFreeform is false";
33
+ }
34
+
35
+ const displayMode =
36
+ process.env.HARNESS_ASK_USER_DISPLAY_MODE === "inline"
37
+ ? "inline"
38
+ : params.displayMode === "inline"
39
+ ? "inline"
40
+ : "overlay";
41
+
42
+ return {
43
+ question,
44
+ context: params.context?.trim() || undefined,
45
+ options,
46
+ allowMultiple: params.allowMultiple === true,
47
+ allowFreeform,
48
+ displayMode,
49
+ timeout:
50
+ typeof params.timeout === "number" && params.timeout > 0
51
+ ? params.timeout
52
+ : undefined,
53
+ };
54
+ }
55
+
56
+ /** @param {import('./types.js').AskResponse | null} response @param {boolean} cancelled */
57
+ export function formatResultText(response, cancelled) {
58
+ if (cancelled || !response) {
59
+ return "User cancelled (no answer)";
60
+ }
61
+ if (response.kind === "freeform") {
62
+ return `User wrote: ${response.text}`;
63
+ }
64
+ if (response.selections.length === 1) {
65
+ return `User selected: ${response.selections[0]}`;
66
+ }
67
+ return `User selected: ${response.selections.join(", ")}`;
68
+ }
69
+
70
+ /** @param {import('./types.js').ValidatedAskParams} validated */
71
+ export function toToolDetails(validated, response, cancelled) {
72
+ return {
73
+ question: validated.question,
74
+ context: validated.context,
75
+ options: validated.options.map((o) => o.title),
76
+ response,
77
+ cancelled,
78
+ };
79
+ }
@@ -0,0 +1,92 @@
1
+ import type {
2
+ AskResponse,
3
+ AskToolDetails,
4
+ AskUserParams,
5
+ NormalizedOption,
6
+ ValidatedAskParams,
7
+ } from "./types.js";
8
+
9
+ export type { ValidatedAskParams };
10
+
11
+ export function normalizeOption(
12
+ raw: string | { title: string; description?: string },
13
+ ): NormalizedOption {
14
+ if (typeof raw === "string") {
15
+ return { title: raw.trim() };
16
+ }
17
+ return {
18
+ title: raw.title.trim(),
19
+ description: raw.description?.trim() || undefined,
20
+ };
21
+ }
22
+
23
+ export function validateAskParams(
24
+ params: AskUserParams,
25
+ ): ValidatedAskParams | string {
26
+ const question = params.question?.trim();
27
+ if (!question) {
28
+ return "ask_user: question is required";
29
+ }
30
+
31
+ const options = (params.options ?? [])
32
+ .map(normalizeOption)
33
+ .filter((o) => o.title);
34
+ if (options.length > 0 && options.length < 2) {
35
+ return "ask_user: provide at least 2 options, or omit options for freeform-only";
36
+ }
37
+
38
+ const allowFreeform = params.allowFreeform !== false;
39
+ if (options.length === 0 && !allowFreeform) {
40
+ return "ask_user: options required when allowFreeform is false";
41
+ }
42
+
43
+ const displayMode =
44
+ process.env.HARNESS_ASK_USER_DISPLAY_MODE === "inline"
45
+ ? "inline"
46
+ : params.displayMode === "inline"
47
+ ? "inline"
48
+ : "overlay";
49
+
50
+ return {
51
+ question,
52
+ context: params.context?.trim() || undefined,
53
+ options,
54
+ allowMultiple: params.allowMultiple === true,
55
+ allowFreeform,
56
+ displayMode,
57
+ timeout:
58
+ typeof params.timeout === "number" && params.timeout > 0
59
+ ? params.timeout
60
+ : undefined,
61
+ };
62
+ }
63
+
64
+ export function formatResultText(
65
+ response: AskResponse | null,
66
+ cancelled: boolean,
67
+ ): string {
68
+ if (cancelled || !response) {
69
+ return "User cancelled (no answer)";
70
+ }
71
+ if (response.kind === "freeform") {
72
+ return `User wrote: ${response.text}`;
73
+ }
74
+ if (response.selections.length === 1) {
75
+ return `User selected: ${response.selections[0]}`;
76
+ }
77
+ return `User selected: ${response.selections.join(", ")}`;
78
+ }
79
+
80
+ export function toToolDetails(
81
+ validated: ValidatedAskParams,
82
+ response: AskResponse | null,
83
+ cancelled: boolean,
84
+ ): AskToolDetails {
85
+ return {
86
+ question: validated.question,
87
+ context: validated.context,
88
+ options: validated.options.map((o) => o.title),
89
+ response,
90
+ cancelled,
91
+ };
92
+ }