@arnilo/prism-coding-agent 0.0.10 → 0.0.12

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 CHANGED
@@ -1,4 +1,25 @@
1
1
  # Changelog
2
+ ## [Unreleased]
3
+
4
+ ## [0.0.12] - 2026-07-22
5
+
6
+ ### Changed
7
+
8
+ - Released with exact 0.0.12 graph.
9
+
10
+
11
+ ## [0.0.11] - 2026-07-22
12
+
13
+ ### Docs
14
+
15
+ - Documented ask_user_decision multi/free-text/suspend glue + `runCodingGoalVerify` in `docs/coding-agent-tools.md` / README.
16
+
17
+ ### Added
18
+
19
+ - `runCodingGoalVerify` / `createCodingGoalVerifyWorkflow`: thin goal→verify composition over plan Markdown, named checks, workflow suspend/approve, and bounded PR handoff (peer `@arnilo/prism-workflows`). No Goal table / second runtime.
20
+ - Opt-in `createAskUserDecisionTool({ ask })`: model proposes 2+ options with exactly 3 pros + 3 cons each; host `ask` returns `selectedId` / `selectedIds` / `customText`. Supports `selectionMode: "single" | "multiple"` and `allowCustom` (custom XOR selection; default/hard custom bytes match question caps). Not in `createCodingTools` / `createAllTools` / `createReadOnlyTools`.
21
+ - Durable ask-user helpers: `suspendAskUserDecision`, `createAskUserDecisionResumeValidator`, `validateAskUserDecisionResume`, `validateAskUserDecisionAgentResume` (workflow-first; agent path reuses same validator without new `AgentRunInterruption` kinds).
22
+
2
23
  ## [0.0.10] - 2026-07-21
3
24
 
4
25
  ### Changed
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @arnilo/prism-coding-agent
2
2
 
3
- Optional first-party coding tools package for [Prism](https://www.npmjs.com/package/@arnilo/prism). Provides host shell/filesystem/repository tools — `shell`, `read`, `write`, `edit`, `repo_list`, `repo_search` — plus opt-in structured Git/check tools via `createGitTools()` and durable plan/checkpoint helpers for workflow composition — as Prism `ToolDefinition` objects. **Inert until a host imports it and registers the tools into a `ToolRegistry`.**
3
+ Optional first-party coding tools package for [Prism](https://www.npmjs.com/package/@arnilo/prism). Provides host shell/filesystem/repository tools — `shell`, `read`, `write`, `edit`, `repo_list`, `repo_search` — plus opt-in structured Git/check tools via `createGitTools()`, opt-in `createAskUserDecisionTool({ ask })`, and durable plan/checkpoint helpers for workflow composition — as Prism `ToolDefinition` objects. **Inert until a host imports it and registers the tools into a `ToolRegistry`.** No tool is auto-registered; hosts pick factories (or filter aggregator output) and may mix in their own `ToolDefinition`s.
4
4
 
5
5
  Behavior is a behavioral port of the pi coding agent's `bash`/`read`/`write`/`edit` tools, adapted to Prism's `ToolDefinition` / `ToolResult` contracts (no `@earendil-works/*` or `typebox` dependencies). List/search/Git are native Prism tools.
6
6
 
@@ -12,7 +12,7 @@ Behavior is a behavioral port of the pi coding agent's `bash`/`read`/`write`/`ed
12
12
  npm install @arnilo/prism-coding-agent
13
13
  ```
14
14
 
15
- `@arnilo/prism` is a peer dependency.
15
+ `@arnilo/prism` is a peer dependency. `runCodingGoalVerify` also peers `@arnilo/prism-workflows`.
16
16
 
17
17
  ## Usage
18
18
 
@@ -38,7 +38,7 @@ Shared `ToolsOptions.executionPolicy` applies to every tool returned by full, al
38
38
  Individual tools with options:
39
39
 
40
40
  ```ts
41
- import { createShellTool, createWriteTool } from "@arnilo/prism-coding-agent";
41
+ import { createShellTool, createWriteTool, createAskUserDecisionTool } from "@arnilo/prism-coding-agent";
42
42
 
43
43
  const shell = createShellTool(process.cwd(), {
44
44
  shellPath: "/bin/bash", // force bash; default: SHELL env → /bin/bash → sh
@@ -54,6 +54,14 @@ const remoteWrite = createWriteTool(process.cwd(), {
54
54
  mkdir: async (dir) => { /* mkdir -p remotely */ },
55
55
  },
56
56
  });
57
+
58
+ // Opt-in: not in createCodingTools(). Host owns the UI.
59
+ const askUser = createAskUserDecisionTool({
60
+ ask: async ({ question, options }) => {
61
+ const selectedId = await host.promptChoice(question, options);
62
+ return { selectedId };
63
+ },
64
+ });
57
65
  ```
58
66
 
59
67
  ## Tools
@@ -67,6 +75,7 @@ const remoteWrite = createWriteTool(process.cwd(), {
67
75
  | `repo_list` | `{ path?, includeHidden?, maxDepth?, maxResults?, offset? }` | Deterministic relative entries; skips hidden/excluded basenames; does not follow symlinks; paginates with `nextOffset`. |
68
76
  | `repo_search` | `{ query, path?, mode?, caseSensitive?, includeHidden?, context?, maxMatches? }` | Literal (default) or bounded regex matches with context; skips binary/excluded paths; finite scan/match/time caps. |
69
77
  | `git_*` / `coding_check` | via `createGitTools(cwd, { commitIdentity, checks? })` | Opt-in structured Git status/diff/branch/worktree/apply/commit/PR-handoff and named checks. Not in `createCodingTools()`. |
78
+ | `ask_user_decision` | via `createAskUserDecisionTool({ ask })` | Opt-in user choice: question + options (3 pros/3 cons); `selectionMode` single\|multiple; `allowCustom` for XOR free-text; host `ask` returns `selectedId` / `selectedIds` / `customText`. Durable: `suspendAskUserDecision` + resume validators. Not in default aggregators. |
70
79
 
71
80
  ### pi name mapping
72
81
 
@@ -78,9 +87,9 @@ const remoteWrite = createWriteTool(process.cwd(), {
78
87
 
79
88
  ## Exports
80
89
 
81
- Factories: `createShellTool`, `createReadTool`, `createWriteTool`, `createEditTool`, `createRepoListTool`, `createRepoSearchTool`, `createCodingTools`, `createReadOnlyTools`, `createAllTools`, `createGitTools`, `createCodingCheckTool`, `createLocalBashOperations`, `createLocalRepositoryOperations`, `createGitOperations`.
90
+ Factories: `createShellTool`, `createReadTool`, `createWriteTool`, `createEditTool`, `createRepoListTool`, `createRepoSearchTool`, `createCodingTools`, `createReadOnlyTools`, `createAllTools`, `createGitTools`, `createCodingCheckTool`, `createAskUserDecisionTool`, `createLocalBashOperations`, `createLocalRepositoryOperations`, `createGitOperations`.
82
91
 
83
- Helpers: `detectSupportedImageMimeType`, `detectSupportedImageMimeTypeFromFile`, `getShellConfig`, `killProcessTree`, `waitForChildProcess`, `withFileMutationQueue`, `resolveRepositoryLimits`, `writeCodingPlanFile`, `readCodingPlanFile`, `buildCodingCheckpointMetadata`, `validateCodingCheckpointMetadata`, `assertCodingResumeAllowed`, `fingerprintJson`. Default/hard coding, repository, Git, and plan/checkpoint limit constants are exported for host configuration.
92
+ Helpers: `detectSupportedImageMimeType`, `detectSupportedImageMimeTypeFromFile`, `getShellConfig`, `killProcessTree`, `waitForChildProcess`, `withFileMutationQueue`, `resolveRepositoryLimits`, `writeCodingPlanFile`, `readCodingPlanFile`, `buildCodingCheckpointMetadata`, `validateCodingCheckpointMetadata`, `assertCodingResumeAllowed`, `fingerprintJson`, `runCodingGoalVerify`, `createCodingGoalVerifyWorkflow`, `suspendAskUserDecision`, `createAskUserDecisionResumeValidator`, `validateAskUserDecisionResume`, `validateAskUserDecisionAgentResume`. Default/hard coding, repository, Git, and plan/checkpoint limit constants are exported for host configuration.
84
93
 
85
94
  Option/operation types: `ToolsOptions`, `ShellToolOptions`/`BashOperations`, `ReadToolOptions`/`ReadOperations`/`ReadTextOptions`/`ReadTextResult`, `WriteToolOptions`/`WriteOperations`, `EditToolOptions`/`EditOperations`/`EditToolDetails`.
86
95
 
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Opt-in `ask_user_decision` tool: model proposes choices with pros/cons;
3
+ * host-injected `ask` blocks until the user picks one or more option ids.
4
+ *
5
+ * Durable path (opt-in): `suspendAskUserDecision` / `validateAskUserDecisionResume`
6
+ * compose `@arnilo/prism-workflows` suspend/resume — no Goal DB, no second store.
7
+ * Blocking `ask()` remains the default. Agent durable interruption kinds are
8
+ * unchanged; hosts reuse the same resume validator against host-held request data.
9
+ *
10
+ * Not included in `createCodingTools` / `createAllTools` / `createReadOnlyTools`.
11
+ */
12
+ import type { ExecutionPolicy, JsonObject, ToolDefinition } from "@arnilo/prism";
13
+ import { type WorkflowResumeValidator, type WorkflowSuspension } from "@arnilo/prism-workflows";
14
+ export declare const ASK_USER_DECISION_TOOL_NAME: "ask_user_decision";
15
+ export declare const ASK_USER_DECISION_SUSPEND_REASON: "ask_user_decision";
16
+ /** Exactly three rationale bullets per side. */
17
+ export declare const ASK_USER_DECISION_RATIONALE_COUNT: 3;
18
+ export declare const DEFAULT_MAX_ASK_USER_DECISION_OPTIONS = 6;
19
+ export declare const HARD_MAX_ASK_USER_DECISION_OPTIONS = 16;
20
+ export declare const DEFAULT_MAX_ASK_USER_DECISION_QUESTION_BYTES = 2048;
21
+ export declare const HARD_MAX_ASK_USER_DECISION_QUESTION_BYTES = 8192;
22
+ export declare const DEFAULT_MAX_ASK_USER_DECISION_LABEL_BYTES = 512;
23
+ export declare const HARD_MAX_ASK_USER_DECISION_LABEL_BYTES = 2048;
24
+ export declare const DEFAULT_MAX_ASK_USER_DECISION_BULLET_BYTES = 512;
25
+ export declare const HARD_MAX_ASK_USER_DECISION_BULLET_BYTES = 2048;
26
+ /** Same ceiling as question text — free-text answers stay short. */
27
+ export declare const DEFAULT_MAX_ASK_USER_DECISION_CUSTOM_BYTES = 2048;
28
+ export declare const HARD_MAX_ASK_USER_DECISION_CUSTOM_BYTES = 8192;
29
+ export type AskUserDecisionSelectionMode = "single" | "multiple";
30
+ export interface AskUserDecisionOption {
31
+ readonly id: string;
32
+ readonly label: string;
33
+ readonly pros: readonly [string, string, string];
34
+ readonly cons: readonly [string, string, string];
35
+ }
36
+ export interface AskUserDecisionRequest {
37
+ readonly question: string;
38
+ readonly options: readonly AskUserDecisionOption[];
39
+ readonly selectionMode: AskUserDecisionSelectionMode;
40
+ readonly allowCustom: boolean;
41
+ readonly toolCallId: string;
42
+ readonly sessionId?: string;
43
+ readonly runId?: string;
44
+ readonly signal?: AbortSignal;
45
+ }
46
+ /**
47
+ * Host answer shapes. Custom text is XOR with selection (v1): never both.
48
+ * `allowCustom` must be true for `{ customText }`.
49
+ */
50
+ export type AskUserDecisionAnswer = {
51
+ readonly selectedId: string;
52
+ readonly selectedIds?: never;
53
+ readonly customText?: never;
54
+ } | {
55
+ readonly selectedIds: readonly string[];
56
+ readonly selectedId?: never;
57
+ readonly customText?: never;
58
+ } | {
59
+ readonly selectedId: string;
60
+ readonly selectedIds: readonly string[];
61
+ readonly customText?: never;
62
+ } | {
63
+ readonly customText: string;
64
+ readonly selectedId?: never;
65
+ readonly selectedIds?: never;
66
+ };
67
+ export type ResolvedAskUserDecisionAnswer = {
68
+ readonly kind: "selection";
69
+ readonly selectedId: string;
70
+ readonly selectedIds: readonly string[];
71
+ } | {
72
+ readonly kind: "custom";
73
+ readonly customText: string;
74
+ };
75
+ export type AskUserDecisionHandler = (request: AskUserDecisionRequest) => Promise<AskUserDecisionAnswer>;
76
+ export interface AskUserDecisionToolOptions {
77
+ /** Required host UI/callback — tool fails closed without it. */
78
+ readonly ask: AskUserDecisionHandler;
79
+ readonly executionPolicy?: ExecutionPolicy;
80
+ readonly maxOptions?: number;
81
+ readonly maxQuestionBytes?: number;
82
+ readonly maxLabelBytes?: number;
83
+ readonly maxBulletBytes?: number;
84
+ readonly maxCustomTextBytes?: number;
85
+ }
86
+ export interface ResolvedAskUserDecisionLimits {
87
+ readonly maxOptions: number;
88
+ readonly maxQuestionBytes: number;
89
+ readonly maxLabelBytes: number;
90
+ readonly maxBulletBytes: number;
91
+ readonly maxCustomTextBytes: number;
92
+ }
93
+ export declare function resolveAskUserDecisionLimits(options?: Pick<AskUserDecisionToolOptions, "maxOptions" | "maxQuestionBytes" | "maxLabelBytes" | "maxBulletBytes" | "maxCustomTextBytes">): ResolvedAskUserDecisionLimits;
94
+ /** Normalize host answer against mode + allowCustom. Exported for tests. */
95
+ export declare function resolveAskUserDecisionAnswer(answer: AskUserDecisionAnswer | null | undefined, selectionMode: AskUserDecisionSelectionMode, options: readonly AskUserDecisionOption[], gates: {
96
+ readonly allowCustom: boolean;
97
+ readonly maxCustomTextBytes: number;
98
+ }): ResolvedAskUserDecisionAnswer;
99
+ /** Parse + validate model args into a bounded decision request. Exported for tests. */
100
+ export declare function parseAskUserDecisionArgs(args: Record<string, unknown>, limits: ResolvedAskUserDecisionLimits): {
101
+ question: string;
102
+ options: AskUserDecisionOption[];
103
+ selectionMode: AskUserDecisionSelectionMode;
104
+ allowCustom: boolean;
105
+ };
106
+ /**
107
+ * Create the opt-in `ask_user_decision` tool.
108
+ * Host must supply `ask`; factory throws if missing.
109
+ */
110
+ export declare function createAskUserDecisionTool(options: AskUserDecisionToolOptions): ToolDefinition;
111
+ /** Durable decision payload for workflow suspension `data` (no AbortSignal / secrets). */
112
+ export interface AskUserDecisionSuspendData {
113
+ readonly question: string;
114
+ readonly options: readonly AskUserDecisionOption[];
115
+ readonly selectionMode: AskUserDecisionSelectionMode;
116
+ readonly allowCustom: boolean;
117
+ readonly toolCallId?: string;
118
+ readonly sessionId?: string;
119
+ readonly runId?: string;
120
+ }
121
+ export interface SuspendAskUserDecisionOptions {
122
+ readonly reason?: string;
123
+ readonly maxCustomTextBytes?: number;
124
+ }
125
+ /** JSON Schema describing resume `input` (= AskUserDecisionAnswer). */
126
+ export declare function askUserDecisionResumeSchema(request: Pick<AskUserDecisionSuspendData, "selectionMode" | "allowCustom" | "options">): JsonObject;
127
+ export declare function toAskUserDecisionSuspendData(request: {
128
+ readonly question: string;
129
+ readonly options: readonly AskUserDecisionOption[];
130
+ readonly selectionMode: AskUserDecisionSelectionMode;
131
+ readonly allowCustom: boolean;
132
+ readonly toolCallId?: string;
133
+ readonly sessionId?: string;
134
+ readonly runId?: string;
135
+ }): AskUserDecisionSuspendData;
136
+ /**
137
+ * Return from a workflow node to pause for a user decision (opt-in durable path).
138
+ * Host resumes via `resumeWorkflow` + `createAskUserDecisionResumeValidator` / `validateAskUserDecisionResume`.
139
+ */
140
+ export declare function suspendAskUserDecision(request: AskUserDecisionSuspendData | Pick<AskUserDecisionRequest, "question" | "options" | "selectionMode" | "allowCustom" | "toolCallId" | "sessionId" | "runId">, options?: SuspendAskUserDecisionOptions): WorkflowSuspension<AskUserDecisionAnswer>;
141
+ /**
142
+ * Validate resume input against the original decision request.
143
+ * Shared by workflow `validateResume` and host-held agent resume adapters.
144
+ */
145
+ export declare function validateAskUserDecisionResume(request: AskUserDecisionSuspendData, value: unknown, limits?: Pick<ResolvedAskUserDecisionLimits, "maxCustomTextBytes">): ResolvedAskUserDecisionAnswer;
146
+ /**
147
+ * Workflow `validateResume` adapter. Reads durable request from `suspension.data`
148
+ * (written by `suspendAskUserDecision`). Deny paths skip answer validation.
149
+ */
150
+ export declare function createAskUserDecisionResumeValidator(limits?: Pick<ResolvedAskUserDecisionLimits, "maxCustomTextBytes">): WorkflowResumeValidator;
151
+ /**
152
+ * Thin agent-path adapter: same validation as workflow resume, for hosts that
153
+ * persist `AskUserDecisionSuspendData` outside `AgentRunInterruption` (core kinds
154
+ * unchanged in 0.0.12). Call after operator supplies an answer.
155
+ */
156
+ export declare function validateAskUserDecisionAgentResume(input: {
157
+ readonly request: AskUserDecisionSuspendData;
158
+ readonly answer: unknown;
159
+ readonly maxCustomTextBytes?: number;
160
+ }): ResolvedAskUserDecisionAnswer;
@@ -0,0 +1,471 @@
1
+ import { suspend, } from "@arnilo/prism-workflows";
2
+ import { enforceExecutionPolicy } from "./execution-policy.js";
3
+ import { validateCodingLimit } from "./limits.js";
4
+ export const ASK_USER_DECISION_TOOL_NAME = "ask_user_decision";
5
+ export const ASK_USER_DECISION_SUSPEND_REASON = "ask_user_decision";
6
+ /** Exactly three rationale bullets per side. */
7
+ export const ASK_USER_DECISION_RATIONALE_COUNT = 3;
8
+ export const DEFAULT_MAX_ASK_USER_DECISION_OPTIONS = 6;
9
+ export const HARD_MAX_ASK_USER_DECISION_OPTIONS = 16;
10
+ export const DEFAULT_MAX_ASK_USER_DECISION_QUESTION_BYTES = 2_048;
11
+ export const HARD_MAX_ASK_USER_DECISION_QUESTION_BYTES = 8_192;
12
+ export const DEFAULT_MAX_ASK_USER_DECISION_LABEL_BYTES = 512;
13
+ export const HARD_MAX_ASK_USER_DECISION_LABEL_BYTES = 2_048;
14
+ export const DEFAULT_MAX_ASK_USER_DECISION_BULLET_BYTES = 512;
15
+ export const HARD_MAX_ASK_USER_DECISION_BULLET_BYTES = 2_048;
16
+ /** Same ceiling as question text — free-text answers stay short. */
17
+ export const DEFAULT_MAX_ASK_USER_DECISION_CUSTOM_BYTES = DEFAULT_MAX_ASK_USER_DECISION_QUESTION_BYTES;
18
+ export const HARD_MAX_ASK_USER_DECISION_CUSTOM_BYTES = HARD_MAX_ASK_USER_DECISION_QUESTION_BYTES;
19
+ export function resolveAskUserDecisionLimits(options) {
20
+ return {
21
+ maxOptions: validateCodingLimit("maxOptions", options?.maxOptions ?? DEFAULT_MAX_ASK_USER_DECISION_OPTIONS, HARD_MAX_ASK_USER_DECISION_OPTIONS),
22
+ maxQuestionBytes: validateCodingLimit("maxQuestionBytes", options?.maxQuestionBytes ?? DEFAULT_MAX_ASK_USER_DECISION_QUESTION_BYTES, HARD_MAX_ASK_USER_DECISION_QUESTION_BYTES),
23
+ maxLabelBytes: validateCodingLimit("maxLabelBytes", options?.maxLabelBytes ?? DEFAULT_MAX_ASK_USER_DECISION_LABEL_BYTES, HARD_MAX_ASK_USER_DECISION_LABEL_BYTES),
24
+ maxBulletBytes: validateCodingLimit("maxBulletBytes", options?.maxBulletBytes ?? DEFAULT_MAX_ASK_USER_DECISION_BULLET_BYTES, HARD_MAX_ASK_USER_DECISION_BULLET_BYTES),
25
+ maxCustomTextBytes: validateCodingLimit("maxCustomTextBytes", options?.maxCustomTextBytes ?? DEFAULT_MAX_ASK_USER_DECISION_CUSTOM_BYTES, HARD_MAX_ASK_USER_DECISION_CUSTOM_BYTES),
26
+ };
27
+ }
28
+ function errorResult(toolCallId, message) {
29
+ return {
30
+ toolCallId,
31
+ name: ASK_USER_DECISION_TOOL_NAME,
32
+ content: [{ type: "text", text: message }],
33
+ error: { message },
34
+ };
35
+ }
36
+ function assertByteLimit(label, text, maxBytes) {
37
+ const bytes = Buffer.byteLength(text, "utf8");
38
+ if (bytes < 1 || bytes > maxBytes) {
39
+ throw new Error(`${label} must be 1..${maxBytes} UTF-8 bytes`);
40
+ }
41
+ if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/.test(text)) {
42
+ throw new Error(`${label} contains control characters`);
43
+ }
44
+ }
45
+ function requireThreeBullets(value, label, maxBytes) {
46
+ if (!Array.isArray(value) || value.length !== ASK_USER_DECISION_RATIONALE_COUNT) {
47
+ throw new Error(`${label} must be exactly ${ASK_USER_DECISION_RATIONALE_COUNT} strings`);
48
+ }
49
+ const out = [];
50
+ for (let i = 0; i < ASK_USER_DECISION_RATIONALE_COUNT; i++) {
51
+ const item = value[i];
52
+ if (typeof item !== "string") {
53
+ throw new Error(`${label}[${i}] must be a string`);
54
+ }
55
+ const trimmed = item.trim();
56
+ assertByteLimit(`${label}[${i}]`, trimmed, maxBytes);
57
+ out.push(trimmed);
58
+ }
59
+ return out;
60
+ }
61
+ function parseSelectionMode(value) {
62
+ if (value === undefined || value === null)
63
+ return "single";
64
+ if (value === "single" || value === "multiple")
65
+ return value;
66
+ throw new Error('selectionMode must be "single" or "multiple"');
67
+ }
68
+ function hasSelectionFields(answer) {
69
+ if (!answer)
70
+ return false;
71
+ if (typeof answer.selectedId === "string" && answer.selectedId.trim() !== "")
72
+ return true;
73
+ return Array.isArray(answer.selectedIds) && answer.selectedIds.length > 0;
74
+ }
75
+ /** Normalize host answer against mode + allowCustom. Exported for tests. */
76
+ export function resolveAskUserDecisionAnswer(answer, selectionMode, options, gates) {
77
+ const customRaw = answer && typeof answer.customText === "string" ? answer.customText.trim() : "";
78
+ const hasCustom = customRaw.length > 0;
79
+ const hasSelection = hasSelectionFields(answer);
80
+ if (hasCustom && hasSelection) {
81
+ throw new Error("customText is mutually exclusive with selectedId/selectedIds");
82
+ }
83
+ if (hasCustom) {
84
+ if (!gates.allowCustom)
85
+ throw new Error("customText rejected (allowCustom=false)");
86
+ assertByteLimit("customText", customRaw, gates.maxCustomTextBytes);
87
+ return { kind: "custom", customText: customRaw };
88
+ }
89
+ const byId = new Map(options.map((o) => [o.id, o]));
90
+ const rawIds = [];
91
+ if (answer && Array.isArray(answer.selectedIds)) {
92
+ for (const id of answer.selectedIds) {
93
+ if (typeof id !== "string")
94
+ throw new Error("selectedIds entries must be strings");
95
+ rawIds.push(id.trim());
96
+ }
97
+ }
98
+ if (answer && typeof answer.selectedId === "string") {
99
+ const id = answer.selectedId.trim();
100
+ if (rawIds.length === 0)
101
+ rawIds.push(id);
102
+ else if (!(rawIds.length === 1 && rawIds[0] === id)) {
103
+ throw new Error("selectedId and selectedIds disagree");
104
+ }
105
+ }
106
+ if (rawIds.length === 0) {
107
+ throw new Error(gates.allowCustom
108
+ ? "ask() must return selectedId/selectedIds or customText"
109
+ : selectionMode === "multiple"
110
+ ? "ask() must return non-empty selectedIds"
111
+ : "ask() must return selectedId");
112
+ }
113
+ const seen = new Set();
114
+ const selectedIds = [];
115
+ for (const id of rawIds) {
116
+ if (!byId.has(id))
117
+ throw new Error(`ask() returned unknown selectedId: ${id}`);
118
+ if (seen.has(id))
119
+ throw new Error(`duplicate selectedId: ${id}`);
120
+ seen.add(id);
121
+ selectedIds.push(id);
122
+ }
123
+ if (selectionMode === "single" && selectedIds.length !== 1) {
124
+ // Single accepts selectedIds only when length is exactly 1.
125
+ throw new Error("single selectionMode requires exactly one selected id");
126
+ }
127
+ return { kind: "selection", selectedIds, selectedId: selectedIds[0] };
128
+ }
129
+ function parseAllowCustom(value) {
130
+ if (value === undefined || value === null)
131
+ return false;
132
+ if (typeof value !== "boolean")
133
+ throw new Error("allowCustom must be a boolean");
134
+ return value;
135
+ }
136
+ /** Parse + validate model args into a bounded decision request. Exported for tests. */
137
+ export function parseAskUserDecisionArgs(args, limits) {
138
+ if (typeof args.question !== "string") {
139
+ throw new Error("question must be a string");
140
+ }
141
+ const question = args.question.trim();
142
+ assertByteLimit("question", question, limits.maxQuestionBytes);
143
+ const selectionMode = parseSelectionMode(args.selectionMode);
144
+ const allowCustom = parseAllowCustom(args.allowCustom);
145
+ if (!Array.isArray(args.options)) {
146
+ throw new Error("options must be an array");
147
+ }
148
+ if (args.options.length < 2) {
149
+ throw new Error("options must include at least 2 choices");
150
+ }
151
+ if (args.options.length > limits.maxOptions) {
152
+ throw new Error(`options exceeds maxOptions (${limits.maxOptions})`);
153
+ }
154
+ const seen = new Set();
155
+ const options = [];
156
+ for (let i = 0; i < args.options.length; i++) {
157
+ const raw = args.options[i];
158
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
159
+ throw new Error(`options[${i}] must be an object`);
160
+ }
161
+ const row = raw;
162
+ if (typeof row.id !== "string")
163
+ throw new Error(`options[${i}].id must be a string`);
164
+ const id = row.id.trim();
165
+ if (!/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(id)) {
166
+ throw new Error(`options[${i}].id has invalid format`);
167
+ }
168
+ if (seen.has(id))
169
+ throw new Error(`duplicate option id: ${id}`);
170
+ seen.add(id);
171
+ if (typeof row.label !== "string")
172
+ throw new Error(`options[${i}].label must be a string`);
173
+ const label = row.label.trim();
174
+ assertByteLimit(`options[${i}].label`, label, limits.maxLabelBytes);
175
+ options.push({
176
+ id,
177
+ label,
178
+ pros: requireThreeBullets(row.pros, `options[${i}].pros`, limits.maxBulletBytes),
179
+ cons: requireThreeBullets(row.cons, `options[${i}].cons`, limits.maxBulletBytes),
180
+ });
181
+ }
182
+ return { question, options, selectionMode, allowCustom };
183
+ }
184
+ /**
185
+ * Create the opt-in `ask_user_decision` tool.
186
+ * Host must supply `ask`; factory throws if missing.
187
+ */
188
+ export function createAskUserDecisionTool(options) {
189
+ if (typeof options?.ask !== "function") {
190
+ throw new Error("ask_user_decision requires options.ask");
191
+ }
192
+ const limits = resolveAskUserDecisionLimits(options);
193
+ const ask = options.ask;
194
+ return {
195
+ name: ASK_USER_DECISION_TOOL_NAME,
196
+ description: "Ask the user to choose a direction when instructions are ambiguous and the choice matters. " +
197
+ "Provide 2+ options; each option MUST include exactly 3 pros and 3 cons. " +
198
+ 'Use selectionMode "multiple" when several options may apply together; default is single choice. ' +
199
+ "Set allowCustom=true only when a short free-text alternative to the listed options is acceptable " +
200
+ "(custom answer is mutually exclusive with selecting option ids). " +
201
+ "Do not use for trivia, confirmations that are already clear, or when a single safe default exists.",
202
+ exclusive: true,
203
+ parameters: {
204
+ type: "object",
205
+ properties: {
206
+ question: {
207
+ type: "string",
208
+ description: "Clear decision question for the user",
209
+ },
210
+ selectionMode: {
211
+ type: "string",
212
+ enum: ["single", "multiple"],
213
+ description: 'single (default) or multiple selection',
214
+ },
215
+ allowCustom: {
216
+ type: "boolean",
217
+ description: "When true, host may return customText instead of selecting option ids (XOR). Default false.",
218
+ },
219
+ options: {
220
+ type: "array",
221
+ minItems: 2,
222
+ maxItems: limits.maxOptions,
223
+ items: {
224
+ type: "object",
225
+ properties: {
226
+ id: {
227
+ type: "string",
228
+ description: "Stable option id returned when selected (e.g. keep_sqlite)",
229
+ },
230
+ label: {
231
+ type: "string",
232
+ description: "User-facing option label",
233
+ },
234
+ pros: {
235
+ type: "array",
236
+ minItems: 3,
237
+ maxItems: 3,
238
+ items: { type: "string" },
239
+ description: "Exactly 3 advantages of this option",
240
+ },
241
+ cons: {
242
+ type: "array",
243
+ minItems: 3,
244
+ maxItems: 3,
245
+ items: { type: "string" },
246
+ description: "Exactly 3 disadvantages of this option",
247
+ },
248
+ },
249
+ required: ["id", "label", "pros", "cons"],
250
+ additionalProperties: false,
251
+ },
252
+ description: `2..${limits.maxOptions} options with pros/cons`,
253
+ },
254
+ },
255
+ required: ["question", "options"],
256
+ additionalProperties: false,
257
+ },
258
+ async execute(args, context) {
259
+ const toolCallId = context.toolCallId;
260
+ if (context.signal?.aborted)
261
+ return errorResult(toolCallId, "Operation aborted");
262
+ let parsed;
263
+ try {
264
+ parsed = parseAskUserDecisionArgs(args, limits);
265
+ }
266
+ catch (error) {
267
+ return errorResult(toolCallId, error instanceof Error ? error.message : String(error));
268
+ }
269
+ const policyCheck = await enforceExecutionPolicy(options.executionPolicy, {
270
+ kind: "ask_user_decision",
271
+ operation: "ask",
272
+ risk: "medium",
273
+ metadata: {
274
+ optionCount: parsed.options.length,
275
+ optionIds: parsed.options.map((o) => o.id),
276
+ selectionMode: parsed.selectionMode,
277
+ allowCustom: parsed.allowCustom,
278
+ sessionId: context.sessionId,
279
+ runId: context.runId,
280
+ signal: context.signal,
281
+ },
282
+ }, toolCallId, ASK_USER_DECISION_TOOL_NAME);
283
+ if (!policyCheck.allowed)
284
+ return policyCheck.result;
285
+ let answer;
286
+ try {
287
+ answer = await ask({
288
+ question: parsed.question,
289
+ options: parsed.options,
290
+ selectionMode: parsed.selectionMode,
291
+ allowCustom: parsed.allowCustom,
292
+ toolCallId,
293
+ sessionId: context.sessionId,
294
+ runId: context.runId,
295
+ signal: context.signal,
296
+ });
297
+ }
298
+ catch (error) {
299
+ return errorResult(toolCallId, error instanceof Error ? error.message : String(error));
300
+ }
301
+ if (context.signal?.aborted)
302
+ return errorResult(toolCallId, "Operation aborted");
303
+ let resolved;
304
+ try {
305
+ resolved = resolveAskUserDecisionAnswer(answer, parsed.selectionMode, parsed.options, {
306
+ allowCustom: parsed.allowCustom,
307
+ maxCustomTextBytes: limits.maxCustomTextBytes,
308
+ });
309
+ }
310
+ catch (error) {
311
+ return errorResult(toolCallId, error instanceof Error ? error.message : String(error));
312
+ }
313
+ if (resolved.kind === "custom") {
314
+ return {
315
+ toolCallId,
316
+ name: ASK_USER_DECISION_TOOL_NAME,
317
+ content: [
318
+ {
319
+ type: "text",
320
+ text: `User provided custom answer: ${resolved.customText}`,
321
+ },
322
+ ],
323
+ metadata: {
324
+ customText: resolved.customText,
325
+ selectionMode: parsed.selectionMode,
326
+ allowCustom: parsed.allowCustom,
327
+ question: parsed.question,
328
+ options: parsed.options,
329
+ },
330
+ };
331
+ }
332
+ const selected = resolved.selectedIds.map((id) => parsed.options.find((o) => o.id === id));
333
+ const labelText = selected.map((o) => `"${o.label}" (id=${o.id})`).join(", ");
334
+ return {
335
+ toolCallId,
336
+ name: ASK_USER_DECISION_TOOL_NAME,
337
+ content: [
338
+ {
339
+ type: "text",
340
+ text: parsed.selectionMode === "multiple"
341
+ ? `User selected ${selected.length} option(s): ${labelText}.`
342
+ : `User selected ${labelText}.`,
343
+ },
344
+ ],
345
+ metadata: {
346
+ selectedId: resolved.selectedId,
347
+ selectedIds: resolved.selectedIds,
348
+ selectedLabels: selected.map((o) => o.label),
349
+ selectionMode: parsed.selectionMode,
350
+ allowCustom: parsed.allowCustom,
351
+ question: parsed.question,
352
+ options: parsed.options,
353
+ },
354
+ };
355
+ },
356
+ };
357
+ }
358
+ /** JSON Schema describing resume `input` (= AskUserDecisionAnswer). */
359
+ export function askUserDecisionResumeSchema(request) {
360
+ const optionIds = request.options.map((o) => o.id);
361
+ const selectionProps = {
362
+ selectedId: { type: "string", enum: optionIds },
363
+ selectedIds: {
364
+ type: "array",
365
+ minItems: 1,
366
+ maxItems: optionIds.length,
367
+ items: { type: "string", enum: optionIds },
368
+ },
369
+ };
370
+ if (!request.allowCustom) {
371
+ return {
372
+ type: "object",
373
+ additionalProperties: false,
374
+ properties: selectionProps,
375
+ // Host may send either field; tool/validator enforces mode + XOR with custom.
376
+ anyOf: [{ required: ["selectedId"] }, { required: ["selectedIds"] }],
377
+ };
378
+ }
379
+ return {
380
+ type: "object",
381
+ additionalProperties: false,
382
+ properties: {
383
+ ...selectionProps,
384
+ customText: {
385
+ type: "string",
386
+ minLength: 1,
387
+ maxLength: DEFAULT_MAX_ASK_USER_DECISION_CUSTOM_BYTES,
388
+ },
389
+ },
390
+ anyOf: [
391
+ { required: ["selectedId"] },
392
+ { required: ["selectedIds"] },
393
+ { required: ["customText"] },
394
+ ],
395
+ };
396
+ }
397
+ export function toAskUserDecisionSuspendData(request) {
398
+ return {
399
+ question: request.question,
400
+ options: request.options,
401
+ selectionMode: request.selectionMode,
402
+ allowCustom: request.allowCustom,
403
+ ...(request.toolCallId ? { toolCallId: request.toolCallId } : {}),
404
+ ...(request.sessionId ? { sessionId: request.sessionId } : {}),
405
+ ...(request.runId ? { runId: request.runId } : {}),
406
+ };
407
+ }
408
+ function isAskUserDecisionSuspendData(value) {
409
+ if (!value || typeof value !== "object" || Array.isArray(value))
410
+ return false;
411
+ const row = value;
412
+ return (typeof row.question === "string"
413
+ && Array.isArray(row.options)
414
+ && (row.selectionMode === "single" || row.selectionMode === "multiple")
415
+ && typeof row.allowCustom === "boolean");
416
+ }
417
+ /**
418
+ * Return from a workflow node to pause for a user decision (opt-in durable path).
419
+ * Host resumes via `resumeWorkflow` + `createAskUserDecisionResumeValidator` / `validateAskUserDecisionResume`.
420
+ */
421
+ export function suspendAskUserDecision(request, options) {
422
+ const data = toAskUserDecisionSuspendData(request);
423
+ if (data.options.length < 2) {
424
+ throw new Error("suspendAskUserDecision requires at least 2 options");
425
+ }
426
+ return suspend({
427
+ reason: options?.reason ?? ASK_USER_DECISION_SUSPEND_REASON,
428
+ data,
429
+ resumeSchema: askUserDecisionResumeSchema(data),
430
+ });
431
+ }
432
+ /**
433
+ * Validate resume input against the original decision request.
434
+ * Shared by workflow `validateResume` and host-held agent resume adapters.
435
+ */
436
+ export function validateAskUserDecisionResume(request, value, limits) {
437
+ if (!isAskUserDecisionSuspendData(request)) {
438
+ throw new Error("invalid ask_user_decision suspend data");
439
+ }
440
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
441
+ throw new Error("resume input must be an ask_user_decision answer object");
442
+ }
443
+ const maxCustomTextBytes = limits?.maxCustomTextBytes ?? DEFAULT_MAX_ASK_USER_DECISION_CUSTOM_BYTES;
444
+ return resolveAskUserDecisionAnswer(value, request.selectionMode, request.options, { allowCustom: request.allowCustom, maxCustomTextBytes });
445
+ }
446
+ /**
447
+ * Workflow `validateResume` adapter. Reads durable request from `suspension.data`
448
+ * (written by `suspendAskUserDecision`). Deny paths skip answer validation.
449
+ */
450
+ export function createAskUserDecisionResumeValidator(limits) {
451
+ return (input) => {
452
+ if (!isAskUserDecisionSuspendData(input.suspension.data)) {
453
+ throw new Error("suspension.data missing ask_user_decision request");
454
+ }
455
+ // Deny (and other no-input resumes) may omit answer; approve supplies it.
456
+ if (input.value === undefined || input.value === null)
457
+ return;
458
+ validateAskUserDecisionResume(input.suspension.data, input.value, limits);
459
+ };
460
+ }
461
+ /**
462
+ * Thin agent-path adapter: same validation as workflow resume, for hosts that
463
+ * persist `AskUserDecisionSuspendData` outside `AgentRunInterruption` (core kinds
464
+ * unchanged in 0.0.12). Call after operator supplies an answer.
465
+ */
466
+ export function validateAskUserDecisionAgentResume(input) {
467
+ return validateAskUserDecisionResume(input.request, input.answer, input.maxCustomTextBytes === undefined
468
+ ? undefined
469
+ : { maxCustomTextBytes: input.maxCustomTextBytes });
470
+ }
471
+ //# sourceMappingURL=ask-user-decision.js.map
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Thin goal→verify composition: plan Markdown + named checks + workflow
3
+ * suspend/approve + bounded handoff. Not a second agent/workflow engine.
4
+ */
5
+ import type { OwnershipScope, SecretRedactor } from "@arnilo/prism";
6
+ import { type WorkflowCheckpointAdapter, type WorkflowEvent, type WorkflowResumeValidator, type WorkflowRunResult } from "@arnilo/prism-workflows";
7
+ import { type CodingCheckSummary, type CodingCheckpointMetadata, type CodingHandoffSummary } from "./coding-checkpoint.js";
8
+ export declare const CODING_GOAL_VERIFY_WORKFLOW_ID: "coding-goal-verify";
9
+ export declare const CODING_GOAL_VERIFY_REVISION: "1";
10
+ export declare const CODING_GOAL_VERIFY_SUSPEND_REASON: "approve-coding-goal-verify";
11
+ export declare class CodingGoalVerifyError extends Error {
12
+ readonly code = "ERR_PRISM_CODING_GOAL_VERIFY";
13
+ constructor(message: string);
14
+ }
15
+ export interface CodingGoalVerifyApproval {
16
+ /** Host validator; required — helper fails closed without it. */
17
+ readonly validateResume: WorkflowResumeValidator;
18
+ readonly reason?: string;
19
+ }
20
+ export interface RunCodingGoalVerifyOptions {
21
+ readonly goal: string;
22
+ readonly cwd: string;
23
+ readonly taskId?: string;
24
+ readonly title?: string;
25
+ readonly baseBranch?: string;
26
+ readonly branch?: string;
27
+ /** Named checks to execute via `runCheck` (host-declared; no free-form shell). */
28
+ readonly checks: readonly string[];
29
+ readonly runCheck: (name: string) => Promise<CodingCheckSummary>;
30
+ /** Host-owned bounded handoff; required before completion. */
31
+ readonly buildHandoff: (input: {
32
+ readonly coding: CodingCheckpointMetadata;
33
+ readonly checks: readonly CodingCheckSummary[];
34
+ }) => Promise<CodingHandoffSummary>;
35
+ readonly approval: CodingGoalVerifyApproval;
36
+ readonly checkpoints: WorkflowCheckpointAdapter;
37
+ readonly ownership?: OwnershipScope;
38
+ readonly redactor?: SecretRedactor;
39
+ readonly signal?: AbortSignal;
40
+ readonly onEvent?: (event: WorkflowEvent) => void;
41
+ /** Second call after suspension — mirrors workflow resume. */
42
+ readonly resume?: {
43
+ readonly runId: string;
44
+ readonly decision: "approve" | "deny";
45
+ readonly expectedVersion: number;
46
+ readonly input?: unknown;
47
+ };
48
+ }
49
+ /** Build the durable DAG used by `runCodingGoalVerify` (exported for hosts that want the definition alone). */
50
+ export declare function createCodingGoalVerifyWorkflow(options: {
51
+ readonly goal: string;
52
+ readonly cwd: string;
53
+ readonly taskId: string;
54
+ readonly title: string;
55
+ readonly baseBranch: string;
56
+ readonly branch: string;
57
+ readonly checks: readonly string[];
58
+ readonly runCheck: (name: string) => Promise<CodingCheckSummary>;
59
+ readonly buildHandoff: RunCodingGoalVerifyOptions["buildHandoff"];
60
+ readonly suspendReason: string;
61
+ }): import("@arnilo/prism-workflows").WorkflowDefinition;
62
+ /**
63
+ * Run (or resume) a thin goal→verify coding composition.
64
+ * Fails closed when `approval` / `approval.validateResume` is missing.
65
+ */
66
+ export declare function runCodingGoalVerify(options: RunCodingGoalVerifyOptions): Promise<WorkflowRunResult>;
@@ -0,0 +1,283 @@
1
+ import { defineWorkflow, functionNode, resumeWorkflow, runWorkflow, suspend, } from "@arnilo/prism-workflows";
2
+ import { CODING_STATE_KEY, assertCodingResumeAllowed, buildCodingCheckpointMetadata, codingCheckpointStatePatch, codingPlanPathForTask, createCodingPlanMarkdown, fingerprintJson, parseCodingPlanTodos, readCodingCheckpointFromState, readCodingPlanFile, writeCodingPlanFile, } from "./coding-checkpoint.js";
3
+ import { DEFAULT_MAX_CHECK_SUMMARY_BYTES, DEFAULT_MAX_PR_HANDOFF_BYTES } from "./limits.js";
4
+ export const CODING_GOAL_VERIFY_WORKFLOW_ID = "coding-goal-verify";
5
+ export const CODING_GOAL_VERIFY_REVISION = "1";
6
+ export const CODING_GOAL_VERIFY_SUSPEND_REASON = "approve-coding-goal-verify";
7
+ export class CodingGoalVerifyError extends Error {
8
+ code = "ERR_PRISM_CODING_GOAL_VERIFY";
9
+ constructor(message) {
10
+ super(message);
11
+ this.name = "CodingGoalVerifyError";
12
+ }
13
+ }
14
+ function requireCoding(state) {
15
+ const coding = readCodingCheckpointFromState(state);
16
+ if (!coding)
17
+ throw new CodingGoalVerifyError("missing state.coding");
18
+ return coding;
19
+ }
20
+ function clipSummary(summary) {
21
+ const max = DEFAULT_MAX_CHECK_SUMMARY_BYTES;
22
+ const bytes = Buffer.from(summary, "utf8");
23
+ if (bytes.length <= max)
24
+ return summary;
25
+ return bytes.subarray(0, max).toString("utf8");
26
+ }
27
+ function normalizeChecks(checks) {
28
+ return checks.map((check) => ({
29
+ name: check.name,
30
+ exitCode: check.exitCode,
31
+ summary: clipSummary(check.summary),
32
+ }));
33
+ }
34
+ function assertHandoffBounded(handoff) {
35
+ const encoded = Buffer.byteLength(JSON.stringify(handoff), "utf8");
36
+ if (encoded > DEFAULT_MAX_PR_HANDOFF_BYTES) {
37
+ throw new CodingGoalVerifyError(`handoff exceeds ${DEFAULT_MAX_PR_HANDOFF_BYTES} byte limit (${encoded} bytes)`);
38
+ }
39
+ }
40
+ function fingerprintsFor(checks) {
41
+ return {
42
+ workflowRevision: CODING_GOAL_VERIFY_REVISION,
43
+ toolFingerprint: fingerprintJson({ tools: ["coding_check", "git_pr_handoff"], checks }),
44
+ policyFingerprint: fingerprintJson({ requireApproval: [CODING_GOAL_VERIFY_SUSPEND_REASON] }),
45
+ };
46
+ }
47
+ function defaultTodos(checkNames) {
48
+ return [
49
+ { id: "plan", text: "Write goal plan Markdown", done: false },
50
+ ...checkNames.map((name) => ({ id: `check-${name}`, text: `Run named check ${name}`, done: false })),
51
+ { id: "handoff", text: "Emit bounded PR handoff", done: false },
52
+ ];
53
+ }
54
+ function markTodos(todos, doneIds) {
55
+ return todos.map((todo) => (doneIds.has(todo.id) ? { ...todo, done: true } : todo));
56
+ }
57
+ /** Build the durable DAG used by `runCodingGoalVerify` (exported for hosts that want the definition alone). */
58
+ export function createCodingGoalVerifyWorkflow(options) {
59
+ const planPath = codingPlanPathForTask(options.taskId);
60
+ const fps = () => fingerprintsFor(options.checks);
61
+ const planNode = functionNode({
62
+ execute: async (ctx) => {
63
+ const todos = defaultTodos(options.checks);
64
+ const markdown = createCodingPlanMarkdown({
65
+ title: options.title,
66
+ taskId: options.taskId,
67
+ status: "planned",
68
+ todos,
69
+ notes: options.goal,
70
+ });
71
+ const plan = await writeCodingPlanFile({
72
+ workspaceRoot: options.cwd,
73
+ planPath,
74
+ markdown,
75
+ });
76
+ const metadata = buildCodingCheckpointMetadata({
77
+ taskId: options.taskId,
78
+ workspaceRoot: options.cwd,
79
+ baseBranch: options.baseBranch,
80
+ branch: options.branch,
81
+ planPath,
82
+ plan,
83
+ fingerprints: fps(),
84
+ todos: parseCodingPlanTodos(markdown),
85
+ status: "planned",
86
+ });
87
+ await ctx.updateState(codingCheckpointStatePatch(metadata), { mode: "merge" });
88
+ return { planPath, planSha256: plan.sha256 };
89
+ },
90
+ });
91
+ const verifyNode = functionNode({
92
+ execute: async (ctx) => {
93
+ const coding = requireCoding(ctx.state);
94
+ const checks = normalizeChecks(await Promise.all(options.checks.map((name) => options.runCheck(name))));
95
+ const failed = checks.some((check) => check.exitCode !== 0);
96
+ const done = new Set([
97
+ "plan",
98
+ ...options.checks.map((name) => `check-${name}`),
99
+ ]);
100
+ const todos = markTodos(coding.todos.length ? coding.todos : defaultTodos(options.checks), done);
101
+ const markdown = createCodingPlanMarkdown({
102
+ title: options.title,
103
+ taskId: options.taskId,
104
+ status: failed ? "awaiting_approval" : "ready_for_handoff",
105
+ todos,
106
+ notes: options.goal,
107
+ });
108
+ const plan = await writeCodingPlanFile({
109
+ workspaceRoot: options.cwd,
110
+ planPath,
111
+ markdown,
112
+ });
113
+ const next = buildCodingCheckpointMetadata({
114
+ ...coding,
115
+ plan,
116
+ checks,
117
+ todos: parseCodingPlanTodos(markdown),
118
+ status: failed ? "awaiting_approval" : "ready_for_handoff",
119
+ fingerprints: fps(),
120
+ updatedAt: new Date().toISOString(),
121
+ });
122
+ await ctx.updateState(codingCheckpointStatePatch(next), { mode: "merge" });
123
+ return { checks, failed };
124
+ },
125
+ });
126
+ const reviewNode = functionNode({
127
+ execute: async (ctx) => {
128
+ const coding = requireCoding(ctx.state);
129
+ const failed = coding.checks.some((check) => check.exitCode !== 0);
130
+ if (!failed)
131
+ return { approved: true, skipped: true };
132
+ if (!ctx.resume) {
133
+ return suspend({
134
+ reason: options.suspendReason,
135
+ data: {
136
+ taskId: coding.taskId,
137
+ branch: coding.branch,
138
+ planSha256: coding.plan.sha256,
139
+ checks: coding.checks,
140
+ },
141
+ resumeSchema: {
142
+ type: "object",
143
+ required: ["reviewer"],
144
+ properties: { reviewer: { type: "string" } },
145
+ },
146
+ });
147
+ }
148
+ const reviewer = ctx.resume.input?.reviewer ?? "unknown";
149
+ return { approved: true, reviewer, planSha256: coding.plan.sha256 };
150
+ },
151
+ });
152
+ const handoffNode = functionNode({
153
+ execute: async (ctx) => {
154
+ const coding = requireCoding(ctx.state);
155
+ const planFile = await readCodingPlanFile({
156
+ workspaceRoot: options.cwd,
157
+ planPath: coding.planPath,
158
+ expected: coding.plan,
159
+ });
160
+ assertCodingResumeAllowed({
161
+ metadata: coding,
162
+ expected: fps(),
163
+ expectedWorkspaceRoot: options.cwd,
164
+ expectedBaseBranch: options.baseBranch,
165
+ planBytes: Buffer.from(planFile.markdown, "utf8"),
166
+ });
167
+ const handoff = await options.buildHandoff({ coding, checks: coding.checks });
168
+ assertHandoffBounded(handoff);
169
+ const todos = markTodos(coding.todos.length ? coding.todos : defaultTodos(options.checks), new Set(["plan", "handoff", ...options.checks.map((name) => `check-${name}`)]));
170
+ const markdown = createCodingPlanMarkdown({
171
+ title: options.title,
172
+ taskId: options.taskId,
173
+ status: "completed",
174
+ todos,
175
+ notes: options.goal,
176
+ });
177
+ const plan = await writeCodingPlanFile({
178
+ workspaceRoot: options.cwd,
179
+ planPath,
180
+ markdown,
181
+ });
182
+ const next = buildCodingCheckpointMetadata({
183
+ ...coding,
184
+ plan,
185
+ handoff,
186
+ todos: parseCodingPlanTodos(markdown),
187
+ status: "completed",
188
+ fingerprints: fps(),
189
+ updatedAt: new Date().toISOString(),
190
+ });
191
+ await ctx.updateState(codingCheckpointStatePatch(next), { mode: "merge" });
192
+ return { handoff, codingStatus: next.status };
193
+ },
194
+ });
195
+ return defineWorkflow({
196
+ revision: CODING_GOAL_VERIFY_REVISION,
197
+ id: CODING_GOAL_VERIFY_WORKFLOW_ID,
198
+ nodes: {
199
+ plan: planNode,
200
+ verify: verifyNode,
201
+ review: reviewNode,
202
+ handoff: handoffNode,
203
+ },
204
+ edges: [
205
+ ["plan", "verify"],
206
+ ["verify", "review"],
207
+ ["review", "handoff"],
208
+ ],
209
+ limits: { maxConcurrency: 1, maxStateBytes: 64 * 1024 },
210
+ });
211
+ }
212
+ /**
213
+ * Run (or resume) a thin goal→verify coding composition.
214
+ * Fails closed when `approval` / `approval.validateResume` is missing.
215
+ */
216
+ export async function runCodingGoalVerify(options) {
217
+ if (!options.approval?.validateResume) {
218
+ throw new CodingGoalVerifyError("approval.validateResume is required");
219
+ }
220
+ if (!Array.isArray(options.checks) || options.checks.length < 1) {
221
+ throw new CodingGoalVerifyError("checks must declare at least one named check");
222
+ }
223
+ for (const name of options.checks) {
224
+ if (typeof name !== "string" || !/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(name)) {
225
+ throw new CodingGoalVerifyError(`invalid check name: ${String(name)}`);
226
+ }
227
+ }
228
+ if (typeof options.runCheck !== "function") {
229
+ throw new CodingGoalVerifyError("runCheck is required");
230
+ }
231
+ if (typeof options.buildHandoff !== "function") {
232
+ throw new CodingGoalVerifyError("buildHandoff is required");
233
+ }
234
+ if (typeof options.goal !== "string" || options.goal.trim().length < 1) {
235
+ throw new CodingGoalVerifyError("goal is required");
236
+ }
237
+ if (typeof options.cwd !== "string" || options.cwd.length < 1) {
238
+ throw new CodingGoalVerifyError("cwd is required");
239
+ }
240
+ const taskId = options.taskId ?? "goal";
241
+ const title = options.title ?? options.goal.slice(0, 120);
242
+ const baseBranch = options.baseBranch ?? "main";
243
+ const branch = options.branch ?? `codex/${taskId}`;
244
+ const suspendReason = options.approval.reason ?? CODING_GOAL_VERIFY_SUSPEND_REASON;
245
+ const workflow = createCodingGoalVerifyWorkflow({
246
+ goal: options.goal,
247
+ cwd: options.cwd,
248
+ taskId,
249
+ title,
250
+ baseBranch,
251
+ branch,
252
+ checks: options.checks,
253
+ runCheck: options.runCheck,
254
+ buildHandoff: options.buildHandoff,
255
+ suspendReason,
256
+ });
257
+ const validateState = async (input) => {
258
+ if (CODING_STATE_KEY in input.value) {
259
+ readCodingCheckpointFromState(input.value);
260
+ }
261
+ };
262
+ const shared = {
263
+ checkpoints: options.checkpoints,
264
+ redactor: options.redactor,
265
+ ownership: options.ownership,
266
+ validateState,
267
+ validateResume: options.approval.validateResume,
268
+ signal: options.signal,
269
+ onEvent: options.onEvent,
270
+ };
271
+ if (options.resume) {
272
+ return resumeWorkflow(workflow, { runId: options.resume.runId, workflowId: workflow.id }, {
273
+ ...shared,
274
+ resume: {
275
+ decision: options.resume.decision,
276
+ expectedVersion: options.resume.expectedVersion,
277
+ input: options.resume.input,
278
+ },
279
+ });
280
+ }
281
+ return runWorkflow(workflow, { goal: options.goal, baseBranch }, shared);
282
+ }
283
+ //# sourceMappingURL=goal-verify.js.map
package/dist/index.d.ts CHANGED
@@ -18,9 +18,13 @@ export { createGitTools, createGitStatusTool, createGitDiffTool, createGitBranch
18
18
  export type { GitToolsOptions } from "./git-tools.js";
19
19
  export { createCodingCheckTool } from "./checks.js";
20
20
  export type { CodingCheckToolOptions, NamedCheckDefinition } from "./checks.js";
21
+ export { ASK_USER_DECISION_RATIONALE_COUNT, ASK_USER_DECISION_SUSPEND_REASON, ASK_USER_DECISION_TOOL_NAME, DEFAULT_MAX_ASK_USER_DECISION_BULLET_BYTES, DEFAULT_MAX_ASK_USER_DECISION_CUSTOM_BYTES, DEFAULT_MAX_ASK_USER_DECISION_LABEL_BYTES, DEFAULT_MAX_ASK_USER_DECISION_OPTIONS, DEFAULT_MAX_ASK_USER_DECISION_QUESTION_BYTES, HARD_MAX_ASK_USER_DECISION_BULLET_BYTES, HARD_MAX_ASK_USER_DECISION_CUSTOM_BYTES, HARD_MAX_ASK_USER_DECISION_LABEL_BYTES, HARD_MAX_ASK_USER_DECISION_OPTIONS, HARD_MAX_ASK_USER_DECISION_QUESTION_BYTES, askUserDecisionResumeSchema, createAskUserDecisionResumeValidator, createAskUserDecisionTool, parseAskUserDecisionArgs, resolveAskUserDecisionAnswer, resolveAskUserDecisionLimits, suspendAskUserDecision, toAskUserDecisionSuspendData, validateAskUserDecisionAgentResume, validateAskUserDecisionResume, } from "./ask-user-decision.js";
22
+ export type { AskUserDecisionAnswer, AskUserDecisionHandler, AskUserDecisionOption, AskUserDecisionRequest, AskUserDecisionSelectionMode, AskUserDecisionSuspendData, AskUserDecisionToolOptions, ResolvedAskUserDecisionAnswer, ResolvedAskUserDecisionLimits, SuspendAskUserDecisionOptions, } from "./ask-user-decision.js";
21
23
  export { createDirectoryArtifactWriter, createTempArtifactWriter, sha256Hex } from "./artifacts.js";
22
24
  export { CODING_CHECKPOINT_SCHEMA_VERSION, CODING_STATE_KEY, CodingCheckpointError, assertCodingResumeAllowed, buildCodingCheckpointMetadata, codingCheckpointStatePatch, codingPlanPathForTask, createCodingArtifactRef, createCodingPlanMarkdown, fingerprintJson, parseCodingPlanTodos, readCodingCheckpointFromState, readCodingPlanFile, resolveCodingCheckpointLimits, validateCodingCheckpointMetadata, verifyCodingArtifactBytes, writeCodingPlanFile, } from "./coding-checkpoint.js";
23
25
  export type { CodingArtifactKind, CodingArtifactRef, CodingCheckSummary, CodingCheckpointLimitOptions, CodingCheckpointMetadata, CodingFingerprints, CodingHandoffSummary, CodingTaskStatus, CodingTodoItem, ResolvedCodingCheckpointLimits, } from "./coding-checkpoint.js";
26
+ export { CODING_GOAL_VERIFY_REVISION, CODING_GOAL_VERIFY_SUSPEND_REASON, CODING_GOAL_VERIFY_WORKFLOW_ID, CodingGoalVerifyError, createCodingGoalVerifyWorkflow, runCodingGoalVerify, } from "./goal-verify.js";
27
+ export type { CodingGoalVerifyApproval, RunCodingGoalVerifyOptions, } from "./goal-verify.js";
24
28
  export { withFileMutationQueue } from "./file-mutation-queue.js";
25
29
  export { enforceExecutionPolicy } from "./execution-policy.js";
26
30
  export { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, DEFAULT_MAX_TEXT_SCAN_BYTES, DEFAULT_MAX_WRITE_BYTES, DEFAULT_MAX_EDIT_FILE_BYTES, DEFAULT_MAX_EDIT_INPUT_BYTES, DEFAULT_MAX_EDITS, DEFAULT_SHELL_TIMEOUT_SECONDS, DEFAULT_MAX_TOTAL_OUTPUT_BYTES, DEFAULT_MAX_REPO_DEPTH, DEFAULT_MAX_REPO_ENTRIES, DEFAULT_MAX_REPO_FILES, DEFAULT_MAX_REPO_RESULTS, DEFAULT_MAX_REPO_CONCURRENCY, DEFAULT_MAX_SEARCH_SCAN_BYTES, DEFAULT_MAX_SEARCH_FILE_BYTES, DEFAULT_MAX_SEARCH_MATCHES, DEFAULT_MAX_SEARCH_PATTERN_BYTES, DEFAULT_MAX_SEARCH_LINE_BYTES, DEFAULT_MAX_SEARCH_CONTEXT_LINES, DEFAULT_MAX_SEARCH_TIME_MS, DEFAULT_MAX_GIT_PATHS, DEFAULT_MAX_GIT_REF_BYTES, DEFAULT_MAX_GIT_MESSAGE_BYTES, DEFAULT_MAX_GIT_OUTPUT_BYTES, DEFAULT_MAX_GIT_DIFF_LINES, DEFAULT_MAX_GIT_CHANGED_FILES, DEFAULT_MAX_GIT_PATCH_BYTES, DEFAULT_MAX_GIT_WORKTREES, DEFAULT_GIT_TIMEOUT_MS, DEFAULT_MAX_CHECK_NAMES, DEFAULT_MAX_CHECK_CONCURRENCY, DEFAULT_CHECK_TIMEOUT_MS, DEFAULT_MAX_CHECK_DIAGNOSTIC_LINES, DEFAULT_MAX_CHECK_OUTPUT_BYTES, DEFAULT_MAX_PR_HANDOFF_BYTES, DEFAULT_MAX_PR_COMMITS, DEFAULT_MAX_PLAN_BYTES, DEFAULT_MAX_TODOS, DEFAULT_MAX_TODO_TEXT_BYTES, DEFAULT_MAX_CODING_ARTIFACTS, DEFAULT_MAX_CODING_ARTIFACT_BYTES, DEFAULT_MAX_CHECK_SUMMARY_BYTES, DEFAULT_MAX_CODING_CHECKPOINT_BYTES, HARD_MAX_BYTES, HARD_MAX_LINES, HARD_MAX_TEXT_SCAN_BYTES, HARD_MAX_IMAGE_BYTES, HARD_MAX_WRITE_BYTES, HARD_MAX_EDIT_FILE_BYTES, HARD_MAX_EDIT_INPUT_BYTES, HARD_MAX_EDITS, HARD_SHELL_TIMEOUT_SECONDS, HARD_MAX_TOTAL_OUTPUT_BYTES, HARD_MAX_REPO_DEPTH, HARD_MAX_REPO_ENTRIES, HARD_MAX_REPO_FILES, HARD_MAX_REPO_RESULTS, HARD_MAX_REPO_CONCURRENCY, HARD_MAX_SEARCH_SCAN_BYTES, HARD_MAX_SEARCH_FILE_BYTES, HARD_MAX_SEARCH_MATCHES, HARD_MAX_SEARCH_PATTERN_BYTES, HARD_MAX_SEARCH_LINE_BYTES, HARD_MAX_SEARCH_CONTEXT_LINES, HARD_MAX_SEARCH_TIME_MS, HARD_MAX_GIT_PATHS, HARD_MAX_GIT_REF_BYTES, HARD_MAX_GIT_MESSAGE_BYTES, HARD_MAX_GIT_OUTPUT_BYTES, HARD_MAX_GIT_DIFF_LINES, HARD_MAX_GIT_CHANGED_FILES, HARD_MAX_GIT_PATCH_BYTES, HARD_MAX_GIT_WORKTREES, HARD_GIT_TIMEOUT_MS, HARD_MAX_CHECK_NAMES, HARD_MAX_CHECK_CONCURRENCY, HARD_CHECK_TIMEOUT_MS, HARD_MAX_CHECK_DIAGNOSTIC_LINES, HARD_MAX_CHECK_OUTPUT_BYTES, HARD_MAX_PR_HANDOFF_BYTES, HARD_MAX_PR_COMMITS, HARD_MAX_PLAN_BYTES, HARD_MAX_TODOS, HARD_MAX_TODO_TEXT_BYTES, HARD_MAX_CODING_ARTIFACTS, HARD_MAX_CODING_ARTIFACT_BYTES, HARD_MAX_CHECK_SUMMARY_BYTES, HARD_MAX_CODING_CHECKPOINT_BYTES, } from "./limits.js";
@@ -52,6 +56,8 @@ export interface ToolsOptions {
52
56
  }
53
57
  /**
54
58
  * Full coding tool set: `shell`, `read`, `write`, `edit`, `repo_list`, `repo_search`.
59
+ * Opt-in tools (`createGitTools`, `createAskUserDecisionTool`, `createCodingCheckTool`)
60
+ * stay out — hosts register them explicitly.
55
61
  */
56
62
  export declare function createCodingTools(cwd: string, options?: ToolsOptions): readonly ToolDefinition[];
57
63
  /**
package/dist/index.js CHANGED
@@ -14,8 +14,10 @@ export { createLocalRepositoryOperations, resolveRepositoryLimits, compileSearch
14
14
  export { createGitOperations, resolveGitLimits, parsePorcelainV2, GitError, SAFE_GIT_ENV, SAFE_GIT_CONFIG_ARGS, createBoundGitRunner, runGitCli, } from "./git.js";
15
15
  export { createGitTools, createGitStatusTool, createGitDiffTool, createGitBranchTool, createGitWorktreeTool, createGitApplyTool, createGitCommitTool, createGitPrHandoffTool, } from "./git-tools.js";
16
16
  export { createCodingCheckTool } from "./checks.js";
17
+ export { ASK_USER_DECISION_RATIONALE_COUNT, ASK_USER_DECISION_SUSPEND_REASON, ASK_USER_DECISION_TOOL_NAME, DEFAULT_MAX_ASK_USER_DECISION_BULLET_BYTES, DEFAULT_MAX_ASK_USER_DECISION_CUSTOM_BYTES, DEFAULT_MAX_ASK_USER_DECISION_LABEL_BYTES, DEFAULT_MAX_ASK_USER_DECISION_OPTIONS, DEFAULT_MAX_ASK_USER_DECISION_QUESTION_BYTES, HARD_MAX_ASK_USER_DECISION_BULLET_BYTES, HARD_MAX_ASK_USER_DECISION_CUSTOM_BYTES, HARD_MAX_ASK_USER_DECISION_LABEL_BYTES, HARD_MAX_ASK_USER_DECISION_OPTIONS, HARD_MAX_ASK_USER_DECISION_QUESTION_BYTES, askUserDecisionResumeSchema, createAskUserDecisionResumeValidator, createAskUserDecisionTool, parseAskUserDecisionArgs, resolveAskUserDecisionAnswer, resolveAskUserDecisionLimits, suspendAskUserDecision, toAskUserDecisionSuspendData, validateAskUserDecisionAgentResume, validateAskUserDecisionResume, } from "./ask-user-decision.js";
17
18
  export { createDirectoryArtifactWriter, createTempArtifactWriter, sha256Hex } from "./artifacts.js";
18
19
  export { CODING_CHECKPOINT_SCHEMA_VERSION, CODING_STATE_KEY, CodingCheckpointError, assertCodingResumeAllowed, buildCodingCheckpointMetadata, codingCheckpointStatePatch, codingPlanPathForTask, createCodingArtifactRef, createCodingPlanMarkdown, fingerprintJson, parseCodingPlanTodos, readCodingCheckpointFromState, readCodingPlanFile, resolveCodingCheckpointLimits, validateCodingCheckpointMetadata, verifyCodingArtifactBytes, writeCodingPlanFile, } from "./coding-checkpoint.js";
20
+ export { CODING_GOAL_VERIFY_REVISION, CODING_GOAL_VERIFY_SUSPEND_REASON, CODING_GOAL_VERIFY_WORKFLOW_ID, CodingGoalVerifyError, createCodingGoalVerifyWorkflow, runCodingGoalVerify, } from "./goal-verify.js";
19
21
  // --- generic primitives (re-exported for hosts that want them) ---
20
22
  export { withFileMutationQueue } from "./file-mutation-queue.js";
21
23
  export { enforceExecutionPolicy } from "./execution-policy.js";
@@ -43,6 +45,8 @@ function withRepositoryDefaults(toolOptions, shared) {
43
45
  }
44
46
  /**
45
47
  * Full coding tool set: `shell`, `read`, `write`, `edit`, `repo_list`, `repo_search`.
48
+ * Opt-in tools (`createGitTools`, `createAskUserDecisionTool`, `createCodingCheckTool`)
49
+ * stay out — hosts register them explicitly.
46
50
  */
47
51
  export function createCodingTools(cwd, options) {
48
52
  const policy = options?.executionPolicy;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@arnilo/prism-coding-agent",
3
- "version": "0.0.10",
4
- "description": "Optional coding-agent tools (shell, read, write, edit, repo_list, repo_search, opt-in Git/check, and durable plan/checkpoint helpers) package for Prism.",
3
+ "version": "0.0.12",
4
+ "description": "Optional coding-agent tools (shell, read, write, edit, repo_list, repo_search, opt-in Git/check/ask-user-decision, and durable plan/checkpoint helpers) package for Prism.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
@@ -28,11 +28,13 @@
28
28
  "diff": "^9.0.0"
29
29
  },
30
30
  "peerDependencies": {
31
- "@arnilo/prism": "0.0.10"
31
+ "@arnilo/prism": "0.0.12",
32
+ "@arnilo/prism-workflows": "0.0.12"
32
33
  },
33
34
  "devDependencies": {
34
35
  "@arnilo/prism": "file:../..",
35
- "@arnilo/prism-evals": "file:../evals"
36
+ "@arnilo/prism-evals": "file:../evals",
37
+ "@arnilo/prism-workflows": "file:../workflows"
36
38
  },
37
39
  "engines": {
38
40
  "node": ">=20"