toolcraft 0.0.99 → 0.0.100

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/composition.json CHANGED
@@ -48,7 +48,7 @@
48
48
  },
49
49
  {
50
50
  "name": "toolcraft",
51
- "version": "0.0.99",
51
+ "version": "0.0.100",
52
52
  "license": "MIT"
53
53
  },
54
54
  {
@@ -48,7 +48,7 @@
48
48
  },
49
49
  {
50
50
  "name": "toolcraft",
51
- "version": "0.0.99",
51
+ "version": "0.0.100",
52
52
  "license": "MIT"
53
53
  },
54
54
  {
@@ -1,11 +1,14 @@
1
1
  import type { OpenTaskListOptions, TaskList, Tasks } from "@poe-code/task-list";
2
2
  import type { HumanInLoopPending, HumanInLoopRuntimeOptions } from "./types.js";
3
+ import { type ApprovalPlanValue } from "./plan-hash.js";
3
4
  export interface ApprovalPayload {
4
5
  approvalId?: string;
5
6
  commandPath: string;
6
7
  params: Record<string, unknown>;
7
8
  message: string;
8
9
  declineInputPrompt?: string | null;
10
+ plan?: ApprovalPlanValue;
11
+ planHash?: string;
9
12
  enqueuedAt?: string;
10
13
  pid?: number | null;
11
14
  result?: unknown;
@@ -2,6 +2,7 @@ import { TaskAlreadyExistsError, TaskNotFoundError, openTaskList } from "@poe-co
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { UserError } from "../user-error.js";
4
4
  import { approvalStateMachine } from "./state-machine.js";
5
+ import { isApprovalPlanValue } from "./plan-hash.js";
5
6
  const DEFAULT_LIST_NAME = "approvals";
6
7
  const openedTaskListsByRuntime = new WeakMap();
7
8
  const validatedListsByRuntime = new WeakMap();
@@ -84,28 +85,36 @@ function isListValidated(runtimeOptions, listName) {
84
85
  }
85
86
  function createApprovalRecord(payload, enqueuedAt) {
86
87
  const approvalId = `${enqueuedAt.slice(0, 19).replaceAll(":", "-")}-${randomBytes(3).toString("hex")}`;
87
- return {
88
+ const metadata = {
89
+ schemaVersion: 1,
90
+ approvalId,
91
+ commandPath: payload.commandPath,
92
+ params: payload.params,
93
+ message: payload.message,
94
+ declineInputPrompt: payload.declineInputPrompt ?? null,
95
+ enqueuedAt,
96
+ pid: null,
97
+ result: null,
98
+ error: null
99
+ };
100
+ const pending = {
101
+ status: "pending-approval",
102
+ approvalId,
103
+ message: payload.message,
104
+ enqueuedAt
105
+ };
106
+ const approval = {
88
107
  approvalId,
89
108
  name: `${payload.commandPath} (${enqueuedAt})`,
90
- metadata: {
91
- schemaVersion: 1,
92
- approvalId,
93
- commandPath: payload.commandPath,
94
- params: payload.params,
95
- message: payload.message,
96
- declineInputPrompt: payload.declineInputPrompt ?? null,
97
- enqueuedAt,
98
- pid: null,
99
- result: null,
100
- error: null
101
- },
102
- pending: {
103
- status: "pending-approval",
104
- approvalId,
105
- message: payload.message,
106
- enqueuedAt
107
- }
109
+ metadata,
110
+ pending
108
111
  };
112
+ if (payload.plan !== undefined && payload.planHash !== undefined) {
113
+ approval.metadata.plan = payload.plan;
114
+ approval.metadata.planHash = payload.planHash;
115
+ approval.pending.planHash = payload.planHash;
116
+ }
117
+ return approval;
109
118
  }
110
119
  async function createApprovalTask(tasks, approval) {
111
120
  await tasks.create({
@@ -198,6 +207,8 @@ function approvalPayloadFromTask(task) {
198
207
  declineInputPrompt: typeof metadata.declineInputPrompt === "string" || metadata.declineInputPrompt === null
199
208
  ? metadata.declineInputPrompt
200
209
  : undefined,
210
+ plan: isApprovalPlanValue(metadata.plan) ? metadata.plan : undefined,
211
+ planHash: typeof metadata.planHash === "string" ? metadata.planHash : undefined,
201
212
  enqueuedAt: metadata.enqueuedAt,
202
213
  pid: typeof metadata.pid === "number" || metadata.pid === null ? metadata.pid : undefined,
203
214
  result: metadata.result,
@@ -12,6 +12,9 @@ export function validateHumanInLoopOnDefine(config) {
12
12
  if (typeof config.humanInLoop.message !== "function") {
13
13
  throw new Error(`${label} '${config.name}': humanInLoop.message must be a function`);
14
14
  }
15
+ if (config.humanInLoop.plan !== undefined && typeof config.humanInLoop.plan !== "function") {
16
+ throw new Error(`${label} '${config.name}': humanInLoop.plan must be a function`);
17
+ }
15
18
  }
16
19
  export function mergeHumanInLoopFromGroup(groupHumanInLoop, childHumanInLoop) {
17
20
  if (childHumanInLoop !== undefined) {
@@ -2,6 +2,7 @@ import { enqueueApproval, ensureApprovalList } from "./approval-tasks.js";
2
2
  import { defaultProviderForPlatform } from "./default-provider.js";
3
3
  import { spawnApprovalRunner } from "./spawn.js";
4
4
  import { ApprovalDeclinedError } from "./types.js";
5
+ import { assertApprovalPlanHash, createApprovalPlan, formatApprovalMessage } from "./plan-hash.js";
5
6
  const providersByRuntime = new WeakMap();
6
7
  let providerWithoutRuntime;
7
8
  export function resolveProvider(runtimeOptions) {
@@ -24,10 +25,17 @@ export async function invokeWithHumanInLoop(node, ctx, runtimeOptions, commandPa
24
25
  if (!node.humanInLoop) {
25
26
  return node.handler(ctx);
26
27
  }
27
- const message = node.humanInLoop.message({
28
+ const planContext = {
28
29
  params: ctx.params,
29
30
  commandPath
30
- });
31
+ };
32
+ const baseMessage = node.humanInLoop.message(planContext);
33
+ const approvalPlan = node.humanInLoop.plan === undefined
34
+ ? undefined
35
+ : createApprovalPlan(await node.humanInLoop.plan(planContext));
36
+ const message = approvalPlan === undefined
37
+ ? baseMessage
38
+ : formatApprovalMessage(baseMessage, approvalPlan);
31
39
  if (node.humanInLoop.mode === "async") {
32
40
  const { tasks } = await ensureApprovalList(runtimeOptions);
33
41
  const { approvalId, pending } = await (options.enqueueApproval ?? enqueueApproval)({
@@ -36,6 +44,8 @@ export async function invokeWithHumanInLoop(node, ctx, runtimeOptions, commandPa
36
44
  commandPath,
37
45
  params: ctx.params,
38
46
  message,
47
+ plan: approvalPlan?.value,
48
+ planHash: approvalPlan?.hash,
39
49
  declineInputPrompt: node.humanInLoop.declineInputPrompt
40
50
  }
41
51
  });
@@ -55,5 +65,9 @@ export async function invokeWithHumanInLoop(node, ctx, runtimeOptions, commandPa
55
65
  commandPath
56
66
  });
57
67
  }
68
+ if (approvalPlan !== undefined && node.humanInLoop.plan !== undefined) {
69
+ const executionPlan = createApprovalPlan(await node.humanInLoop.plan(planContext));
70
+ assertApprovalPlanHash(approvalPlan.hash, executionPlan.hash);
71
+ }
58
72
  return node.handler(ctx);
59
73
  }
@@ -0,0 +1,13 @@
1
+ export type ApprovalPlanValue = null | boolean | number | string | ApprovalPlanValue[] | {
2
+ [key: string]: ApprovalPlanValue;
3
+ };
4
+ export interface ApprovalPlan {
5
+ value: ApprovalPlanValue;
6
+ canonical: string;
7
+ display: string;
8
+ hash: string;
9
+ }
10
+ export declare function isApprovalPlanValue(value: unknown): value is ApprovalPlanValue;
11
+ export declare function createApprovalPlan(value: unknown): ApprovalPlan;
12
+ export declare function formatApprovalMessage(message: string, plan: ApprovalPlan): string;
13
+ export declare function assertApprovalPlanHash(expectedHash: string, actualHash: string): void;
@@ -0,0 +1,73 @@
1
+ import { createHash } from "node:crypto";
2
+ import { UserError } from "../user-error.js";
3
+ export function isApprovalPlanValue(value) {
4
+ if (value === null || typeof value === "string" || typeof value === "boolean") {
5
+ return true;
6
+ }
7
+ if (typeof value === "number") {
8
+ return Number.isFinite(value);
9
+ }
10
+ if (Array.isArray(value)) {
11
+ return value.every(isApprovalPlanValue);
12
+ }
13
+ if (typeof value !== "object") {
14
+ return false;
15
+ }
16
+ const prototype = Object.getPrototypeOf(value);
17
+ return ((prototype === Object.prototype || prototype === null) &&
18
+ Object.values(value).every(isApprovalPlanValue));
19
+ }
20
+ function normalizePlan(value, seen) {
21
+ if (value === null || typeof value === "string" || typeof value === "boolean") {
22
+ return value;
23
+ }
24
+ if (typeof value === "number") {
25
+ if (!Number.isFinite(value)) {
26
+ throw new UserError("Approval plan numbers must be finite.");
27
+ }
28
+ return value;
29
+ }
30
+ if (typeof value !== "object") {
31
+ throw new UserError("Approval plan must contain only JSON values.");
32
+ }
33
+ if (seen.has(value)) {
34
+ throw new UserError("Approval plan must not contain circular references.");
35
+ }
36
+ seen.add(value);
37
+ try {
38
+ if (Array.isArray(value)) {
39
+ return value.map((item) => normalizePlan(item, seen));
40
+ }
41
+ const prototype = Object.getPrototypeOf(value);
42
+ if (prototype !== Object.prototype && prototype !== null) {
43
+ throw new UserError("Approval plan must contain only JSON values.");
44
+ }
45
+ const normalized = {};
46
+ for (const key of Object.keys(value).sort()) {
47
+ normalized[key] = normalizePlan(value[key], seen);
48
+ }
49
+ return normalized;
50
+ }
51
+ finally {
52
+ seen.delete(value);
53
+ }
54
+ }
55
+ export function createApprovalPlan(value) {
56
+ const normalized = normalizePlan(value, new Set());
57
+ const canonical = JSON.stringify(normalized);
58
+ const digest = createHash("sha256").update(canonical).digest("hex");
59
+ return {
60
+ value: normalized,
61
+ canonical,
62
+ display: JSON.stringify(normalized, null, 2),
63
+ hash: `sha256:${digest}`
64
+ };
65
+ }
66
+ export function formatApprovalMessage(message, plan) {
67
+ return `${message}\n\nPlan:\n${plan.display}\n\nPlan hash: ${plan.hash}`;
68
+ }
69
+ export function assertApprovalPlanHash(expectedHash, actualHash) {
70
+ if (expectedHash !== actualHash) {
71
+ throw new UserError(`Approval plan changed after approval. Expected ${expectedHash}, received ${actualHash}.`);
72
+ }
73
+ }
@@ -4,6 +4,7 @@ import { createEnv, createFs } from "../runtime/io.js";
4
4
  import { ensureApprovalList } from "./approval-tasks.js";
5
5
  import { resolveProvider } from "./gate.js";
6
6
  import { createRuntimeLogger } from "../runtime-logging.js";
7
+ import { assertApprovalPlanHash, createApprovalPlan, formatApprovalMessage, isApprovalPlanValue } from "./plan-hash.js";
7
8
  const MAX_AVAILABLE_COMMAND_PATHS = 20;
8
9
  export async function runApproval(approvalId, runtimeOptions, root) {
9
10
  const { tasks } = await ensureApprovalList(runtimeOptions);
@@ -27,6 +28,7 @@ export async function runApproval(approvalId, runtimeOptions, root) {
27
28
  throw error;
28
29
  }
29
30
  try {
31
+ verifyStoredApprovalPlan(approval);
30
32
  const approvalResult = await provider.requestApproval({
31
33
  message: approval.message,
32
34
  declineInputPrompt: approval.declineInputPrompt ?? undefined
@@ -50,10 +52,32 @@ export async function runApproval(approvalId, runtimeOptions, root) {
50
52
  });
51
53
  return;
52
54
  }
55
+ let command;
56
+ let ctx;
57
+ try {
58
+ command = findCommand(root, approval.commandPath);
59
+ ctx = createHandlerContext(command, approval.params);
60
+ if (approval.planHash !== undefined) {
61
+ if (command.humanInLoop?.plan === undefined) {
62
+ throw new UserError("Approval plan can no longer be verified by this command.");
63
+ }
64
+ const executionPlan = createApprovalPlan(await command.humanInLoop.plan({
65
+ params: approval.params,
66
+ commandPath: approval.commandPath
67
+ }));
68
+ assertApprovalPlanHash(approval.planHash, executionPlan.hash);
69
+ }
70
+ }
71
+ catch (error) {
72
+ await tasks.fire(approvalId, "fail", {
73
+ metadataPatch: {
74
+ error: errorMetadataFromUnknown(error)
75
+ }
76
+ });
77
+ return;
78
+ }
53
79
  await tasks.fire(approvalId, "start");
54
80
  try {
55
- const command = findCommand(root, approval.commandPath);
56
- const ctx = createHandlerContext(command, approval.params);
57
81
  const result = await command.handler(ctx);
58
82
  const serializedResult = serializeJsonResult(result);
59
83
  if (!serializedResult.ok) {
@@ -80,6 +104,21 @@ export async function runApproval(approvalId, runtimeOptions, root) {
80
104
  });
81
105
  }
82
106
  }
107
+ function verifyStoredApprovalPlan(approval) {
108
+ const hasPlan = approval.plan !== undefined;
109
+ const hasPlanHash = approval.planHash !== undefined;
110
+ if (!hasPlan && !hasPlanHash) {
111
+ return;
112
+ }
113
+ if (approval.plan === undefined || approval.planHash === undefined) {
114
+ throw new UserError("Malformed approval plan metadata.");
115
+ }
116
+ const storedPlan = createApprovalPlan(approval.plan);
117
+ assertApprovalPlanHash(approval.planHash, storedPlan.hash);
118
+ if (!approval.message.endsWith(formatApprovalMessage("", storedPlan))) {
119
+ throw new UserError("Approval prompt does not match its stored plan hash.");
120
+ }
121
+ }
83
122
  function readApprovalPayload(task) {
84
123
  const metadata = task.metadata;
85
124
  if (typeof metadata !== "object" || metadata === null) {
@@ -103,6 +142,8 @@ function readApprovalPayload(task) {
103
142
  params: metadata.params,
104
143
  message: metadata.message,
105
144
  declineInputPrompt,
145
+ plan: isApprovalPlanValue(metadata.plan) ? metadata.plan : undefined,
146
+ planHash: typeof metadata.planHash === "string" ? metadata.planHash : undefined,
106
147
  enqueuedAt: typeof metadata.enqueuedAt === "string" ? metadata.enqueuedAt : undefined,
107
148
  pid: typeof metadata.pid === "number" || metadata.pid === null ? metadata.pid : undefined,
108
149
  result: metadata.result,
@@ -8,6 +8,10 @@ export interface HumanInLoopConfig<TParamsSchema extends ObjectSchema<any>> {
8
8
  params: Static<TParamsSchema>;
9
9
  commandPath: string;
10
10
  }) => string;
11
+ plan?: (ctx: {
12
+ params: Static<TParamsSchema>;
13
+ commandPath: string;
14
+ }) => unknown | Promise<unknown>;
11
15
  declineInputPrompt?: string;
12
16
  }
13
17
  export interface HumanInLoopRuntimeOptions {
@@ -27,6 +31,7 @@ export interface HumanInLoopPending {
27
31
  approvalId: string;
28
32
  message: string;
29
33
  enqueuedAt: string;
34
+ planHash?: string;
30
35
  }
31
36
  export declare class ApprovalDeclinedError extends UserError {
32
37
  readonly reason?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolcraft",
3
- "version": "0.0.99",
3
+ "version": "0.0.100",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -104,7 +104,7 @@
104
104
  "postpack": "node ../../scripts/manage-bundled-workspace-deps.mjs cleanup . toolcraft-design @poe-code/frontmatter @poe-code/agent-mcp-config @poe-code/agent-human-in-loop @poe-code/task-list @poe-code/agent-defs @poe-code/config-mutations @poe-code/process-runner tiny-mcp-client auth-store"
105
105
  },
106
106
  "dependencies": {
107
- "toolcraft-schema": "0.0.99",
107
+ "toolcraft-schema": "0.0.100",
108
108
  "commander": "^13.1.0",
109
109
  "fast-string-width": "^3.0.2",
110
110
  "fast-wrap-ansi": "^0.2.0",