@arnilo/prism-coding-agent 0.0.96 → 0.1.1

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 (81) hide show
  1. package/CHANGELOG.md +139 -3
  2. package/README.md +48 -19
  3. package/dist/ask-user-decision.d.ts +160 -0
  4. package/dist/ask-user-decision.js +495 -0
  5. package/dist/atomic-write.d.ts +3 -0
  6. package/dist/atomic-write.js +24 -0
  7. package/dist/checks.js +5 -0
  8. package/dist/coding-checkpoint.js +6 -15
  9. package/dist/delete.d.ts +29 -0
  10. package/dist/delete.js +119 -0
  11. package/dist/edit-diff.js +1 -4
  12. package/dist/edit.d.ts +5 -1
  13. package/dist/edit.js +20 -9
  14. package/dist/effects.d.ts +33 -0
  15. package/dist/effects.js +89 -0
  16. package/dist/execution-policy.d.ts +8 -3
  17. package/dist/execution-policy.js +5 -2
  18. package/dist/file-mutation-queue.js +1 -2
  19. package/dist/forge/github.d.ts +2 -0
  20. package/dist/forge/github.js +554 -0
  21. package/dist/forge/index.d.ts +3 -0
  22. package/dist/forge/index.js +3 -0
  23. package/dist/forge/types.d.ts +150 -0
  24. package/dist/forge/types.js +19 -0
  25. package/dist/git-aware-repository.d.ts +25 -0
  26. package/dist/git-aware-repository.js +268 -0
  27. package/dist/git-exec.js +1 -1
  28. package/dist/git-tools.d.ts +4 -1
  29. package/dist/git-tools.js +15 -7
  30. package/dist/git.d.ts +3 -3
  31. package/dist/git.js +14 -14
  32. package/dist/glob-match.d.ts +6 -0
  33. package/dist/glob-match.js +81 -0
  34. package/dist/glob.d.ts +14 -0
  35. package/dist/glob.js +147 -0
  36. package/dist/goal-verify.d.ts +66 -0
  37. package/dist/goal-verify.js +280 -0
  38. package/dist/index.d.ts +63 -30
  39. package/dist/index.js +40 -16
  40. package/dist/language/client.d.ts +44 -0
  41. package/dist/language/client.js +290 -0
  42. package/dist/language/framing.d.ts +23 -0
  43. package/dist/language/framing.js +112 -0
  44. package/dist/language/index.d.ts +4 -0
  45. package/dist/language/index.js +4 -0
  46. package/dist/language/intelligence.d.ts +10 -0
  47. package/dist/language/intelligence.js +526 -0
  48. package/dist/language/types.d.ts +106 -0
  49. package/dist/language/types.js +21 -0
  50. package/dist/lifecycle.d.ts +75 -0
  51. package/dist/lifecycle.js +102 -0
  52. package/dist/limits.d.ts +41 -0
  53. package/dist/limits.js +41 -0
  54. package/dist/list.js +6 -10
  55. package/dist/move.d.ts +24 -0
  56. package/dist/move.js +150 -0
  57. package/dist/mutation-path.d.ts +7 -0
  58. package/dist/mutation-path.js +51 -0
  59. package/dist/output-accumulator.d.ts +8 -0
  60. package/dist/output-accumulator.js +45 -1
  61. package/dist/path-utils.js +1 -1
  62. package/dist/process/index.d.ts +3 -0
  63. package/dist/process/index.js +3 -0
  64. package/dist/process/sessions.d.ts +2 -0
  65. package/dist/process/sessions.js +592 -0
  66. package/dist/process/types.d.ts +146 -0
  67. package/dist/process/types.js +19 -0
  68. package/dist/read-path-set.d.ts +14 -0
  69. package/dist/read-path-set.js +26 -0
  70. package/dist/read.d.ts +3 -0
  71. package/dist/read.js +11 -17
  72. package/dist/repository.d.ts +54 -3
  73. package/dist/repository.js +144 -38
  74. package/dist/search.d.ts +1 -1
  75. package/dist/search.js +91 -27
  76. package/dist/shell.d.ts +3 -0
  77. package/dist/shell.js +23 -8
  78. package/dist/truncate.js +1 -1
  79. package/dist/write.d.ts +5 -1
  80. package/dist/write.js +19 -6
  81. package/package.json +6 -4
@@ -0,0 +1,495 @@
1
+ import { suspend, } from "@arnilo/prism-workflows";
2
+ import { CODING_OBSERVATION_EFFECT } from "./effects.js";
3
+ import { enforceExecutionPolicy } from "./execution-policy.js";
4
+ import { validateCodingLimit } from "./limits.js";
5
+ export const ASK_USER_DECISION_TOOL_NAME = "ask_user_decision";
6
+ export const ASK_USER_DECISION_SUSPEND_REASON = "ask_user_decision";
7
+ /** Exactly three rationale bullets per side. */
8
+ export const ASK_USER_DECISION_RATIONALE_COUNT = 3;
9
+ export const DEFAULT_MAX_ASK_USER_DECISION_OPTIONS = 6;
10
+ export const HARD_MAX_ASK_USER_DECISION_OPTIONS = 16;
11
+ export const DEFAULT_MAX_ASK_USER_DECISION_QUESTION_BYTES = 2_048;
12
+ export const HARD_MAX_ASK_USER_DECISION_QUESTION_BYTES = 8_192;
13
+ export const DEFAULT_MAX_ASK_USER_DECISION_LABEL_BYTES = 512;
14
+ export const HARD_MAX_ASK_USER_DECISION_LABEL_BYTES = 2_048;
15
+ export const DEFAULT_MAX_ASK_USER_DECISION_BULLET_BYTES = 512;
16
+ export const HARD_MAX_ASK_USER_DECISION_BULLET_BYTES = 2_048;
17
+ /** Same ceiling as question text — free-text answers stay short. */
18
+ export const DEFAULT_MAX_ASK_USER_DECISION_CUSTOM_BYTES = DEFAULT_MAX_ASK_USER_DECISION_QUESTION_BYTES;
19
+ export const HARD_MAX_ASK_USER_DECISION_CUSTOM_BYTES = HARD_MAX_ASK_USER_DECISION_QUESTION_BYTES;
20
+ export function resolveAskUserDecisionLimits(options) {
21
+ return {
22
+ maxOptions: validateCodingLimit("maxOptions", options?.maxOptions ?? DEFAULT_MAX_ASK_USER_DECISION_OPTIONS, HARD_MAX_ASK_USER_DECISION_OPTIONS),
23
+ maxQuestionBytes: validateCodingLimit("maxQuestionBytes", options?.maxQuestionBytes ?? DEFAULT_MAX_ASK_USER_DECISION_QUESTION_BYTES, HARD_MAX_ASK_USER_DECISION_QUESTION_BYTES),
24
+ maxLabelBytes: validateCodingLimit("maxLabelBytes", options?.maxLabelBytes ?? DEFAULT_MAX_ASK_USER_DECISION_LABEL_BYTES, HARD_MAX_ASK_USER_DECISION_LABEL_BYTES),
25
+ maxBulletBytes: validateCodingLimit("maxBulletBytes", options?.maxBulletBytes ?? DEFAULT_MAX_ASK_USER_DECISION_BULLET_BYTES, HARD_MAX_ASK_USER_DECISION_BULLET_BYTES),
26
+ maxCustomTextBytes: validateCodingLimit("maxCustomTextBytes", options?.maxCustomTextBytes ?? DEFAULT_MAX_ASK_USER_DECISION_CUSTOM_BYTES, HARD_MAX_ASK_USER_DECISION_CUSTOM_BYTES),
27
+ };
28
+ }
29
+ function errorResult(toolCallId, message) {
30
+ return {
31
+ toolCallId,
32
+ name: ASK_USER_DECISION_TOOL_NAME,
33
+ content: [{ type: "text", text: message }],
34
+ error: { message },
35
+ };
36
+ }
37
+ function assertByteLimit(label, text, maxBytes) {
38
+ const bytes = Buffer.byteLength(text, "utf8");
39
+ if (bytes < 1 || bytes > maxBytes) {
40
+ throw new Error(`${label} must be 1..${maxBytes} UTF-8 bytes`);
41
+ }
42
+ if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/.test(text)) {
43
+ throw new Error(`${label} contains control characters`);
44
+ }
45
+ }
46
+ function requireThreeBullets(value, label, maxBytes) {
47
+ if (!Array.isArray(value) || value.length !== ASK_USER_DECISION_RATIONALE_COUNT) {
48
+ throw new Error(`${label} must be exactly ${ASK_USER_DECISION_RATIONALE_COUNT} strings`);
49
+ }
50
+ const out = [];
51
+ for (let i = 0; i < ASK_USER_DECISION_RATIONALE_COUNT; i++) {
52
+ const item = value[i];
53
+ if (typeof item !== "string") {
54
+ throw new Error(`${label}[${i}] must be a string`);
55
+ }
56
+ const trimmed = item.trim();
57
+ assertByteLimit(`${label}[${i}]`, trimmed, maxBytes);
58
+ out.push(trimmed);
59
+ }
60
+ return out;
61
+ }
62
+ function parseSelectionMode(value) {
63
+ if (value === undefined || value === null)
64
+ return "single";
65
+ if (value === "single" || value === "multiple")
66
+ return value;
67
+ throw new Error('selectionMode must be "single" or "multiple"');
68
+ }
69
+ function hasSelectionFields(answer) {
70
+ if (!answer)
71
+ return false;
72
+ if (typeof answer.selectedId === "string" && answer.selectedId.trim() !== "")
73
+ return true;
74
+ return Array.isArray(answer.selectedIds) && answer.selectedIds.length > 0;
75
+ }
76
+ /** Normalize host answer against mode + allowCustom. Exported for tests. */
77
+ export function resolveAskUserDecisionAnswer(answer, selectionMode, options, gates) {
78
+ const customRaw = answer && typeof answer.customText === "string" ? answer.customText.trim() : "";
79
+ const hasCustom = customRaw.length > 0;
80
+ const hasSelection = hasSelectionFields(answer);
81
+ if (hasCustom && hasSelection) {
82
+ throw new Error("customText is mutually exclusive with selectedId/selectedIds");
83
+ }
84
+ if (hasCustom) {
85
+ if (!gates.allowCustom)
86
+ throw new Error("customText rejected (allowCustom=false)");
87
+ assertByteLimit("customText", customRaw, gates.maxCustomTextBytes);
88
+ return { kind: "custom", customText: customRaw };
89
+ }
90
+ const byId = new Map(options.map((o) => [o.id, o]));
91
+ const rawIds = [];
92
+ if (answer && Array.isArray(answer.selectedIds)) {
93
+ for (const id of answer.selectedIds) {
94
+ if (typeof id !== "string")
95
+ throw new Error("selectedIds entries must be strings");
96
+ rawIds.push(id.trim());
97
+ }
98
+ }
99
+ if (answer && typeof answer.selectedId === "string") {
100
+ const id = answer.selectedId.trim();
101
+ if (rawIds.length === 0)
102
+ rawIds.push(id);
103
+ else if (!(rawIds.length === 1 && rawIds[0] === id)) {
104
+ throw new Error("selectedId and selectedIds disagree");
105
+ }
106
+ }
107
+ if (rawIds.length === 0) {
108
+ throw new Error(gates.allowCustom
109
+ ? "ask() must return selectedId/selectedIds or customText"
110
+ : selectionMode === "multiple"
111
+ ? "ask() must return non-empty selectedIds"
112
+ : "ask() must return selectedId");
113
+ }
114
+ const seen = new Set();
115
+ const selectedIds = [];
116
+ for (const id of rawIds) {
117
+ if (!byId.has(id))
118
+ throw new Error(`ask() returned unknown selectedId: ${id}`);
119
+ if (seen.has(id))
120
+ throw new Error(`duplicate selectedId: ${id}`);
121
+ seen.add(id);
122
+ selectedIds.push(id);
123
+ }
124
+ if (selectionMode === "single" && selectedIds.length !== 1) {
125
+ // Single accepts selectedIds only when length is exactly 1.
126
+ throw new Error("single selectionMode requires exactly one selected id");
127
+ }
128
+ return { kind: "selection", selectedIds, selectedId: selectedIds[0] };
129
+ }
130
+ function parseAllowCustom(value) {
131
+ if (value === undefined || value === null)
132
+ return false;
133
+ if (typeof value !== "boolean")
134
+ throw new Error("allowCustom must be a boolean");
135
+ return value;
136
+ }
137
+ /** Parse + validate model args into a bounded decision request. Exported for tests. */
138
+ export function parseAskUserDecisionArgs(args, limits) {
139
+ if (typeof args.question !== "string") {
140
+ throw new Error("question must be a string");
141
+ }
142
+ const question = args.question.trim();
143
+ assertByteLimit("question", question, limits.maxQuestionBytes);
144
+ const selectionMode = parseSelectionMode(args.selectionMode);
145
+ const allowCustom = parseAllowCustom(args.allowCustom);
146
+ if (!Array.isArray(args.options)) {
147
+ throw new Error("options must be an array");
148
+ }
149
+ if (args.options.length < 2) {
150
+ throw new Error("options must include at least 2 choices");
151
+ }
152
+ if (args.options.length > limits.maxOptions) {
153
+ throw new Error(`options exceeds maxOptions (${limits.maxOptions})`);
154
+ }
155
+ const seen = new Set();
156
+ const options = [];
157
+ for (let i = 0; i < args.options.length; i++) {
158
+ const raw = args.options[i];
159
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
160
+ throw new Error(`options[${i}] must be an object`);
161
+ }
162
+ const row = raw;
163
+ if (typeof row.id !== "string")
164
+ throw new Error(`options[${i}].id must be a string`);
165
+ const id = row.id.trim();
166
+ if (!/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(id)) {
167
+ throw new Error(`options[${i}].id has invalid format`);
168
+ }
169
+ if (seen.has(id))
170
+ throw new Error(`duplicate option id: ${id}`);
171
+ seen.add(id);
172
+ if (typeof row.label !== "string")
173
+ throw new Error(`options[${i}].label must be a string`);
174
+ const label = row.label.trim();
175
+ assertByteLimit(`options[${i}].label`, label, limits.maxLabelBytes);
176
+ options.push({
177
+ id,
178
+ label,
179
+ pros: requireThreeBullets(row.pros, `options[${i}].pros`, limits.maxBulletBytes),
180
+ cons: requireThreeBullets(row.cons, `options[${i}].cons`, limits.maxBulletBytes),
181
+ });
182
+ }
183
+ return { question, options, selectionMode, allowCustom };
184
+ }
185
+ /**
186
+ * Create the opt-in `ask_user_decision` tool.
187
+ * Host must supply `ask`; factory throws if missing.
188
+ */
189
+ export function createAskUserDecisionTool(options) {
190
+ if (typeof options?.ask !== "function") {
191
+ throw new Error("ask_user_decision requires options.ask");
192
+ }
193
+ const limits = resolveAskUserDecisionLimits(options);
194
+ const ask = options.ask;
195
+ return {
196
+ name: ASK_USER_DECISION_TOOL_NAME,
197
+ effect: CODING_OBSERVATION_EFFECT,
198
+ description: "Ask the user to choose a direction when instructions are ambiguous and the choice matters. " +
199
+ "Provide 2+ options; each option MUST include exactly 3 pros and 3 cons. " +
200
+ 'Use selectionMode "multiple" when several options may apply together; default is single choice. ' +
201
+ "Set allowCustom=true only when a short free-text alternative to the listed options is acceptable " +
202
+ "(custom answer is mutually exclusive with selecting option ids). " +
203
+ "Do not use for trivia, confirmations that are already clear, or when a single safe default exists.",
204
+ exclusive: true,
205
+ parameters: {
206
+ type: "object",
207
+ properties: {
208
+ question: {
209
+ type: "string",
210
+ description: "Clear decision question for the user",
211
+ },
212
+ selectionMode: {
213
+ type: "string",
214
+ enum: ["single", "multiple"],
215
+ description: "single (default) or multiple selection",
216
+ },
217
+ allowCustom: {
218
+ type: "boolean",
219
+ description: "When true, host may return customText instead of selecting option ids (XOR). Default false.",
220
+ },
221
+ options: {
222
+ type: "array",
223
+ minItems: 2,
224
+ maxItems: limits.maxOptions,
225
+ items: {
226
+ type: "object",
227
+ properties: {
228
+ id: {
229
+ type: "string",
230
+ description: "Stable option id returned when selected (e.g. keep_sqlite)",
231
+ },
232
+ label: {
233
+ type: "string",
234
+ description: "User-facing option label",
235
+ },
236
+ pros: {
237
+ type: "array",
238
+ minItems: 3,
239
+ maxItems: 3,
240
+ items: { type: "string" },
241
+ description: "Exactly 3 advantages of this option",
242
+ },
243
+ cons: {
244
+ type: "array",
245
+ minItems: 3,
246
+ maxItems: 3,
247
+ items: { type: "string" },
248
+ description: "Exactly 3 disadvantages of this option",
249
+ },
250
+ },
251
+ required: ["id", "label", "pros", "cons"],
252
+ additionalProperties: false,
253
+ },
254
+ description: `2..${limits.maxOptions} options with pros/cons`,
255
+ },
256
+ },
257
+ required: ["question", "options"],
258
+ additionalProperties: false,
259
+ },
260
+ // Shared elicitation mapping (Phase 8): a durable gated run surfaces this call as a
261
+ // kind:"elicitation" pending decision; the answer payload resolves it without executing ask().
262
+ // The full UX payload (question + options with pros/cons) rides on the schema's extension key.
263
+ elicitation: (args) => {
264
+ let parsed;
265
+ try {
266
+ parsed = parseAskUserDecisionArgs(args, limits);
267
+ }
268
+ catch {
269
+ return undefined; // malformed args fall back to plain tool approval; execute reports the parse error
270
+ }
271
+ return {
272
+ schema: {
273
+ ...askUserDecisionResumeSchema(parsed),
274
+ "x-prism-ask-user-decision": toAskUserDecisionSuspendData(parsed),
275
+ },
276
+ reason: parsed.question,
277
+ validate: (payload) => {
278
+ resolveAskUserDecisionAnswer(payload, parsed.selectionMode, parsed.options, {
279
+ allowCustom: parsed.allowCustom,
280
+ maxCustomTextBytes: limits.maxCustomTextBytes,
281
+ });
282
+ },
283
+ };
284
+ },
285
+ async execute(args, context) {
286
+ const toolCallId = context.toolCallId;
287
+ if (context.signal?.aborted)
288
+ return errorResult(toolCallId, "Operation aborted");
289
+ let parsed;
290
+ try {
291
+ parsed = parseAskUserDecisionArgs(args, limits);
292
+ }
293
+ catch (error) {
294
+ return errorResult(toolCallId, error instanceof Error ? error.message : String(error));
295
+ }
296
+ const policyCheck = await enforceExecutionPolicy(options.executionPolicy, {
297
+ kind: "ask_user_decision",
298
+ operation: "ask",
299
+ risk: "medium",
300
+ metadata: {
301
+ optionCount: parsed.options.length,
302
+ optionIds: parsed.options.map((o) => o.id),
303
+ selectionMode: parsed.selectionMode,
304
+ allowCustom: parsed.allowCustom,
305
+ sessionId: context.sessionId,
306
+ runId: context.runId,
307
+ signal: context.signal,
308
+ },
309
+ }, toolCallId, ASK_USER_DECISION_TOOL_NAME);
310
+ if (!policyCheck.allowed)
311
+ return policyCheck.result;
312
+ let answer;
313
+ try {
314
+ answer = await ask({
315
+ question: parsed.question,
316
+ options: parsed.options,
317
+ selectionMode: parsed.selectionMode,
318
+ allowCustom: parsed.allowCustom,
319
+ toolCallId,
320
+ sessionId: context.sessionId,
321
+ runId: context.runId,
322
+ signal: context.signal,
323
+ });
324
+ }
325
+ catch (error) {
326
+ return errorResult(toolCallId, error instanceof Error ? error.message : String(error));
327
+ }
328
+ if (context.signal?.aborted)
329
+ return errorResult(toolCallId, "Operation aborted");
330
+ let resolved;
331
+ try {
332
+ resolved = resolveAskUserDecisionAnswer(answer, parsed.selectionMode, parsed.options, {
333
+ allowCustom: parsed.allowCustom,
334
+ maxCustomTextBytes: limits.maxCustomTextBytes,
335
+ });
336
+ }
337
+ catch (error) {
338
+ return errorResult(toolCallId, error instanceof Error ? error.message : String(error));
339
+ }
340
+ if (resolved.kind === "custom") {
341
+ return {
342
+ toolCallId,
343
+ name: ASK_USER_DECISION_TOOL_NAME,
344
+ content: [
345
+ {
346
+ type: "text",
347
+ text: `User provided custom answer: ${resolved.customText}`,
348
+ },
349
+ ],
350
+ metadata: {
351
+ customText: resolved.customText,
352
+ selectionMode: parsed.selectionMode,
353
+ allowCustom: parsed.allowCustom,
354
+ question: parsed.question,
355
+ options: parsed.options,
356
+ },
357
+ };
358
+ }
359
+ const selected = resolved.selectedIds.map((id) => parsed.options.find((o) => o.id === id));
360
+ const labelText = selected.map((o) => `"${o.label}" (id=${o.id})`).join(", ");
361
+ return {
362
+ toolCallId,
363
+ name: ASK_USER_DECISION_TOOL_NAME,
364
+ content: [
365
+ {
366
+ type: "text",
367
+ text: parsed.selectionMode === "multiple"
368
+ ? `User selected ${selected.length} option(s): ${labelText}.`
369
+ : `User selected ${labelText}.`,
370
+ },
371
+ ],
372
+ metadata: {
373
+ selectedId: resolved.selectedId,
374
+ selectedIds: resolved.selectedIds,
375
+ selectedLabels: selected.map((o) => o.label),
376
+ selectionMode: parsed.selectionMode,
377
+ allowCustom: parsed.allowCustom,
378
+ question: parsed.question,
379
+ options: parsed.options,
380
+ },
381
+ };
382
+ },
383
+ };
384
+ }
385
+ /** JSON Schema describing resume `input` (= AskUserDecisionAnswer). */
386
+ export function askUserDecisionResumeSchema(request) {
387
+ const optionIds = request.options.map((o) => o.id);
388
+ const selectionProps = {
389
+ selectedId: { type: "string", enum: optionIds },
390
+ selectedIds: {
391
+ type: "array",
392
+ minItems: 1,
393
+ maxItems: optionIds.length,
394
+ items: { type: "string", enum: optionIds },
395
+ },
396
+ };
397
+ if (!request.allowCustom) {
398
+ return {
399
+ type: "object",
400
+ additionalProperties: false,
401
+ properties: selectionProps,
402
+ // Host may send either field; tool/validator enforces mode + XOR with custom.
403
+ anyOf: [{ required: ["selectedId"] }, { required: ["selectedIds"] }],
404
+ };
405
+ }
406
+ return {
407
+ type: "object",
408
+ additionalProperties: false,
409
+ properties: {
410
+ ...selectionProps,
411
+ customText: {
412
+ type: "string",
413
+ minLength: 1,
414
+ maxLength: DEFAULT_MAX_ASK_USER_DECISION_CUSTOM_BYTES,
415
+ },
416
+ },
417
+ anyOf: [{ required: ["selectedId"] }, { required: ["selectedIds"] }, { required: ["customText"] }],
418
+ };
419
+ }
420
+ export function toAskUserDecisionSuspendData(request) {
421
+ return {
422
+ question: request.question,
423
+ options: request.options,
424
+ selectionMode: request.selectionMode,
425
+ allowCustom: request.allowCustom,
426
+ ...(request.toolCallId ? { toolCallId: request.toolCallId } : {}),
427
+ ...(request.sessionId ? { sessionId: request.sessionId } : {}),
428
+ ...(request.runId ? { runId: request.runId } : {}),
429
+ };
430
+ }
431
+ function isAskUserDecisionSuspendData(value) {
432
+ if (!value || typeof value !== "object" || Array.isArray(value))
433
+ return false;
434
+ const row = value;
435
+ return (typeof row.question === "string" &&
436
+ Array.isArray(row.options) &&
437
+ (row.selectionMode === "single" || row.selectionMode === "multiple") &&
438
+ typeof row.allowCustom === "boolean");
439
+ }
440
+ /**
441
+ * Return from a workflow node to pause for a user decision (opt-in durable path).
442
+ * Host resumes via `resumeWorkflow` + `createAskUserDecisionResumeValidator` / `validateAskUserDecisionResume`.
443
+ */
444
+ export function suspendAskUserDecision(request, options) {
445
+ const data = toAskUserDecisionSuspendData(request);
446
+ if (data.options.length < 2) {
447
+ throw new Error("suspendAskUserDecision requires at least 2 options");
448
+ }
449
+ return suspend({
450
+ reason: options?.reason ?? ASK_USER_DECISION_SUSPEND_REASON,
451
+ data,
452
+ resumeSchema: askUserDecisionResumeSchema(data),
453
+ });
454
+ }
455
+ /**
456
+ * Validate resume input against the original decision request.
457
+ * Shared by workflow `validateResume` and host-held agent resume adapters.
458
+ */
459
+ export function validateAskUserDecisionResume(request, value, limits) {
460
+ if (!isAskUserDecisionSuspendData(request)) {
461
+ throw new Error("invalid ask_user_decision suspend data");
462
+ }
463
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
464
+ throw new Error("resume input must be an ask_user_decision answer object");
465
+ }
466
+ const maxCustomTextBytes = limits?.maxCustomTextBytes ?? DEFAULT_MAX_ASK_USER_DECISION_CUSTOM_BYTES;
467
+ return resolveAskUserDecisionAnswer(value, request.selectionMode, request.options, {
468
+ allowCustom: request.allowCustom,
469
+ maxCustomTextBytes,
470
+ });
471
+ }
472
+ /**
473
+ * Workflow `validateResume` adapter. Reads durable request from `suspension.data`
474
+ * (written by `suspendAskUserDecision`). Deny paths skip answer validation.
475
+ */
476
+ export function createAskUserDecisionResumeValidator(limits) {
477
+ return (input) => {
478
+ if (!isAskUserDecisionSuspendData(input.suspension.data)) {
479
+ throw new Error("suspension.data missing ask_user_decision request");
480
+ }
481
+ // Deny (and other no-input resumes) may omit answer; approve supplies it.
482
+ if (input.value === undefined || input.value === null)
483
+ return;
484
+ validateAskUserDecisionResume(input.suspension.data, input.value, limits);
485
+ };
486
+ }
487
+ /**
488
+ * Thin agent-path adapter: same validation as workflow resume, for hosts that
489
+ * persist `AskUserDecisionSuspendData` outside `AgentRunInterruption` (core kinds
490
+ * unchanged in 0.0.12). Call after operator supplies an answer.
491
+ */
492
+ export function validateAskUserDecisionAgentResume(input) {
493
+ return validateAskUserDecisionResume(input.request, input.answer, input.maxCustomTextBytes === undefined ? undefined : { maxCustomTextBytes: input.maxCustomTextBytes });
494
+ }
495
+ //# sourceMappingURL=ask-user-decision.js.map
@@ -0,0 +1,3 @@
1
+ export declare function atomicWriteUtf8File(targetPath: string, content: string, options?: {
2
+ signal?: AbortSignal;
3
+ }): Promise<void>;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Same-directory temp + rename for crash-safe UTF-8 file replacement.
3
+ * Custom WriteOperations/EditOperations should provide equivalent durability.
4
+ */
5
+ import { randomBytes } from "node:crypto";
6
+ import { rename as fsRename, unlink as fsUnlink, writeFile as fsWriteFile } from "node:fs/promises";
7
+ import { dirname, join } from "node:path";
8
+ export async function atomicWriteUtf8File(targetPath, content, options) {
9
+ const dir = dirname(targetPath);
10
+ const tempPath = join(dir, `.prism-write-${randomBytes(8).toString("hex")}`);
11
+ try {
12
+ await fsWriteFile(tempPath, content, { encoding: "utf-8", signal: options?.signal });
13
+ if (options?.signal?.aborted) {
14
+ await fsUnlink(tempPath).catch(() => { });
15
+ throw new Error("Operation aborted");
16
+ }
17
+ await fsRename(tempPath, targetPath);
18
+ }
19
+ catch (error) {
20
+ await fsUnlink(tempPath).catch(() => { });
21
+ throw error;
22
+ }
23
+ }
24
+ //# sourceMappingURL=atomic-write.js.map
package/dist/checks.js CHANGED
@@ -1,6 +1,10 @@
1
+ /**
2
+ * Named check tool: host declares fixed executable+args; model selects only a name.
3
+ */
1
4
  import { spawn } from "node:child_process";
2
5
  import { rm } from "node:fs/promises";
3
6
  import { isAbsolute } from "node:path";
7
+ import { CODING_UNSUPPORTED_EFFECT } from "./effects.js";
4
8
  import { enforceExecutionPolicy } from "./execution-policy.js";
5
9
  import { DEFAULT_CHECK_TIMEOUT_MS, DEFAULT_MAX_CHECK_CONCURRENCY, DEFAULT_MAX_CHECK_DIAGNOSTIC_LINES, DEFAULT_MAX_CHECK_NAMES, DEFAULT_MAX_CHECK_OUTPUT_BYTES, HARD_CHECK_TIMEOUT_MS, HARD_MAX_BYTES, HARD_MAX_CHECK_CONCURRENCY, HARD_MAX_CHECK_DIAGNOSTIC_LINES, HARD_MAX_CHECK_NAMES, HARD_MAX_CHECK_OUTPUT_BYTES, validateCodingLimit, } from "./limits.js";
6
10
  import { OutputAccumulator } from "./output-accumulator.js";
@@ -158,6 +162,7 @@ export function createCodingCheckTool(cwd, options) {
158
162
  const names = [...checks.keys()].sort();
159
163
  return {
160
164
  name: "coding_check",
165
+ effect: CODING_UNSUPPORTED_EFFECT,
161
166
  description: `Run a host-declared named check. Allowed names: ${names.join(", ")}. The model cannot choose executables or arguments.`,
162
167
  exclusive: true,
163
168
  parameters: {
@@ -8,8 +8,8 @@
8
8
  import { createHash } from "node:crypto";
9
9
  import { mkdir, readFile, writeFile } from "node:fs/promises";
10
10
  import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
11
- import { DEFAULT_MAX_CHECK_SUMMARY_BYTES, DEFAULT_MAX_CODING_ARTIFACT_BYTES, DEFAULT_MAX_CODING_ARTIFACTS, DEFAULT_MAX_CODING_CHECKPOINT_BYTES, DEFAULT_MAX_PLAN_BYTES, DEFAULT_MAX_TODO_TEXT_BYTES, DEFAULT_MAX_TODOS, HARD_MAX_CHECK_SUMMARY_BYTES, HARD_MAX_CODING_ARTIFACT_BYTES, HARD_MAX_CODING_ARTIFACTS, HARD_MAX_CODING_CHECKPOINT_BYTES, HARD_MAX_PLAN_BYTES, HARD_MAX_TODO_TEXT_BYTES, HARD_MAX_TODOS, validateCodingLimit, } from "./limits.js";
12
11
  import { sha256Hex } from "./artifacts.js";
12
+ import { DEFAULT_MAX_CHECK_SUMMARY_BYTES, DEFAULT_MAX_CODING_ARTIFACT_BYTES, DEFAULT_MAX_CODING_ARTIFACTS, DEFAULT_MAX_CODING_CHECKPOINT_BYTES, DEFAULT_MAX_PLAN_BYTES, DEFAULT_MAX_TODO_TEXT_BYTES, DEFAULT_MAX_TODOS, HARD_MAX_CHECK_SUMMARY_BYTES, HARD_MAX_CODING_ARTIFACT_BYTES, HARD_MAX_CODING_ARTIFACTS, HARD_MAX_CODING_CHECKPOINT_BYTES, HARD_MAX_PLAN_BYTES, HARD_MAX_TODO_TEXT_BYTES, HARD_MAX_TODOS, validateCodingLimit, } from "./limits.js";
13
13
  export const CODING_CHECKPOINT_SCHEMA_VERSION = 1;
14
14
  /** Workflow shared-state key that holds coding checkpoint metadata. */
15
15
  export const CODING_STATE_KEY = "coding";
@@ -217,9 +217,7 @@ export function validateCodingCheckpointMetadata(value, limits) {
217
217
  const planPath = requireRelativePath(value.planPath, "planPath");
218
218
  const plan = validateArtifactRef(value.plan, resolved, { requireKind: "plan" });
219
219
  const worktreePath = value.worktreePath === undefined ? undefined : requireAbsolutePath(value.worktreePath, "worktreePath");
220
- const workspaceExport = value.workspaceExport === undefined
221
- ? undefined
222
- : validateArtifactRef(value.workspaceExport, resolved);
220
+ const workspaceExport = value.workspaceExport === undefined ? undefined : validateArtifactRef(value.workspaceExport, resolved);
223
221
  const artifacts = requireArray(value.artifacts, "artifacts").map((item, index) => validateArtifactRef(item, resolved, { label: `artifacts[${index}]` }));
224
222
  if (artifacts.length > resolved.maxArtifacts) {
225
223
  throw new CodingCheckpointError(`Coding checkpoint exceeds ${resolved.maxArtifacts} artifact references`);
@@ -270,8 +268,7 @@ export function validateCodingCheckpointMetadata(value, limits) {
270
268
  export function assertCodingResumeAllowed(input) {
271
269
  const metadata = validateCodingCheckpointMetadata(input.metadata, input.limits);
272
270
  assertFingerprintsMatch(metadata.fingerprints, input.expected);
273
- if (input.expectedWorkspaceRoot !== undefined &&
274
- resolve(input.expectedWorkspaceRoot) !== resolve(metadata.workspaceRoot)) {
271
+ if (input.expectedWorkspaceRoot !== undefined && resolve(input.expectedWorkspaceRoot) !== resolve(metadata.workspaceRoot)) {
275
272
  throw new CodingCheckpointError("Workspace root mismatch on coding resume");
276
273
  }
277
274
  if (input.expectedBaseBranch !== undefined && input.expectedBaseBranch !== metadata.baseBranch) {
@@ -326,12 +323,8 @@ function validateFingerprints(value) {
326
323
  const workflowRevision = requireString(value.workflowRevision, "fingerprints.workflowRevision");
327
324
  const toolFingerprint = requireFingerprint(value.toolFingerprint, "fingerprints.toolFingerprint");
328
325
  const policyFingerprint = requireFingerprint(value.policyFingerprint, "fingerprints.policyFingerprint");
329
- const definitionHash = value.definitionHash === undefined
330
- ? undefined
331
- : requireFingerprint(value.definitionHash, "fingerprints.definitionHash");
332
- const imageDigest = value.imageDigest === undefined
333
- ? undefined
334
- : requireString(value.imageDigest, "fingerprints.imageDigest");
326
+ const definitionHash = value.definitionHash === undefined ? undefined : requireFingerprint(value.definitionHash, "fingerprints.definitionHash");
327
+ const imageDigest = value.imageDigest === undefined ? undefined : requireString(value.imageDigest, "fingerprints.imageDigest");
335
328
  if (imageDigest !== undefined && !/sha256:[a-f0-9]{64}/.test(imageDigest) && !SHA256_HEX.test(imageDigest)) {
336
329
  // Allow either raw hex or docker digest form.
337
330
  if (!imageDigest.includes("@sha256:") && !imageDigest.startsWith("sha256:")) {
@@ -421,9 +414,7 @@ function validateHandoffSummary(value, limits) {
421
414
  if (changedPathCount < 0 || checkCount < 0) {
422
415
  throw new CodingCheckpointError("handoff counts must be non-negative");
423
416
  }
424
- const artifact = value.artifact === undefined
425
- ? undefined
426
- : validateArtifactRef(value.artifact, limits, { label: "handoff.artifact" });
417
+ const artifact = value.artifact === undefined ? undefined : validateArtifactRef(value.artifact, limits, { label: "handoff.artifact" });
427
418
  return { base, head, changedPathCount, checkCount, artifact };
428
419
  }
429
420
  function requireStatus(value) {
@@ -0,0 +1,29 @@
1
+ import type { ExecutionPolicy, ToolDefinition } from "@arnilo/prism";
2
+ import type { CodingLifecycleEvent } from "./lifecycle.js";
3
+ export interface MutationStat {
4
+ isFile(): boolean;
5
+ isDirectory(): boolean;
6
+ isSymbolicLink(): boolean;
7
+ size: number;
8
+ }
9
+ export interface DeleteOperations {
10
+ lstat: (absolutePath: string, options?: {
11
+ signal?: AbortSignal;
12
+ }) => Promise<MutationStat>;
13
+ unlink: (absolutePath: string, options?: {
14
+ signal?: AbortSignal;
15
+ }) => Promise<void>;
16
+ rmdir: (absolutePath: string, options?: {
17
+ signal?: AbortSignal;
18
+ }) => Promise<void>;
19
+ readdir: (absolutePath: string, options?: {
20
+ signal?: AbortSignal;
21
+ }) => Promise<readonly string[]>;
22
+ }
23
+ export interface DeleteToolOptions {
24
+ executionPolicy?: ExecutionPolicy;
25
+ operations?: DeleteOperations;
26
+ /** Optional consumer-gated lifecycle listener (file_changed / permission_denied). */
27
+ onEvent?: (event: CodingLifecycleEvent) => void;
28
+ }
29
+ export declare function createDeleteTool(cwd: string, options?: DeleteToolOptions): ToolDefinition;