@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
package/dist/delete.js ADDED
@@ -0,0 +1,119 @@
1
+ import { lstat, readdir, rmdir, unlink } from "node:fs/promises";
2
+ import { CODING_LOCAL_EFFECT } from "./effects.js";
3
+ import { enforceExecutionPolicy } from "./execution-policy.js";
4
+ import { withFileMutationQueue } from "./file-mutation-queue.js";
5
+ import { resolveContainedMutationPath } from "./mutation-path.js";
6
+ const defaultDeleteOperations = {
7
+ lstat: async (path) => {
8
+ const st = await lstat(path);
9
+ return {
10
+ isFile: () => st.isFile(),
11
+ isDirectory: () => st.isDirectory(),
12
+ isSymbolicLink: () => st.isSymbolicLink(),
13
+ size: st.size,
14
+ };
15
+ },
16
+ unlink: (path) => unlink(path).then(() => { }),
17
+ rmdir: (path) => rmdir(path).then(() => { }),
18
+ readdir: (path) => readdir(path),
19
+ };
20
+ function errorResult(toolCallId, message) {
21
+ return {
22
+ toolCallId,
23
+ name: "delete",
24
+ content: [{ type: "text", text: message }],
25
+ error: { message },
26
+ };
27
+ }
28
+ export function createDeleteTool(cwd, options) {
29
+ const ops = options?.operations ?? defaultDeleteOperations;
30
+ return {
31
+ name: "delete",
32
+ effect: CODING_LOCAL_EFFECT,
33
+ description: "High-risk: permanently delete a single file or empty directory in the workspace. Non-empty directories are rejected (no recursive delete). No trash/recycle — host undo is not automatic. Prefer edit/write when content can be fixed in place.",
34
+ parameters: {
35
+ type: "object",
36
+ properties: {
37
+ path: { type: "string", description: "Path to the file or empty directory to delete (relative or absolute)" },
38
+ },
39
+ required: ["path"],
40
+ additionalProperties: false,
41
+ },
42
+ async execute(args, context) {
43
+ const toolCallId = context.toolCallId;
44
+ const path = typeof args.path === "string" ? args.path : "";
45
+ if (path.length === 0) {
46
+ return errorResult(toolCallId, "path is required and must be a non-empty string.");
47
+ }
48
+ try {
49
+ let absolutePath;
50
+ try {
51
+ absolutePath = await resolveContainedMutationPath(cwd, path);
52
+ }
53
+ catch (error) {
54
+ const err = error;
55
+ if (err.code === "ENOENT")
56
+ return errorResult(toolCallId, `No such file or directory: ${path}`);
57
+ throw error;
58
+ }
59
+ const policyCheck = await enforceExecutionPolicy(options?.executionPolicy, {
60
+ kind: "delete",
61
+ operation: "delete",
62
+ paths: [absolutePath],
63
+ risk: "high",
64
+ metadata: { sessionId: context.sessionId, runId: context.runId, signal: context.signal },
65
+ }, toolCallId, "delete", (denied) => options?.onEvent?.({ type: "permission_denied", ...denied }));
66
+ if (!policyCheck.allowed)
67
+ return policyCheck.result;
68
+ const allowedPath = policyCheck.action.paths?.[0] ?? absolutePath;
69
+ return await withFileMutationQueue(allowedPath, async () => {
70
+ if (context.signal?.aborted)
71
+ return errorResult(toolCallId, "Operation aborted");
72
+ let st;
73
+ try {
74
+ st = await ops.lstat(allowedPath, { signal: context.signal });
75
+ }
76
+ catch (error) {
77
+ const err = error;
78
+ if (err.code === "ENOENT")
79
+ return errorResult(toolCallId, `No such file or directory: ${path}`);
80
+ const message = error instanceof Error ? error.message : String(error);
81
+ return errorResult(toolCallId, message);
82
+ }
83
+ if (context.signal?.aborted)
84
+ return errorResult(toolCallId, "Operation aborted");
85
+ if (st.isFile() || st.isSymbolicLink()) {
86
+ await ops.unlink(allowedPath, { signal: context.signal });
87
+ options?.onEvent?.({ type: "file_changed", path: allowedPath, op: "delete", toolCallId });
88
+ return {
89
+ toolCallId,
90
+ name: "delete",
91
+ content: [{ type: "text", text: `Successfully deleted ${allowedPath}` }],
92
+ metadata: { path: allowedPath, kind: st.isSymbolicLink() ? "symlink" : "file", bytes: st.size },
93
+ };
94
+ }
95
+ if (st.isDirectory()) {
96
+ const entries = await ops.readdir(allowedPath, { signal: context.signal });
97
+ if (entries.length > 0) {
98
+ return errorResult(toolCallId, `Directory is not empty: ${path}. Recursive delete is not supported.`);
99
+ }
100
+ await ops.rmdir(allowedPath, { signal: context.signal });
101
+ options?.onEvent?.({ type: "file_changed", path: allowedPath, op: "delete", toolCallId });
102
+ return {
103
+ toolCallId,
104
+ name: "delete",
105
+ content: [{ type: "text", text: `Successfully deleted empty directory ${allowedPath}` }],
106
+ metadata: { path: allowedPath, kind: "directory" },
107
+ };
108
+ }
109
+ return errorResult(toolCallId, `Unsupported file type: ${path}`);
110
+ });
111
+ }
112
+ catch (error) {
113
+ const message = error instanceof Error ? error.message : String(error);
114
+ return errorResult(toolCallId, message);
115
+ }
116
+ },
117
+ };
118
+ }
119
+ //# sourceMappingURL=delete.js.map
package/dist/edit-diff.js CHANGED
@@ -88,10 +88,7 @@ function applyReplacements(content, replacements, offset = 0) {
88
88
  for (let i = replacements.length - 1; i >= 0; i--) {
89
89
  const replacement = replacements[i];
90
90
  const matchIndex = replacement.matchIndex - offset;
91
- result =
92
- result.substring(0, matchIndex) +
93
- replacement.newText +
94
- result.substring(matchIndex + replacement.matchLength);
91
+ result = result.substring(0, matchIndex) + replacement.newText + result.substring(matchIndex + replacement.matchLength);
95
92
  }
96
93
  return result;
97
94
  }
package/dist/edit.d.ts CHANGED
@@ -21,6 +21,8 @@
21
21
  */
22
22
  import { Buffer } from "node:buffer";
23
23
  import type { ExecutionPolicy, ToolDefinition } from "@arnilo/prism";
24
+ import type { CodingLifecycleEvent } from "./lifecycle.js";
25
+ import { type ReadBeforeWriteOptions } from "./read-path-set.js";
24
26
  export interface Edit {
25
27
  oldText: string;
26
28
  newText: string;
@@ -59,7 +61,7 @@ export interface EditOperations {
59
61
  size: number;
60
62
  }>;
61
63
  }
62
- export interface EditToolOptions {
64
+ export interface EditToolOptions extends ReadBeforeWriteOptions {
63
65
  /** Structured pre-execution policy checked before filesystem writes. */
64
66
  executionPolicy?: ExecutionPolicy;
65
67
  /** Custom operations backend (default: local filesystem). */
@@ -70,5 +72,7 @@ export interface EditToolOptions {
70
72
  maxInputBytes?: number;
71
73
  /** Maximum replacements per call (default 100). */
72
74
  maxEdits?: number;
75
+ /** Optional consumer-gated lifecycle listener (file_changed / permission_denied). */
76
+ onEvent?: (event: CodingLifecycleEvent) => void;
73
77
  }
74
78
  export declare function createEditTool(cwd: string, options?: EditToolOptions): ToolDefinition;
package/dist/edit.js CHANGED
@@ -20,17 +20,20 @@
20
20
  * completed, the edit is real and is reported as success rather than a misleading "aborted".
21
21
  */
22
22
  import { Buffer } from "node:buffer";
23
- import { access as fsAccess, stat as fsStat, writeFile as fsWriteFile, } from "node:fs/promises";
24
23
  import { constants } from "node:fs";
24
+ import { access as fsAccess, stat as fsStat } from "node:fs/promises";
25
+ import { atomicWriteUtf8File } from "./atomic-write.js";
26
+ import { CODING_LOCAL_EFFECT } from "./effects.js";
25
27
  import { readFileBounded } from "./bounded-file.js";
28
+ import { applyEditsToNormalizedContent, detectLineEnding, generateDiffString, generateUnifiedPatch, normalizeToLF, restoreLineEndings, stripBom, } from "./edit-diff.js";
26
29
  import { enforceExecutionPolicy } from "./execution-policy.js";
27
- import { resolveToCwd } from "./path-utils.js";
28
30
  import { withFileMutationQueue } from "./file-mutation-queue.js";
29
31
  import { DEFAULT_MAX_EDIT_FILE_BYTES, DEFAULT_MAX_EDIT_INPUT_BYTES, DEFAULT_MAX_EDITS, HARD_MAX_EDIT_FILE_BYTES, HARD_MAX_EDIT_INPUT_BYTES, HARD_MAX_EDITS, validateCodingLimit, } from "./limits.js";
30
- import { applyEditsToNormalizedContent, detectLineEnding, generateDiffString, generateUnifiedPatch, normalizeToLF, restoreLineEndings, stripBom, } from "./edit-diff.js";
32
+ import { resolveToCwd } from "./path-utils.js";
33
+ import { refuseReadBeforeWrite } from "./read-path-set.js";
31
34
  const defaultEditOperations = {
32
35
  readFile: (path, options) => readFileBounded(path, options.maxBytes, options.signal),
33
- writeFile: (path, content, options) => fsWriteFile(path, content, { encoding: "utf-8", signal: options?.signal }),
36
+ writeFile: (path, content, options) => atomicWriteUtf8File(path, content, { signal: options?.signal }),
34
37
  access: (path) => fsAccess(path, constants.R_OK | constants.W_OK),
35
38
  statFile: async (path) => ({ size: (await fsStat(path)).size }),
36
39
  };
@@ -93,11 +96,16 @@ export function createEditTool(cwd, options) {
93
96
  const maxEdits = validateCodingLimit("maxEdits", options?.maxEdits ?? DEFAULT_MAX_EDITS, HARD_MAX_EDITS);
94
97
  return {
95
98
  name: "edit",
96
- description: "Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.",
99
+ effect: CODING_LOCAL_EFFECT,
100
+ description: "Edit a single file using exact-then-fuzzy text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. Exact match is tried first; if it fails, fuzzy match (unicode normalize + whitespace collapse) may still succeed silently — prefer exact oldText to avoid wrong-region edits. Duplicate/ambiguous matches fail closed and leave the file unchanged. If two changes affect the same block or nearby lines, merge them into one edit. Do not include large unchanged regions just to connect distant changes. When the host enabled requireReadBeforeWrite, read the path first or pass force=true.",
97
101
  parameters: {
98
102
  type: "object",
99
103
  properties: {
100
104
  path: { type: "string", description: "Path to the file to edit (relative or absolute)" },
105
+ force: {
106
+ type: "boolean",
107
+ description: "Bypass read-before-write guard when the host enabled requireReadBeforeWrite.",
108
+ },
101
109
  edits: {
102
110
  type: "array",
103
111
  description: "One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.",
@@ -124,6 +132,7 @@ export function createEditTool(cwd, options) {
124
132
  return errorResult(toolCallId, `edit input exceeds ${maxInputBytes} byte limit.`);
125
133
  }
126
134
  const prepared = prepareEditArguments(args);
135
+ const force = args.force === true;
127
136
  if (prepared.path.length === 0) {
128
137
  return errorResult(toolCallId, "path is required and must be a non-empty string.");
129
138
  }
@@ -140,10 +149,13 @@ export function createEditTool(cwd, options) {
140
149
  paths: [absolutePath],
141
150
  risk: "medium",
142
151
  metadata: { editCount: edits.length, sessionId: context.sessionId, runId: context.runId, signal: context.signal },
143
- }, toolCallId, "edit");
152
+ }, toolCallId, "edit", (denied) => options?.onEvent?.({ type: "permission_denied", ...denied }));
144
153
  if (!policyCheck.allowed)
145
154
  return policyCheck.result;
146
155
  const allowedPath = policyCheck.action.paths?.[0] ?? absolutePath;
156
+ const rbwRefusal = refuseReadBeforeWrite("edit", prepared.path, allowedPath, options, force);
157
+ if (rbwRefusal)
158
+ return errorResult(toolCallId, rbwRefusal);
147
159
  return await withFileMutationQueue(allowedPath, async () => {
148
160
  if (context.signal?.aborted)
149
161
  return errorResult(toolCallId, "Operation aborted");
@@ -191,14 +203,13 @@ export function createEditTool(cwd, options) {
191
203
  return errorResult(toolCallId, "Operation aborted");
192
204
  const finalContent = bom + restoreLineEndings(newContent, originalEnding);
193
205
  await ops.writeFile(allowedPath, finalContent, { signal: context.signal });
206
+ options?.onEvent?.({ type: "file_changed", path: allowedPath, op: "edit", toolCallId });
194
207
  const diffResult = generateDiffString(baseContent, newContent);
195
208
  const patch = generateUnifiedPatch(prepared.path, baseContent, newContent);
196
209
  return {
197
210
  toolCallId,
198
211
  name: "edit",
199
- content: [
200
- { type: "text", text: `Successfully replaced ${edits.length} block(s) in ${prepared.path}.` },
201
- ],
212
+ content: [{ type: "text", text: `Successfully replaced ${edits.length} block(s) in ${prepared.path}.` }],
202
213
  metadata: {
203
214
  diff: diffResult.diff,
204
215
  patch,
@@ -0,0 +1,33 @@
1
+ import type { JsonObject, ToolEffectDeclaration, ToolEffectRecord, ToolResult } from "@arnilo/prism";
2
+ export declare const CODING_OBSERVATION_EFFECT: {
3
+ readonly kind: "none";
4
+ readonly idempotency: "none";
5
+ };
6
+ export declare const CODING_LOCAL_EFFECT: {
7
+ readonly kind: "local_mutation";
8
+ readonly idempotency: "optional";
9
+ };
10
+ export declare const CODING_UNSUPPORTED_EFFECT: {
11
+ readonly kind: "external_mutation";
12
+ readonly idempotency: "unsupported";
13
+ };
14
+ export declare function classifyGitBranchEffect(args: JsonObject): ToolEffectDeclaration;
15
+ export declare function classifyGitWorktreeEffect(args: JsonObject): ToolEffectDeclaration;
16
+ export declare function classifyGitApplyEffect(args: JsonObject): ToolEffectDeclaration;
17
+ export interface CodingEffectReconciliationInput {
18
+ readonly cwd: string;
19
+ readonly record: Pick<ToolEffectRecord, "toolCallId" | "toolName">;
20
+ readonly args: JsonObject;
21
+ readonly signal?: AbortSignal;
22
+ }
23
+ export type CodingEffectReconciliation = {
24
+ readonly status: "completed";
25
+ readonly result: ToolResult;
26
+ } | {
27
+ readonly status: "unknown";
28
+ };
29
+ /**
30
+ * Checks only exact local postconditions. Callers retain the pending call arguments,
31
+ * then pass a completed result to ToolEffectStore.resolveUnknown() when this returns one.
32
+ */
33
+ export declare function reconcileCodingToolEffect(input: CodingEffectReconciliationInput): Promise<CodingEffectReconciliation>;
@@ -0,0 +1,89 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { Buffer } from "node:buffer";
3
+ import { HARD_MAX_WRITE_BYTES } from "./limits.js";
4
+ import { resolveContainedMutationPath } from "./mutation-path.js";
5
+ export const CODING_OBSERVATION_EFFECT = { kind: "none", idempotency: "none" };
6
+ export const CODING_LOCAL_EFFECT = { kind: "local_mutation", idempotency: "optional" };
7
+ export const CODING_UNSUPPORTED_EFFECT = { kind: "external_mutation", idempotency: "unsupported" };
8
+ export function classifyGitBranchEffect(args) {
9
+ return args.action === "list" || args.action === "validate" ? CODING_OBSERVATION_EFFECT : CODING_LOCAL_EFFECT;
10
+ }
11
+ export function classifyGitWorktreeEffect(args) {
12
+ return args.action === "list" ? CODING_OBSERVATION_EFFECT : CODING_LOCAL_EFFECT;
13
+ }
14
+ export function classifyGitApplyEffect(args) {
15
+ return args.action === "check" ? CODING_OBSERVATION_EFFECT : CODING_LOCAL_EFFECT;
16
+ }
17
+ /**
18
+ * Checks only exact local postconditions. Callers retain the pending call arguments,
19
+ * then pass a completed result to ToolEffectStore.resolveUnknown() when this returns one.
20
+ */
21
+ export async function reconcileCodingToolEffect(input) {
22
+ input.signal?.throwIfAborted();
23
+ const completed = () => ({
24
+ status: "completed",
25
+ result: {
26
+ toolCallId: input.record.toolCallId,
27
+ name: input.record.toolName,
28
+ content: [{ type: "text", text: "Local tool postcondition verified." }],
29
+ value: { reconciled: true },
30
+ },
31
+ });
32
+ try {
33
+ if (input.record.toolName === "write") {
34
+ const path = stringArg(input.args, "path");
35
+ const content = stringArg(input.args, "content");
36
+ if (path === undefined || content === undefined || Buffer.byteLength(content, "utf8") > HARD_MAX_WRITE_BYTES)
37
+ return { status: "unknown" };
38
+ const target = await resolveContainedMutationPath(input.cwd, path);
39
+ const expected = Buffer.from(content, "utf8");
40
+ if ((await stat(target)).size !== expected.length)
41
+ return { status: "unknown" };
42
+ return (await readFile(target)).equals(expected) ? completed() : { status: "unknown" };
43
+ }
44
+ if (input.record.toolName === "delete") {
45
+ const path = stringArg(input.args, "path");
46
+ if (path === undefined)
47
+ return { status: "unknown" };
48
+ await resolveContainedMutationPath(input.cwd, path, { allowMissing: true });
49
+ return (await missing(input.cwd, path)) ? completed() : { status: "unknown" };
50
+ }
51
+ if (input.record.toolName === "move") {
52
+ const from = stringArg(input.args, "from");
53
+ const to = stringArg(input.args, "to");
54
+ if (from === undefined || to === undefined)
55
+ return { status: "unknown" };
56
+ await Promise.all([
57
+ resolveContainedMutationPath(input.cwd, from, { allowMissing: true }),
58
+ resolveContainedMutationPath(input.cwd, to, { allowMissing: true }),
59
+ ]);
60
+ if (await missing(input.cwd, from)) {
61
+ try {
62
+ await resolveContainedMutationPath(input.cwd, to);
63
+ return completed();
64
+ }
65
+ catch {
66
+ return { status: "unknown" };
67
+ }
68
+ }
69
+ }
70
+ }
71
+ catch {
72
+ // Containment/read failure is not proof that an effect did not happen.
73
+ }
74
+ return { status: "unknown" };
75
+ }
76
+ function stringArg(args, name) {
77
+ const value = args[name];
78
+ return typeof value === "string" ? value : undefined;
79
+ }
80
+ async function missing(cwd, path) {
81
+ try {
82
+ await resolveContainedMutationPath(cwd, path);
83
+ return false;
84
+ }
85
+ catch (error) {
86
+ return error.code === "ENOENT" || error.code === "ENOTDIR";
87
+ }
88
+ }
89
+ //# sourceMappingURL=effects.js.map
@@ -1,6 +1,11 @@
1
- import type { ExecutionAction, ExecutionPolicy } from "@arnilo/prism";
2
- import type { ToolResult } from "@arnilo/prism";
3
- export declare function enforceExecutionPolicy(policy: ExecutionPolicy | undefined, action: ExecutionAction, toolCallId: string, toolName: string): Promise<{
1
+ import type { ExecutionAction, ExecutionPolicy, ToolResult } from "@arnilo/prism";
2
+ export declare function enforceExecutionPolicy(policy: ExecutionPolicy | undefined, action: ExecutionAction, toolCallId: string, toolName: string,
3
+ /** Called once on policy denial so one site emits lifecycle permission_denied for every tool. */
4
+ onDenied?: (input: {
5
+ readonly toolCallId: string;
6
+ readonly toolName: string;
7
+ readonly reason: string;
8
+ }) => void): Promise<{
4
9
  allowed: true;
5
10
  action: ExecutionAction;
6
11
  } | {
@@ -1,5 +1,7 @@
1
1
  import { assertExecutionAllowed, ExecutionDeniedError } from "@arnilo/prism";
2
- export async function enforceExecutionPolicy(policy, action, toolCallId, toolName) {
2
+ export async function enforceExecutionPolicy(policy, action, toolCallId, toolName,
3
+ /** Called once on policy denial so one site emits lifecycle permission_denied for every tool. */
4
+ onDenied) {
3
5
  if (!policy)
4
6
  return { allowed: true, action };
5
7
  try {
@@ -8,10 +10,11 @@ export async function enforceExecutionPolicy(policy, action, toolCallId, toolNam
8
10
  }
9
11
  catch (error) {
10
12
  const message = error instanceof ExecutionDeniedError
11
- ? error.decision.reason ?? error.message
13
+ ? (error.decision.reason ?? error.message)
12
14
  : error instanceof Error
13
15
  ? error.message
14
16
  : String(error);
17
+ onDenied?.({ toolCallId, toolName, reason: message });
15
18
  return {
16
19
  allowed: false,
17
20
  result: {
@@ -19,8 +19,7 @@ function isMissingPathError(error) {
19
19
  return (typeof error === "object" &&
20
20
  error !== null &&
21
21
  "code" in error &&
22
- (error.code === "ENOENT" ||
23
- error.code === "ENOTDIR"));
22
+ (error.code === "ENOENT" || error.code === "ENOTDIR"));
24
23
  }
25
24
  async function getMutationQueueKey(filePath) {
26
25
  const resolvedPath = resolve(filePath);
@@ -0,0 +1,2 @@
1
+ import type { CreateGitHubForgeOptions, ForgeOperations } from "./types.js";
2
+ export declare function createGitHubForge(options: CreateGitHubForgeOptions): ForgeOperations;