toolroll 0.9.34 → 0.9.36

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 (52) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +7 -0
  3. package/dist/assignment.d.ts +1 -0
  4. package/dist/assignment.js +2 -2
  5. package/dist/browser-workspace.d.ts +3 -3
  6. package/dist/builder.js +4 -7
  7. package/dist/chat-acceptance.d.ts +1 -1
  8. package/dist/contracts/contract.d.ts +33 -5
  9. package/dist/contracts/contract.js +143 -16
  10. package/dist/contracts/flow-propose.d.ts +325 -0
  11. package/dist/contracts/flow-propose.js +41 -0
  12. package/dist/contracts/flow.d.ts +19400 -0
  13. package/dist/contracts/flow.js +359 -0
  14. package/dist/contracts/handoff.d.ts +91 -0
  15. package/dist/contracts/handoff.js +82 -0
  16. package/dist/contracts/proof.d.ts +136 -0
  17. package/dist/contracts/proof.js +110 -0
  18. package/dist/contracts/verification-receipt.d.ts +148 -0
  19. package/dist/contracts/verification-receipt.js +87 -0
  20. package/dist/demo.js +28 -11
  21. package/dist/desktop-update.d.ts +2 -2
  22. package/dist/evidence.d.ts +3 -40
  23. package/dist/evidence.js +5 -1
  24. package/dist/flow-gallery.d.ts +3 -2
  25. package/dist/flow-share.d.ts +10 -32
  26. package/dist/flow-share.js +65 -83
  27. package/dist/flow-starters.d.ts +2 -1
  28. package/dist/flow-triggers.d.ts +15 -72
  29. package/dist/flow-triggers.js +94 -83
  30. package/dist/flows.d.ts +37 -218
  31. package/dist/flows.js +350 -385
  32. package/dist/mate-tools.js +14 -44
  33. package/dist/mcp-connect.d.ts +3 -0
  34. package/dist/mcp-connect.js +16 -4
  35. package/dist/operate.js +31 -2
  36. package/dist/plan.d.ts +5 -5
  37. package/dist/plan.js +5 -25
  38. package/dist/process-recovery-eligibility.d.ts +2 -2
  39. package/dist/process-recovery-eligibility.js +13 -8
  40. package/dist/proof.d.ts +15 -91
  41. package/dist/proof.js +90 -249
  42. package/dist/result-evidence-readers.d.ts +42 -0
  43. package/dist/result-evidence-readers.js +91 -0
  44. package/dist/serve.js +8 -99
  45. package/dist/setup-guide.d.ts +2 -2
  46. package/dist/surface.js +8 -1
  47. package/dist/task-review-brief.d.ts +75 -0
  48. package/dist/task-review-brief.js +179 -0
  49. package/dist/text-limits.d.ts +70 -0
  50. package/dist/text-limits.js +70 -0
  51. package/dist/verification-evidence.js +24 -12
  52. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,49 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.36 — 2026-10-06
4
+
5
+ - **Flows have one contract.** Every kind of zone, step, route, answer,
6
+ option, trigger and the flow file is described once
7
+ (`src/contracts/flow.ts`), and that description is what the lead's
8
+ `propose_flow` is told, what `toolroll flows create/edit/import` and the
9
+ console check, and what `docs/flow-file.schema.json` publishes (now
10
+ generated, so it includes Send to me, Person chooses and `ifReplied`, which
11
+ the hand-written one had missed). A refusal names the field and suggests the
12
+ one meant: `steps[0].routes[0]: unknown key 'to' (did you mean goesTo?)`,
13
+ `zones[2]: unknown key 'onFail' (did you mean ifFails?)`, instead of
14
+ "there's no step called ." Every saved flow, template, starter, kit and
15
+ gallery flow reads exactly as before: the same saved bytes and digests, and
16
+ the same exported files.
17
+ - **Tightened, on purpose.** A step, flow file zone, trigger setting or
18
+ `propose_flow` argument with a key its kind doesn't take is refused by name
19
+ rather than silently ignored: `planning` on a Holding step, `remindAfter` on
20
+ a Wait step, `ifFails` on a Sort step (use `ifNotSure`, which it meant) or
21
+ `next` on a Sort or Person chooses step. A sort's `sureAt` is 0.5–99. A flow
22
+ file's zones keep only the words their kind uses when exported, and a file
23
+ or drawing made by a newer Toolroll is refused as such. Messages for flows
24
+ and triggers changed from "Zone Build: …" sentences to path-named lines.
25
+
26
+ - **Builder handoffs and proofs have one contract.** The handoff a builder
27
+ leaves, its proof and the verification receipt are versioned schemas. Fifteen
28
+ real past runs replay with identical verdicts, receipt bytes and digests.
29
+ - **One compact review packet.** `toolroll task review <id> --brief` (or
30
+ `--json`) shows a build's conclusion, changed files, check result, review
31
+ findings by severity, screenshot paths and what to do next, in about 40
32
+ lines and without logs or diffs.
33
+ - **PostHog connects in one short step.** Its sign-in now uses PostHog's own
34
+ MCP permission preset instead of listing every permission, which showed a
35
+ 75-group permission screen. Sign-in returns are now logged.
36
+ - **Tests can't mistake a real process for a fake one.** Fake process IDs in
37
+ tests sit above every platform's limit, and a guard refuses new low ones (a
38
+ Brave tab and a GitHub runner process had made tests fail at random).
39
+
40
+ ## 0.9.35 — 2026-10-05
41
+
42
+ - **One-click sign-ins ask for every permission a service lists.** Toolroll
43
+ asked for only the first 20, so a service needing one later in its list
44
+ issued a token it then refused (PostHog needs `user:read`, its 140th).
45
+ Reconnect a service that failed its test after signing in.
46
+
3
47
  ## 0.9.34 — 2026-10-05
4
48
 
5
49
  - **Plans have one contract.** The first step of making every handoff robust:
package/README.md CHANGED
@@ -117,10 +117,17 @@ toolroll project use /path/to/project
117
117
  toolroll project show
118
118
  toolroll brief
119
119
  toolroll assignment show <task>
120
+ toolroll task review <task> --brief
120
121
  toolroll task complete <task>
121
122
  toolroll task revise <task> --feedback "Keep the filter selected after reload."
122
123
  ```
123
124
 
125
+ `task review <task> --brief` combines the latest finished build or report's conclusion,
126
+ file counts, checks, review findings, screenshot paths, plan and next actions.
127
+ Use `--run <id>` for an earlier attempt, `--json` for the same fields, and `--all`
128
+ to include LOW findings. Lists stay bounded, with omitted entries counted; missing
129
+ fields say `not recorded`. The lead token works through `--token` or `TOOLROLL_LEAD_TOKEN`.
130
+
124
131
  Completion and revisions use the local sign-in already saved by `up`. Completion
125
132
  reports the exact run, commit and check outcome it handled. Agents use a scoped
126
133
  credential (`project use /path/to/project --token-file /path/to/credential`) and
@@ -170,6 +170,7 @@ export declare function assignmentBrief(assignment: AssignmentSnapshot | null):
170
170
  status: "not-recorded";
171
171
  };
172
172
  } | null;
173
+ export declare function assignmentChecksForRun(store: Store, root: string | undefined, runId: number, now: Date): AssignmentChecks;
173
174
  export declare function assignmentOf(store: Store, taskId: string, now: Date, access: AssignmentAccess, root?: string): AssignmentSnapshot | null;
174
175
  type MutationResult = {
175
176
  ok: true;
@@ -83,7 +83,7 @@ function ownerOf(store, rootId, repo) {
83
83
  }
84
84
  // Report the recorded check outcome, never turn a model verdict into a check.
85
85
  // Follow-up checks on the same commit (Run checks) upgrade a pass and show a failure.
86
- function nativeChecks(store, root, runId, now) {
86
+ export function assignmentChecksForRun(store, root, runId, now) {
87
87
  const own = buildChecks(store, root, runId);
88
88
  const run = store.getRun(runId);
89
89
  const followUps = followUpChecksOf(store, runId, now, root);
@@ -163,7 +163,7 @@ export function assignmentOf(store, taskId, now, access, root) {
163
163
  const reports = artifacts.filter(a => a.kind === "report");
164
164
  const unavailable = family.versions.flatMap(version => store.runsFor(version.refId).flatMap(run => store.artifactsFor(run.id).filter(artifact => root === undefined || !readVerifiedArtifact(root, artifact).ok)
165
165
  .map(artifact => `Saved ${artifact.kind} #${artifact.id} (run ${run.id}) is unavailable or changed.`)));
166
- const checks = result === null ? null : nativeChecks(store, root, result.id, now);
166
+ const checks = result === null ? null : assignmentChecksForRun(store, root, result.id, now);
167
167
  const finishedBuild = result?.role === "builder" && (result.outcome === "built" || result.outcome === "no-change") &&
168
168
  result.finishedAt !== null && /^[a-f0-9]{40}$/.test(result.headRevision ?? "");
169
169
  // Finished work returns to its lead or user. Strict terms and previous model
@@ -802,9 +802,9 @@ export type BrowserFlowStage = {
802
802
  wait?: {
803
803
  for: "reply" | "time" | "hours";
804
804
  minutes: number;
805
- from?: string;
806
- to?: string;
807
- timeZone?: string;
805
+ from?: string | undefined;
806
+ to?: string | undefined;
807
+ timeZone?: string | undefined;
808
808
  };
809
809
  /** A Pull request zone merges once checks pass (after a person approved), this way. */
810
810
  merge?: "squash" | "merge" | "rebase" | undefined;
package/dist/builder.js CHANGED
@@ -63,6 +63,7 @@ import { openLiveLog } from "./live.js";
63
63
  import { CheckProgressTracker } from "./check-progress.js";
64
64
  import { captureParkEvidence, captureTerminalDiff, captureBaseTree, evidenceRoot, handoffName, isImagePath, WORKTREE_EVIDENCE_DIR, storeHandoffArtifact, looksLikeProtocolFile, mailboxName, progressFileName, proofFileName, rubricFileName, proposalFileName, quarantineMailboxes, readMailbox, readVerifiedArtifact, scanForSecrets, redactSecretLines, storeEvidence, validateScreenshotBytes, imageDimensions, SCREENSHOT_BYTE_CAP, boundStreamHeadTail } from "./evidence.js";
65
65
  import { PROOF_LIMITS, parseProof, serializeProof, adjudicate, artifactManifestOnly, sameDiffStatFacts } from "./proof.js";
66
+ import { parseHandoffArtifact } from "./contracts/handoff.js";
66
67
  import { captureReviewContext } from "./review-context.js";
67
68
  import { authoritySnapshotDigest, classifyRevisionAuthority, isMilestoneRegression, milestonesOf, parseExecutionPlanDocument, parsePlanRevisionProposal, parseProgressSnapshot, renderExecutionPlanDocument, PROGRESS_LIMITS, REVISION_LIMITS, } from "./plan.js";
68
69
  import { maybeSettleRepairChain } from "./dispose.js";
@@ -1115,11 +1116,9 @@ export async function build(store, request) {
1115
1116
  try {
1116
1117
  const verified = readVerifiedArtifact(root, handoffArtifact);
1117
1118
  if (verified.ok) {
1118
- const parsed = JSON.parse(verified.content.toString("utf8"));
1119
- if (parsed.branch === branch &&
1120
- typeof parsed.conclusion === "string" &&
1121
- parsed.freshness?.currentAsOf === baseRevision) {
1122
- previousHandoff = `A previous attempt (${String(parsed.outcome ?? "finished")}) left the branch exactly where it now stands and concluded: ${parsed.conclusion}`;
1119
+ const parsed = parseHandoffArtifact(verified.content.toString("utf8"));
1120
+ if (parsed.ok && parsed.value.branch === branch && parsed.value.freshness.currentAsOf === baseRevision) {
1121
+ previousHandoff = `A previous attempt (${parsed.value.outcome}) left the branch exactly where it now stands and concluded: ${parsed.value.conclusion}`;
1123
1122
  }
1124
1123
  }
1125
1124
  }
@@ -1740,7 +1739,6 @@ export async function settleProviderOutcome(captured, result) {
1740
1739
  store.setRunPhase(request.runId, "capturing-evidence");
1741
1740
  const diffEvidence = await captureTerminalDiff(store, git, worktree, pinnedBase, baseRevision, root, request.runId, clock());
1742
1741
  storeHandoffArtifact(store, root, {
1743
- schema: 1,
1744
1742
  taskId,
1745
1743
  runId: request.runId,
1746
1744
  provider,
@@ -1835,7 +1833,6 @@ export async function settleProviderOutcome(captured, result) {
1835
1833
  store.setRunPhase(request.runId, "capturing-evidence");
1836
1834
  const diffEvidence = await captureTerminalDiff(store, git, worktree, pinnedBase, head, root, request.runId, clock());
1837
1835
  storeHandoffArtifact(store, root, {
1838
- schema: 1,
1839
1836
  taskId,
1840
1837
  runId: request.runId,
1841
1838
  provider,
@@ -45,7 +45,7 @@ export declare function readAcceptanceEvidence(store: Store, who: VerifiedApprov
45
45
  note: string | null;
46
46
  } | null;
47
47
  evidence: {
48
- kind: import("./scope.js").EvidenceKind;
48
+ kind: "changed-path" | "check" | "manual-review" | "screenshot";
49
49
  reference: string | null;
50
50
  }[];
51
51
  coverageGaps: (string | null)[];
@@ -29,10 +29,37 @@ export type ContractIssue = {
29
29
  };
30
30
  /** `["steps", 0, "routes", 0, "goesTo"]` as `steps[0].routes[0].goesTo`. */
31
31
  export declare function pathOf(path: readonly PropertyKey[]): string;
32
- /** A Zod error as path-named issues, in the error's own order. */
33
- export declare function contractIssues(error: z.ZodError): ContractIssue[];
32
+ /**
33
+ * How a contract names a key it doesn't know: the keys a person or model often writes for one it does (`onFail` for
34
+ * `ifFails`, `to` for `goesTo`), tried in order against the keys allowed at that place. Without one that fits, a key
35
+ * spelled close to an allowed one (a case or a letter or two off) is suggested.
36
+ */
37
+ export type ContractOptions = {
38
+ aliases?: Readonly<Record<string, readonly string[]>>;
39
+ };
40
+ type Where = {
41
+ schema?: z.ZodType;
42
+ input?: unknown;
43
+ options?: ContractOptions;
44
+ };
45
+ /**
46
+ * A Zod error as path-named issues, in the error's own order. Given the schema and input it came from, an unknown key
47
+ * also says which allowed key it most likely meant (`unknown key 'onFail' (did you mean ifFails?)`).
48
+ */
49
+ export declare function contractIssues(error: z.ZodError, where?: Where): ContractIssue[];
34
50
  /** A Zod error as path-named lines: `steps[0].routes[0].goesTo: required`, `routes[0]: unknown key 'to'`. */
35
- export declare function contractError(error: z.ZodError): string[];
51
+ export declare function contractError(error: z.ZodError, where?: Where): string[];
52
+ /** A refusal as a reader reports it: a stable reason code, and the path-named line as the message. */
53
+ export type ContractProblem = {
54
+ reason: string;
55
+ message: string;
56
+ };
57
+ /**
58
+ * A contract issue as a problem with a reason code built from its path — `missing-goal`, `criteria[0].how-too-long`,
59
+ * `checks-too-many`, `payload-unknown-key`, `bad-version`, `newer-version` — so callers that branch on a reason, and
60
+ * durable outcomes that recorded one, read the same as before the contract.
61
+ */
62
+ export declare function contractProblemOf(issue: ContractIssue): ContractProblem;
36
63
  export type ContractResult<T> = {
37
64
  ok: true;
38
65
  value: T;
@@ -41,7 +68,7 @@ export type ContractResult<T> = {
41
68
  issues: ContractIssue[];
42
69
  };
43
70
  /** Parse with the input reported, so a missing field reads `required` rather than a wrong type. */
44
- export declare function parseContract<T>(schema: z.ZodType<T>, input: unknown): ContractResult<T>;
71
+ export declare function parseContract<T>(schema: z.ZodType<T>, input: unknown, options?: ContractOptions): ContractResult<T>;
45
72
  /**
46
73
  * Read a versioned payload. The current version parses as itself; an older known version (0 for a payload saved
47
74
  * before it carried `version`) is upgraded by its adapter and then parsed by the same schema; a newer version is
@@ -51,9 +78,10 @@ export declare function readVersioned<T>(schema: z.ZodType<T> & {
51
78
  shape: {
52
79
  version: z.ZodLiteral<number>;
53
80
  };
54
- }, input: unknown, upgrades?: Readonly<Record<number, (payload: Record<string, unknown>) => unknown>>): ContractResult<T>;
81
+ }, input: unknown, upgrades?: Readonly<Record<number, (payload: Record<string, unknown>) => unknown>>, options?: ContractOptions): ContractResult<T>;
55
82
  /**
56
83
  * The JSON Schema a model gets for `schema` (Claude's `--json-schema`). Throws when the schema uses refine,
57
84
  * superRefine, transform or preprocess anywhere: what a model is told must be everything the schema checks.
58
85
  */
59
86
  export declare function toModelSchema(schema: z.ZodType): Record<string, unknown>;
87
+ export {};
@@ -41,55 +41,182 @@ function got(input) {
41
41
  return "an array";
42
42
  return typeof input === "object" ? "an object" : `a ${typeof input}`;
43
43
  }
44
- function issuesOf(issue) {
44
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
45
+ /** The keys an object schema allows at `path` in `input`, following arrays, wrappers and (by its discriminator) a union. */
46
+ function keysAt(schema, input, path) {
47
+ let node = schema, value = input;
48
+ for (let at = 0; node !== undefined;) {
49
+ const def = node._zod.def;
50
+ if (def.type === "optional" || def.type === "nullable" || def.type === "readonly") {
51
+ node = def["innerType"];
52
+ continue;
53
+ }
54
+ if (def.type === "lazy") {
55
+ node = def["getter"]();
56
+ continue;
57
+ }
58
+ if (def.type === "union") {
59
+ const options = def["options"];
60
+ const by = def["discriminator"];
61
+ const wanted = by !== undefined && isRecord(value) ? value[by] : undefined;
62
+ node = options.find(option => {
63
+ const shape = option._zod.def["shape"];
64
+ if (shape === undefined)
65
+ return false;
66
+ if (by === undefined)
67
+ return true;
68
+ const literal = shape[by]?._zod.def;
69
+ return literal !== undefined && literal["values"]?.includes(wanted) === true;
70
+ });
71
+ continue;
72
+ }
73
+ if (at === path.length)
74
+ return def.type === "object" ? Object.keys(def["shape"]) : null;
75
+ const part = path[at++];
76
+ if (def.type === "array" && typeof part === "number") {
77
+ node = def["element"];
78
+ value = Array.isArray(value) ? value[part] : undefined;
79
+ continue;
80
+ }
81
+ if (def.type === "object" && typeof part === "string") {
82
+ const shape = def["shape"];
83
+ node = shape[part] ?? def["catchall"];
84
+ value = isRecord(value) ? value[part] : undefined;
85
+ continue;
86
+ }
87
+ return null;
88
+ }
89
+ return null;
90
+ }
91
+ /** Edit distance, for "did you mean" (short strings only). */
92
+ function distance(a, b) {
93
+ const row = Array.from({ length: b.length + 1 }, (_, index) => index);
94
+ for (let i = 1; i <= a.length; i++) {
95
+ let last = row[0];
96
+ row[0] = i;
97
+ for (let j = 1; j <= b.length; j++) {
98
+ const was = row[j];
99
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, last + (a[i - 1] === b[j - 1] ? 0 : 1));
100
+ last = was;
101
+ }
102
+ }
103
+ return row[b.length];
104
+ }
105
+ /** The allowed key an unknown one most likely meant, or null. */
106
+ function suggestion(key, known, options) {
107
+ if (known === null || known.length === 0)
108
+ return null;
109
+ const alias = options.aliases?.[key]?.find(one => known.includes(one));
110
+ if (alias !== undefined)
111
+ return alias;
112
+ const close = known
113
+ .map(one => ({ one, far: one.toLowerCase() === key.toLowerCase() ? 0 : distance(one.toLowerCase(), key.toLowerCase()) }))
114
+ .filter(({ one, far }) => far <= (Math.min(one.length, key.length) >= 6 ? 2 : 1))
115
+ .sort((a, b) => a.far - b.far);
116
+ return close[0]?.one ?? null;
117
+ }
118
+ function issuesOf(issue, where = {}) {
45
119
  const at = pathOf(issue.path);
46
120
  const one = (kind, what) => ({ path: at, kind, line: `${at}: ${what}` });
47
121
  switch (issue.code) {
48
- case "unrecognized_keys":
49
- return issue.keys.map(key => ({ path: at, kind: "unknown-key", line: `${at}: unknown key '${key}'` }));
122
+ case "unrecognized_keys": {
123
+ const known = where.schema === undefined ? null : keysAt(where.schema, where.input, issue.path);
124
+ return issue.keys.map(key => {
125
+ const meant = suggestion(key, known, where.options ?? {});
126
+ return { path: at, kind: "unknown-key", line: `${at}: unknown key '${key}'${meant === null ? "" : ` (did you mean ${meant}?)`}` };
127
+ });
128
+ }
50
129
  case "invalid_type": {
51
130
  const missing = "input" in issue ? issue.input === undefined : / received undefined$/.test(issue.message);
52
- if (missing)
131
+ // A null where a value belongs is a value left out.
132
+ if (missing || ("input" in issue && issue.input === null && issue.expected !== "null"))
53
133
  return [one("required", "required")];
54
- return [one("wrong-type", `must be ${issue.expected === "array" ? "an array" : issue.expected === "object" ? "an object" : `a ${issue.expected}`}${"input" in issue ? ` (got ${got(issue.input)})` : ""}`)];
134
+ const expected = issue.expected === "array" ? "an array" : issue.expected === "object" ? "an object" : issue.expected === "null" ? "null" : `a ${issue.expected}`;
135
+ return [one("wrong-type", `must be ${expected}${"input" in issue ? ` (got ${got(issue.input)})` : ""}`)];
55
136
  }
56
137
  case "too_big":
57
138
  if (issue.origin === "array" || issue.origin === "set")
58
139
  return [one("too-many", `at most ${String(issue.maximum)} items`)];
140
+ if (issue.origin === "number" || issue.origin === "int" || issue.origin === "bigint")
141
+ return [one("bad-value", `at most ${String(issue.maximum)}`)];
59
142
  return [one("too-long", issue.origin === "string" && issue.message.startsWith("over ") ? issue.message : `at most ${String(issue.maximum)}${issue.origin === "string" ? " characters" : ""}`)];
60
143
  case "too_small":
61
144
  if (issue.origin === "string" && Number(issue.minimum) === 1)
62
145
  return [one("empty", "must not be empty")];
63
146
  if (issue.origin === "array" || issue.origin === "set")
64
147
  return [one("too-few", `at least ${String(issue.minimum)} item${Number(issue.minimum) === 1 ? "" : "s"}`)];
148
+ if (issue.origin === "number" || issue.origin === "int" || issue.origin === "bigint")
149
+ return [one("bad-value", `at least ${String(issue.minimum)}`)];
65
150
  return [one("too-few", `at least ${String(issue.minimum)}`)];
66
151
  case "invalid_value":
67
152
  return [one("bad-value", `must be ${issue.values.length === 1 ? JSON.stringify(issue.values[0]) : `one of ${issue.values.map(value => JSON.stringify(value)).join(", ")}`}`)];
68
- case "invalid_union":
153
+ case "invalid_union": {
154
+ // A discriminated union names its choices (`steps[0].kind: must be one of "inbox", "task", ...`).
155
+ const by = issue.discriminator, options = issue.options;
156
+ if (typeof by === "string" && Array.isArray(options)) {
157
+ const given = "input" in issue && isRecord(issue.input) ? issue.input[by] : undefined;
158
+ const path = at === "payload" || issue.path.at(-1) === by ? at : `${at}.${by}`;
159
+ const line = given === undefined || given === null ? "required" : `must be one of ${options.map(value => JSON.stringify(value)).join(", ")}`;
160
+ return [{ path, kind: given === undefined || given === null ? "required" : "bad-value", line: `${path}: ${line}` }];
161
+ }
69
162
  return [one("invalid", "does not match any allowed shape")];
163
+ }
164
+ case "invalid_format":
165
+ // A pattern's own words say what it wants (`must be a short id: lowercase letters, numbers and dashes`).
166
+ return [one("bad-value", issue.message.startsWith("Invalid") ? `must match ${issue.format === "regex" ? issue.pattern ?? "its pattern" : issue.format}` : issue.message)];
70
167
  default:
71
168
  return [one("invalid", issue.message)];
72
169
  }
73
170
  }
74
- /** A Zod error as path-named issues, in the error's own order. */
75
- export function contractIssues(error) {
76
- return error.issues.flatMap(issuesOf);
171
+ /**
172
+ * A Zod error as path-named issues, in the error's own order. Given the schema and input it came from, an unknown key
173
+ * also says which allowed key it most likely meant (`unknown key 'onFail' (did you mean ifFails?)`).
174
+ */
175
+ export function contractIssues(error, where = {}) {
176
+ return error.issues.flatMap(issue => issuesOf(issue, where));
77
177
  }
78
178
  /** A Zod error as path-named lines: `steps[0].routes[0].goesTo: required`, `routes[0]: unknown key 'to'`. */
79
- export function contractError(error) {
80
- return contractIssues(error).map(issue => issue.line);
179
+ export function contractError(error, where = {}) {
180
+ return contractIssues(error, where).map(issue => issue.line);
181
+ }
182
+ /**
183
+ * A contract issue as a problem with a reason code built from its path — `missing-goal`, `criteria[0].how-too-long`,
184
+ * `checks-too-many`, `payload-unknown-key`, `bad-version`, `newer-version` — so callers that branch on a reason, and
185
+ * durable outcomes that recorded one, read the same as before the contract.
186
+ */
187
+ export function contractProblemOf(issue) {
188
+ const at = issue.path;
189
+ const reason = (() => {
190
+ switch (issue.kind) {
191
+ case "required":
192
+ case "empty":
193
+ case "too-few":
194
+ return `missing-${at}`;
195
+ case "too-long":
196
+ return `${at}-too-long`;
197
+ case "too-many":
198
+ return `${at}-too-many`;
199
+ case "unknown-key":
200
+ return `${at}-unknown-key`;
201
+ case "newer-version":
202
+ return "newer-version";
203
+ default:
204
+ return `bad-${at}`;
205
+ }
206
+ })();
207
+ return { reason, message: issue.line };
81
208
  }
82
209
  /** Parse with the input reported, so a missing field reads `required` rather than a wrong type. */
83
- export function parseContract(schema, input) {
210
+ export function parseContract(schema, input, options = {}) {
84
211
  const parsed = schema.safeParse(input, { reportInput: true });
85
- return parsed.success ? { ok: true, value: parsed.data } : { ok: false, issues: contractIssues(parsed.error) };
212
+ return parsed.success ? { ok: true, value: parsed.data } : { ok: false, issues: contractIssues(parsed.error, { schema, input, options }) };
86
213
  }
87
214
  /**
88
215
  * Read a versioned payload. The current version parses as itself; an older known version (0 for a payload saved
89
216
  * before it carried `version`) is upgraded by its adapter and then parsed by the same schema; a newer version is
90
217
  * refused plainly; anything else is an unknown version.
91
218
  */
92
- export function readVersioned(schema, input, upgrades = {}) {
219
+ export function readVersioned(schema, input, upgrades = {}, options = {}) {
93
220
  const current = schema.shape.version.value;
94
221
  if (typeof input !== "object" || input === null || Array.isArray(input)) {
95
222
  return { ok: false, issues: [{ path: "payload", kind: "wrong-type", line: `payload: must be an object (got ${got(input)})` }] };
@@ -97,7 +224,7 @@ export function readVersioned(schema, input, upgrades = {}) {
97
224
  const body = input;
98
225
  const version = Object.prototype.hasOwnProperty.call(body, "version") ? body["version"] : 0;
99
226
  if (version === current)
100
- return parseContract(schema, body);
227
+ return parseContract(schema, body, options);
101
228
  if (typeof version === "number" && Number.isInteger(version) && version > current) {
102
229
  return { ok: false, issues: [{ path: "version", kind: "newer-version", line: `version: made by a newer Toolroll (version ${version}; this one reads up to ${current})` }] };
103
230
  }
@@ -105,7 +232,7 @@ export function readVersioned(schema, input, upgrades = {}) {
105
232
  if (upgrade === undefined) {
106
233
  return { ok: false, issues: [{ path: "version", kind: "bad-value", line: `version: unknown version ${JSON.stringify(version)} (this Toolroll reads ${current}${Object.keys(upgrades).length === 0 ? "" : ` and ${Object.keys(upgrades).join(", ")}`})` }] };
107
234
  }
108
- return parseContract(schema, upgrade(body));
235
+ return parseContract(schema, upgrade(body), options);
109
236
  }
110
237
  /** The Zod node kinds a model-facing schema may use: each says exactly what its JSON Schema says. */
111
238
  const MODEL_FACING_TYPES = new Set(["string", "number", "int", "boolean", "null", "literal", "enum", "array", "object", "optional", "nullable", "union", "tuple", "record", "lazy", "readonly", "unknown", "any"]);