toolroll 0.9.34 → 0.9.37

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 (195) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +7 -0
  3. package/dist/assignment-adapters.d.ts +12 -48
  4. package/dist/assignment-adapters.js +33 -43
  5. package/dist/assignment.d.ts +1 -0
  6. package/dist/assignment.js +2 -2
  7. package/dist/browser-workspace.d.ts +3 -3
  8. package/dist/build-review.d.ts +1 -1
  9. package/dist/build-review.js +2 -1
  10. package/dist/builder.js +4 -7
  11. package/dist/chat-acceptance.d.ts +2 -2
  12. package/dist/chat-actions.d.ts +18 -12
  13. package/dist/chat-actions.js +15 -65
  14. package/dist/chat-ask.js +5 -5
  15. package/dist/chat-decide.d.ts +14 -1
  16. package/dist/chat-decide.js +12 -6
  17. package/dist/chat-delivery-state.d.ts +12 -70
  18. package/dist/chat-delivery-state.js +17 -135
  19. package/dist/chat-delivery.js +58 -30
  20. package/dist/chat-flow.js +8 -6
  21. package/dist/chat-inbox.js +1 -1
  22. package/dist/coding-context.d.ts +2 -39
  23. package/dist/coding-context.js +2 -1
  24. package/dist/coding-handoff.d.ts +6 -25
  25. package/dist/coding-handoff.js +11 -5
  26. package/dist/coding-types.d.ts +2 -26
  27. package/dist/coding-workspace.d.ts +2 -0
  28. package/dist/coding-workspace.js +50 -14
  29. package/dist/contracts/acceptance-terms.d.ts +14 -0
  30. package/dist/contracts/acceptance-terms.js +16 -0
  31. package/dist/contracts/chat-actions.d.ts +328 -0
  32. package/dist/contracts/chat-actions.js +133 -0
  33. package/dist/contracts/chat-callback-rows.d.ts +59 -0
  34. package/dist/contracts/chat-callback-rows.js +46 -0
  35. package/dist/contracts/chat-content.d.ts +276 -0
  36. package/dist/contracts/chat-content.js +127 -0
  37. package/dist/contracts/chat-tables.d.ts +6 -0
  38. package/dist/contracts/chat-tables.js +136 -0
  39. package/dist/contracts/coding-context.d.ts +97 -0
  40. package/dist/contracts/coding-context.js +60 -0
  41. package/dist/contracts/coding-handoff.d.ts +44 -0
  42. package/dist/contracts/coding-handoff.js +47 -0
  43. package/dist/contracts/coding-workspace.d.ts +165 -0
  44. package/dist/contracts/coding-workspace.js +71 -0
  45. package/dist/contracts/contract.d.ts +40 -7
  46. package/dist/contracts/contract.js +155 -19
  47. package/dist/contracts/decision.d.ts +45 -0
  48. package/dist/contracts/decision.js +45 -0
  49. package/dist/contracts/discord-callback.d.ts +45 -0
  50. package/dist/contracts/discord-callback.js +34 -0
  51. package/dist/contracts/flow-card.d.ts +50 -0
  52. package/dist/contracts/flow-card.js +46 -0
  53. package/dist/contracts/flow-propose.d.ts +325 -0
  54. package/dist/contracts/flow-propose.js +41 -0
  55. package/dist/contracts/flow-send.d.ts +119 -0
  56. package/dist/contracts/flow-send.js +112 -0
  57. package/dist/contracts/flow.d.ts +19405 -0
  58. package/dist/contracts/flow.js +364 -0
  59. package/dist/contracts/gateway-tools.d.ts +374 -0
  60. package/dist/contracts/gateway-tools.js +94 -0
  61. package/dist/contracts/handoff.d.ts +91 -0
  62. package/dist/contracts/handoff.js +82 -0
  63. package/dist/contracts/lead-context.d.ts +165 -0
  64. package/dist/contracts/lead-context.js +73 -0
  65. package/dist/contracts/lead-tools.d.ts +1905 -0
  66. package/dist/contracts/lead-tools.js +265 -0
  67. package/dist/contracts/memory-pass.d.ts +158 -0
  68. package/dist/contracts/memory-pass.js +102 -0
  69. package/dist/contracts/project-knowledge.d.ts +126 -0
  70. package/dist/contracts/project-knowledge.js +93 -0
  71. package/dist/contracts/project-memory.d.ts +117 -0
  72. package/dist/contracts/project-memory.js +51 -0
  73. package/dist/contracts/project-skills.d.ts +121 -0
  74. package/dist/contracts/project-skills.js +76 -0
  75. package/dist/contracts/proof.d.ts +136 -0
  76. package/dist/contracts/proof.js +110 -0
  77. package/dist/contracts/review-findings.d.ts +46 -0
  78. package/dist/contracts/review-findings.js +48 -0
  79. package/dist/contracts/route.d.ts +484 -0
  80. package/dist/contracts/route.js +128 -0
  81. package/dist/contracts/scope.d.ts +105 -0
  82. package/dist/contracts/scope.js +114 -0
  83. package/dist/contracts/scout-report.d.ts +234 -0
  84. package/dist/contracts/scout-report.js +141 -0
  85. package/dist/contracts/slack-callback.d.ts +43 -0
  86. package/dist/contracts/slack-callback.js +35 -0
  87. package/dist/contracts/sort-answer.d.ts +83 -0
  88. package/dist/contracts/sort-answer.js +77 -0
  89. package/dist/contracts/stage-output.d.ts +127 -0
  90. package/dist/contracts/stage-output.js +166 -0
  91. package/dist/contracts/task-sizing.d.ts +24 -0
  92. package/dist/contracts/task-sizing.js +28 -0
  93. package/dist/contracts/teammate-turn.d.ts +43 -0
  94. package/dist/contracts/teammate-turn.js +51 -0
  95. package/dist/contracts/teams-callback.d.ts +14 -0
  96. package/dist/contracts/teams-callback.js +14 -0
  97. package/dist/contracts/telegram-callback.d.ts +120 -0
  98. package/dist/contracts/telegram-callback.js +97 -0
  99. package/dist/contracts/verification-receipt.d.ts +148 -0
  100. package/dist/contracts/verification-receipt.js +87 -0
  101. package/dist/decision.d.ts +11 -20
  102. package/dist/decision.js +86 -167
  103. package/dist/demo.js +28 -11
  104. package/dist/discord-chat.js +31 -20
  105. package/dist/evidence.d.ts +3 -40
  106. package/dist/evidence.js +5 -1
  107. package/dist/flow-engine.js +14 -17
  108. package/dist/flow-gallery.d.ts +3 -2
  109. package/dist/flow-items.d.ts +4 -13
  110. package/dist/flow-items.js +1 -15
  111. package/dist/flow-send.d.ts +6 -37
  112. package/dist/flow-send.js +18 -14
  113. package/dist/flow-share.d.ts +10 -32
  114. package/dist/flow-share.js +72 -84
  115. package/dist/flow-sort.d.ts +7 -26
  116. package/dist/flow-sort.js +25 -20
  117. package/dist/flow-starters.d.ts +2 -1
  118. package/dist/flow-steps.js +3 -2
  119. package/dist/flow-triggers.d.ts +15 -72
  120. package/dist/flow-triggers.js +94 -83
  121. package/dist/flows.d.ts +37 -220
  122. package/dist/flows.js +356 -387
  123. package/dist/invoke.js +4 -4
  124. package/dist/lead-context.d.ts +4 -2
  125. package/dist/lead-context.js +24 -17
  126. package/dist/mate-tools.d.ts +4 -0
  127. package/dist/mate-tools.js +111 -308
  128. package/dist/mcp-connect.d.ts +27 -4
  129. package/dist/mcp-connect.js +92 -17
  130. package/dist/mcp.js +84 -216
  131. package/dist/memory-pass.d.ts +17 -42
  132. package/dist/memory-pass.js +54 -26
  133. package/dist/operate.js +31 -2
  134. package/dist/phase-routing.d.ts +19 -72
  135. package/dist/phase-routing.js +34 -164
  136. package/dist/plan.d.ts +5 -5
  137. package/dist/plan.js +5 -25
  138. package/dist/process-recovery-eligibility.d.ts +2 -2
  139. package/dist/process-recovery-eligibility.js +13 -8
  140. package/dist/project-knowledge.d.ts +16 -35
  141. package/dist/project-knowledge.js +42 -21
  142. package/dist/project-memory.d.ts +2 -28
  143. package/dist/project-memory.js +28 -13
  144. package/dist/project-skills.d.ts +16 -38
  145. package/dist/project-skills.js +41 -32
  146. package/dist/project-tools.d.ts +1 -0
  147. package/dist/project-tools.js +4 -4
  148. package/dist/proof.d.ts +15 -91
  149. package/dist/proof.js +90 -249
  150. package/dist/provider.d.ts +2 -0
  151. package/dist/provider.js +4 -16
  152. package/dist/result-evidence-readers.d.ts +42 -0
  153. package/dist/result-evidence-readers.js +91 -0
  154. package/dist/result-shots.d.ts +1 -1
  155. package/dist/result-shots.js +2 -1
  156. package/dist/review-switch.d.ts +3 -8
  157. package/dist/reviewer.d.ts +8 -12
  158. package/dist/reviewer.js +11 -35
  159. package/dist/scope.d.ts +21 -43
  160. package/dist/scope.js +42 -128
  161. package/dist/scout-report.d.ts +9 -194
  162. package/dist/scout-report.js +113 -264
  163. package/dist/scout.js +15 -11
  164. package/dist/serve.js +17 -101
  165. package/dist/slack-chat.js +26 -20
  166. package/dist/slack-state.js +2 -1
  167. package/dist/store.d.ts +9 -32
  168. package/dist/store.js +18 -15
  169. package/dist/structured-output.js +2 -1
  170. package/dist/subscription-chat.d.ts +4 -0
  171. package/dist/subscription-chat.js +4 -1
  172. package/dist/surface.js +8 -1
  173. package/dist/task-review-brief.d.ts +69 -0
  174. package/dist/task-review-brief.js +179 -0
  175. package/dist/task-sizing.d.ts +3 -27
  176. package/dist/task-sizing.js +11 -26
  177. package/dist/task-text.d.ts +0 -5
  178. package/dist/task-text.js +1 -3
  179. package/dist/teammate-question.js +7 -5
  180. package/dist/teammate-work.js +9 -5
  181. package/dist/teammates.d.ts +13 -64
  182. package/dist/teammates.js +32 -33
  183. package/dist/teams-chat.js +20 -11
  184. package/dist/telegram-flow.js +4 -3
  185. package/dist/telegram-mate.d.ts +4 -14
  186. package/dist/telegram-mate.js +8 -7
  187. package/dist/telegram-team.d.ts +2 -19
  188. package/dist/telegram.d.ts +1 -1
  189. package/dist/telegram.js +52 -42
  190. package/dist/text-limits.d.ts +212 -1
  191. package/dist/text-limits.js +214 -1
  192. package/dist/tools-ui.d.ts +2 -1
  193. package/dist/tools-ui.js +7 -4
  194. package/dist/verification-evidence.js +24 -12
  195. package/package.json +1 -1
@@ -1,285 +1,138 @@
1
1
  /**
2
- * The scout's terminal handoff (mate arc §10), parsed with the 422 rule:
3
- * fail closed, every problem reported at once, stable reasons, caps and
4
- * control-character rejection on every string. A report reaches the task
5
- * page, the ledger, the terminal, and — through mateView — the mate; and
6
- * each follow-up becomes a filing's title and goal at one tap, so it gets
7
- * the park discipline exactly as the plan does.
2
+ * The scout's terminal handoff (mate arc §10), read against the report contract (src/contracts/scout-report.ts) with
3
+ * the 422 rule: fail closed, every problem reported at once, each naming its path (`items[0].url: must be an http or
4
+ * https address`), stable reasons, caps and control-character rejection on every string. A report reaches the task
5
+ * page, the ledger, the terminal, and — through mateView — the mate; and each follow-up becomes a filing's title and
6
+ * goal at one tap, so it gets the park discipline exactly as the plan does.
8
7
  */
8
+ import { contractProblemOf } from "./contracts/contract.js";
9
+ import { FOLLOW_UP_TEXT_FIELDS, IMAGE_TEXT_FIELDS, ITEM_TEXT_FIELDS, readReportPayload, REPORT_LIMITS, REPORT_TEXT_FIELDS, reportPayloadBody, } from "./contracts/scout-report.js";
9
10
  import { hasForbiddenControls } from "./decision.js";
10
- import { TASK_TEXT_LIMITS } from "./task-text.js";
11
11
  import { TEXT_LIMITS } from "./text-limits.js";
12
- /** Caps are BYTES of UTF-8 (v4 review, finding 11): a 64 KiB report is
13
- * 64 KiB whatever script it is written in. They hold what a scout writes;
14
- * a stored report is read without them (parseReport's `stored`). */
15
- export const REPORT_LIMITS = {
16
- payload: 96 * 1024,
17
- title: 200,
18
- summary: TEXT_LIMITS.reportSummary,
19
- document: 64 * 1024,
20
- followUps: 5,
21
- followUpTitle: 200,
22
- /** A follow-up files as a task: its goal is held to the task goal limit, in bytes of the same count. */
23
- followUpGoal: TASK_TEXT_LIMITS.text,
24
- items: 6,
25
- itemTitle: 200,
26
- itemWhy: 1_000,
27
- url: 2_000,
28
- images: 8,
29
- caption: 300,
30
- };
31
- /** A screenshot's name in the scout's output folder: one plain file name, PNG or JPEG, never a path. */
32
- export const REPORT_IMAGE_FILE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}\.(?:png|jpe?g)$/i;
33
- const REPORT_SHAPE = {
34
- type: "object",
35
- properties: {
36
- title: { type: "string" },
37
- summary: { type: "string" },
38
- report: { type: "string" },
39
- followUps: {
40
- type: "array",
41
- items: {
42
- type: "object",
43
- properties: { title: { type: "string" }, goal: { type: "string" } },
44
- required: ["title", "goal"],
45
- additionalProperties: false,
46
- },
47
- },
48
- items: {
49
- type: "array",
50
- items: {
51
- type: "object",
52
- properties: { title: { type: "string" }, why: { type: "string" }, url: { type: "string" }, image: { type: "string" } },
53
- required: ["title", "why", "url"],
54
- additionalProperties: false,
55
- },
56
- },
57
- images: {
58
- type: "array",
59
- items: {
60
- type: "object",
61
- properties: { file: { type: "string" }, caption: { type: "string" }, url: { type: "string" } },
62
- required: ["file", "caption", "url"],
63
- additionalProperties: false,
64
- },
65
- },
66
- },
67
- required: ["title", "summary", "report"],
68
- additionalProperties: false,
69
- };
70
- /** The park mailbox's decision, the same fields `parseDecision` reads. */
71
- const DECISION_SHAPE = {
72
- type: "object",
73
- properties: {
74
- urgency: { type: "string", enum: ["blocking"] },
75
- recap: { type: "string" },
76
- question: { type: "string" },
77
- options: {
78
- type: "array",
79
- items: {
80
- type: "object",
81
- properties: {
82
- id: { type: "string" },
83
- label: { type: "string" },
84
- consequence: { type: "string" },
85
- reversible: { type: "boolean" },
86
- },
87
- required: ["id", "label", "consequence", "reversible"],
88
- additionalProperties: false,
89
- },
90
- },
91
- recommendation: { type: "string" },
92
- },
93
- required: ["urgency", "recap", "question", "options", "recommendation"],
94
- additionalProperties: false,
95
- };
96
- /**
97
- * The scout's handback for Claude's `--json-schema` (run 2334's fix): plan
98
- * mode only lets a session write its own plan file, so a Claude scout
99
- * returns a report — or a question for the operator — as structured output
100
- * on the terminal result event. Shape only, never the validator: byte caps,
101
- * one-line titles and control characters stay `parseReport`'s and
102
- * `parseDecision`'s, applied to the re-serialized body.
103
- */
104
- export const SCOUT_OUTPUT_JSON_SCHEMA = {
105
- type: "object",
106
- properties: {
107
- kind: { type: "string", enum: ["report", "question"] },
108
- report: REPORT_SHAPE,
109
- decision: DECISION_SHAPE,
110
- },
111
- required: ["kind"],
112
- additionalProperties: false,
113
- };
12
+ export { REPORT_IMAGE_FILE, REPORT_LIMITS, REPORT_VERSION, SCOUT_OUTPUT_JSON_SCHEMA, } from "./contracts/scout-report.js";
114
13
  function refuse(reason, message) {
115
14
  return { ok: false, problems: [{ reason, message }] };
116
15
  }
117
- function describe(value) {
118
- if (value === undefined)
119
- return "nothing";
120
- if (value === null)
121
- return "null";
122
- if (typeof value === "string")
123
- return `a ${value.length}-char string`;
124
- return `a ${Array.isArray(value) ? "array" : typeof value}`;
125
- }
126
- function prose(value, field, cap, problems) {
127
- if (value === undefined || value === null || value === "" || (typeof value === "string" && value.trim() === "")) {
128
- problems.push({ reason: `missing-${field}`, message: `${field} is required` });
129
- return null;
130
- }
131
- if (typeof value !== "string") {
132
- problems.push({ reason: `bad-${field}`, message: `${field} must be a string (got ${describe(value)})` });
133
- return null;
134
- }
135
- if (!readingStored && Buffer.byteLength(value, "utf8") > cap) {
136
- problems.push({ reason: `${field}-too-long`, message: `${field} is over ${cap} bytes` });
137
- return null;
138
- }
139
- if (hasForbiddenControls(value)) {
140
- problems.push({ reason: `${field}-controls`, message: `${field} carries control characters that could become terminal escapes` });
141
- return null;
142
- }
143
- return value;
144
- }
145
- /** True while a stored report is read: a limit is for writing, and reading never re-checks a text's length. */
146
- let readingStored = false;
147
16
  /** A report, checked. `stored`: one already kept as evidence, read without the length caps (a report written under
148
17
  * higher limits stays readable); every other check still holds. */
149
18
  export function parseReport(raw, options = {}) {
150
- readingStored = options.stored === true;
151
- try {
152
- return parseReportBody(raw);
153
- }
154
- finally {
155
- readingStored = false;
156
- }
157
- }
158
- function parseReportBody(raw) {
159
- if (!readingStored && Buffer.byteLength(raw, "utf8") > REPORT_LIMITS.payload) {
160
- return refuse("too-large", `the payload is over ${REPORT_LIMITS.payload} bytes`);
19
+ const stored = options.stored === true;
20
+ if (!stored && Buffer.byteLength(raw, "utf8") > REPORT_LIMITS.payload) {
21
+ return refuse("too-large", `payload: over ${REPORT_LIMITS.payload.toLocaleString("en-US")} bytes`);
161
22
  }
162
23
  let parsed;
163
24
  try {
164
25
  parsed = JSON.parse(raw);
165
26
  }
166
27
  catch (error) {
167
- return refuse("not-json", `the payload is not JSON: ${error instanceof Error ? error.message : String(error)}`);
168
- }
169
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
170
- return refuse("not-an-object", "the payload must be one JSON object");
171
- }
172
- const body = parsed;
173
- const problems = [];
174
- const title = prose(body["title"], "title", REPORT_LIMITS.title, problems);
175
- const summary = prose(body["summary"], "summary", REPORT_LIMITS.summary, problems);
176
- const document = prose(body["report"], "report", REPORT_LIMITS.document, problems);
177
- if (title !== null && /[\n\r]/.test(title)) {
178
- problems.push({ reason: "title-multiline", message: "title must be one line" });
179
- }
180
- // One paragraph: the summary is what the operator reads first, on a
181
- // phone, in a digest line — a blank line inside it is a second paragraph.
182
- if (summary !== null && /\n[ \t]*\n/.test(summary)) {
183
- problems.push({ reason: "summary-paragraphs", message: "summary is one paragraph — no blank lines" });
184
- }
185
- const followUps = [];
186
- if (body["followUps"] !== undefined && body["followUps"] !== null) {
187
- if (!Array.isArray(body["followUps"])) {
188
- problems.push({ reason: "bad-followUps", message: `followUps must be an array (got ${describe(body["followUps"])})` });
189
- }
190
- else if (body["followUps"].length > REPORT_LIMITS.followUps) {
191
- problems.push({ reason: "followUps-too-many", message: `followUps lists ${body["followUps"].length} — cap is ${REPORT_LIMITS.followUps}` });
192
- }
193
- else {
194
- for (const [index, one] of body["followUps"].entries()) {
195
- if (typeof one !== "object" || one === null || Array.isArray(one)) {
196
- problems.push({ reason: `followUps[${index}]-shape`, message: `followUps[${index}] must be {title, goal}` });
197
- continue;
198
- }
199
- const entry = one;
200
- const followTitle = prose(entry["title"], `followUps[${index}].title`, REPORT_LIMITS.followUpTitle, problems);
201
- const goal = prose(entry["goal"], `followUps[${index}].goal`, REPORT_LIMITS.followUpGoal, problems);
202
- if (followTitle !== null && /[\n\r]/.test(followTitle)) {
203
- problems.push({ reason: `followUps[${index}]-title-multiline`, message: `followUps[${index}].title must be one line` });
204
- }
205
- else if (followTitle !== null && goal !== null) {
206
- followUps.push({ title: followTitle, goal });
207
- }
208
- }
209
- }
210
- }
211
- const images = listOf(body["images"], "images", REPORT_LIMITS.images, problems, (entry, at) => {
212
- // Any one line here: the runner refuses an image that isn't a plain file name in its output folder, not the report.
213
- const file = prose(entry["file"], `${at}.file`, REPORT_LIMITS.caption, problems);
214
- const caption = prose(entry["caption"], `${at}.caption`, REPORT_LIMITS.caption, problems);
215
- const url = link(entry["url"], `${at}.url`, problems);
216
- if (file !== null && /[\n\r]/.test(file))
217
- problems.push({ reason: `${at}-file-multiline`, message: `${at}.file must be one line` });
218
- if (caption !== null && /[\n\r]/.test(caption))
219
- problems.push({ reason: `${at}-caption-multiline`, message: `${at}.caption must be one line` });
220
- const sha256 = entry["sha256"];
221
- if (sha256 !== undefined && (typeof sha256 !== "string" || !/^[0-9a-f]{64}$/.test(sha256)))
222
- problems.push({ reason: `${at}-sha256`, message: `${at}.sha256 must be a sha256 hex digest` });
223
- const artifact = entry["artifact"];
224
- if (artifact !== undefined && (typeof artifact !== "number" || !Number.isSafeInteger(artifact) || artifact <= 0))
225
- problems.push({ reason: `${at}-artifact`, message: `${at}.artifact must be an evidence id` });
226
- if (file === null || caption === null || url === null || /[\n\r]/.test(file) || /[\n\r]/.test(caption))
227
- return null;
228
- return { file, caption, url, ...(typeof sha256 === "string" ? { sha256 } : {}), ...(typeof artifact === "number" ? { artifact } : {}) };
229
- });
230
- const files = new Set();
231
- for (const one of images) {
232
- if (files.has(one.file))
233
- problems.push({ reason: "images-duplicate", message: `images names ${one.file} twice` });
234
- files.add(one.file);
235
- }
236
- const items = listOf(body["items"], "items", REPORT_LIMITS.items, problems, (entry, at) => {
237
- const itemTitle = prose(entry["title"], `${at}.title`, REPORT_LIMITS.itemTitle, problems);
238
- const why = prose(entry["why"], `${at}.why`, REPORT_LIMITS.itemWhy, problems);
239
- const url = link(entry["url"], `${at}.url`, problems);
240
- const image = entry["image"] === undefined || entry["image"] === null || entry["image"] === "" ? null : entry["image"];
241
- if (itemTitle !== null && /[\n\r]/.test(itemTitle))
242
- problems.push({ reason: `${at}-title-multiline`, message: `${at}.title must be one line` });
243
- if (image !== null && (typeof image !== "string" || !files.has(image)))
244
- problems.push({ reason: `${at}-image`, message: `${at}.image must name one of images by its file` });
245
- if (itemTitle === null || why === null || url === null || /[\n\r]/.test(itemTitle) || (image !== null && (typeof image !== "string" || !files.has(image))))
246
- return null;
247
- return { title: itemTitle, why, url, image: image };
248
- });
249
- if (problems.length > 0)
28
+ return refuse("not-json", `payload: not JSON: ${error instanceof Error ? error.message : String(error)}`);
29
+ }
30
+ const body = reportPayloadBody(parsed);
31
+ if (body === null)
32
+ return refuse("not-an-object", "payload: must be one JSON object");
33
+ const read = readReportPayload(parsed, { stored });
34
+ // Every report limit counts UTF-8 bytes, including those TEXT_LIMITS names without "Bytes" (a summary, a goal).
35
+ const issues = (read.ok ? [] : read.issues).map(issue => (issue.kind === "too-long" ? { ...issue, line: issue.line.replace(/ characters$/, " bytes") } : issue));
36
+ const contract = issues.map(contractProblemOf);
37
+ if (contract.some(problem => problem.reason === "newer-version" || problem.reason === "bad-version"))
38
+ return { ok: false, problems: contract };
39
+ const problems = [...contract, ...reportRuleProblems(body, issues, stored)];
40
+ if (!read.ok || problems.length > 0)
250
41
  return { ok: false, problems };
251
- return { ok: true, report: { title: title, summary: summary, report: document, followUps, items, images } };
42
+ const payload = read.value;
43
+ return {
44
+ ok: true,
45
+ report: {
46
+ title: payload.title,
47
+ summary: payload.summary,
48
+ report: payload.report,
49
+ followUps: (payload.followUps ?? []).map(one => ({ title: one.title, goal: one.goal })),
50
+ items: (payload.items ?? []).map(one => ({ title: one.title, why: one.why, url: one.url, image: one.image === undefined || one.image === null || one.image === "" ? null : one.image })),
51
+ images: (payload.images ?? []).map(one => ({
52
+ file: one.file,
53
+ caption: one.caption,
54
+ url: one.url,
55
+ ...(one.sha256 === undefined ? {} : { sha256: one.sha256 }),
56
+ ...(one.artifact === undefined ? {} : { artifact: one.artifact }),
57
+ })),
58
+ },
59
+ };
252
60
  }
253
- /** An optional capped list of objects, each read by `one`; problems are reported, never thrown. */
254
- function listOf(value, field, cap, problems, one) {
255
- if (value === undefined || value === null)
256
- return [];
257
- if (!Array.isArray(value)) {
258
- problems.push({ reason: `bad-${field}`, message: `${field} must be an array (got ${describe(value)})` });
259
- return [];
260
- }
261
- if (value.length > cap) {
262
- problems.push({ reason: `${field}-too-many`, message: `${field} lists ${value.length} — cap is ${cap}` });
263
- return [];
264
- }
265
- const kept = [];
266
- for (const [index, entry] of value.entries()) {
267
- const at = `${field}[${index}]`;
268
- if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
269
- problems.push({ reason: `${at}-shape`, message: `${at} must be an object` });
61
+ /**
62
+ * The rules JSON Schema cannot state, in plain code with path-named errors: blank text, byte caps (on write only),
63
+ * control characters, one-line fields, a one-paragraph summary, safe links, a screenshot named twice and an item
64
+ * naming one the report doesn't have. A field the contract already refused (or one inside it) is skipped, so every
65
+ * problem is reported once and all at once.
66
+ */
67
+ function reportRuleProblems(body, issues, stored) {
68
+ const problems = [];
69
+ // An unknown key is about its object, not the fields beside it.
70
+ const refused = issues.filter(issue => issue.kind !== "unknown-key").map(issue => issue.path);
71
+ const touched = (path) => refused.some(at => at === path || path.startsWith(`${at}.`) || path.startsWith(`${at}[`) || at.startsWith(`${path}.`) || at.startsWith(`${path}[`));
72
+ const say = (path, reason, what) => problems.push({ reason, message: `${path}: ${what}` });
73
+ /** One text field checked; true when it passed every rule. */
74
+ const text = (value, path, rule) => {
75
+ if (typeof value !== "string" || touched(path))
76
+ return false;
77
+ const limit = TEXT_LIMITS[rule.limit];
78
+ if (value.trim() === "")
79
+ say(path, `missing-${path}`, "must not be empty");
80
+ else if (!stored && Buffer.byteLength(value, "utf8") > limit)
81
+ say(path, `${path}-too-long`, `over ${limit.toLocaleString("en-US")} bytes`);
82
+ else if (hasForbiddenControls(value))
83
+ say(path, `${path}-controls`, "carries control characters that could become terminal escapes");
84
+ else if (rule.oneLine === true && /[\n\r]/.test(value))
85
+ say(path, `${path}-multiline`, "must be one line");
86
+ else if (rule.link === true && !isLink(value))
87
+ say(path, `${path}-not-a-link`, "must be an http or https address");
88
+ else
89
+ return true;
90
+ return false;
91
+ };
92
+ const entries = (list) => {
93
+ const value = body[list];
94
+ if (!Array.isArray(value) || touched(list))
95
+ return [];
96
+ return value.flatMap((entry, index) => typeof entry === "object" && entry !== null && !Array.isArray(entry) ? [[entry, `${list}[${index}]`]] : []);
97
+ };
98
+ const fields = (entry, at, rules) => {
99
+ for (const [field, rule] of Object.entries(rules))
100
+ text(entry[field], at === "" ? field : `${at}.${field}`, rule);
101
+ };
102
+ fields(body, "", REPORT_TEXT_FIELDS);
103
+ // One paragraph: the summary is what the operator reads first, on a phone, in a digest line — a blank line inside
104
+ // it is a second paragraph.
105
+ const summary = body["summary"];
106
+ if (typeof summary === "string" && !problems.some(problem => problem.message.startsWith("summary:")) && !touched("summary") && /\n[ \t]*\n/.test(summary)) {
107
+ say("summary", "summary-paragraphs", "one paragraph — no blank lines");
108
+ }
109
+ for (const [entry, at] of entries("followUps"))
110
+ fields(entry, at, FOLLOW_UP_TEXT_FIELDS);
111
+ const files = new Map();
112
+ for (const [entry, at] of entries("images")) {
113
+ fields(entry, at, IMAGE_TEXT_FIELDS);
114
+ const file = entry["file"];
115
+ if (typeof file !== "string" || touched(`${at}.file`))
270
116
  continue;
117
+ const first = files.get(file);
118
+ if (first !== undefined)
119
+ say(`${at}.file`, "images-duplicate", `names ${file} again (also ${first})`);
120
+ else
121
+ files.set(file, at);
122
+ }
123
+ // An item's picture is checked against the images as written: one naming a screenshot the report doesn't list is refused.
124
+ const imagesRefused = touched("images");
125
+ for (const [entry, at] of entries("items")) {
126
+ fields(entry, at, ITEM_TEXT_FIELDS);
127
+ const image = entry["image"];
128
+ if (typeof image === "string" && image !== "" && !touched(`${at}.image`) && !imagesRefused && !files.has(image)) {
129
+ say(`${at}.image`, `bad-${at}.image`, "must name one of images by its file");
271
130
  }
272
- const read = one(entry, at);
273
- if (read !== null)
274
- kept.push(read);
275
131
  }
276
- return kept;
132
+ return problems;
277
133
  }
278
134
  /** A cited web address: http or https, one line, no credentials. */
279
- function link(value, field, problems) {
280
- const text = prose(value, field, REPORT_LIMITS.url, problems);
281
- if (text === null)
282
- return null;
135
+ function isLink(text) {
283
136
  let url = null;
284
137
  try {
285
138
  url = new URL(text);
@@ -287,9 +140,5 @@ function link(value, field, problems) {
287
140
  catch {
288
141
  url = null;
289
142
  }
290
- if (url === null || (url.protocol !== "http:" && url.protocol !== "https:") || /\s/.test(text) || url.username !== "" || url.password !== "") {
291
- problems.push({ reason: `${field}-not-a-link`, message: `${field} must be an http or https address` });
292
- return null;
293
- }
294
- return text;
143
+ return url !== null && (url.protocol === "http:" || url.protocol === "https:") && !/\s/.test(text) && url.username === "" && url.password === "";
295
144
  }
package/dist/scout.js CHANGED
@@ -20,7 +20,8 @@ import { auditOf } from "./provider.js";
20
20
  import { currentClaim, heartbeat } from "./claim.js";
21
21
  import { heartbeat as runnerHeartbeat } from "./runner.js";
22
22
  import { parseDecision } from "./decision.js";
23
- import { parseReport, REPORT_IMAGE_FILE, REPORT_LIMITS, SCOUT_OUTPUT_JSON_SCHEMA } from "./scout-report.js";
23
+ import { scoutHandbackSchema } from "./contracts/scout-report.js";
24
+ import { parseReport, REPORT_IMAGE_FILE, REPORT_LIMITS, REPORT_VERSION, SCOUT_OUTPUT_JSON_SCHEMA } from "./scout-report.js";
24
25
  import { TOKEN_ENVS as TELEGRAM_TOKEN_ENVS } from "./telegram.js";
25
26
  import { evidenceRoot, mailboxName, quarantineMailboxes, readMailbox, reportFileName, SCREENSHOT_BYTE_CAP, storeEvidence, validateScreenshotBytes, writeEvidenceFile, } from "./evidence.js";
26
27
  import { MARKER as LEASE_MARKER } from "./worktree.js";
@@ -29,7 +30,7 @@ import { proveTreeUntouched, snapshotIgnored } from "./tree-proof.js";
29
30
  import { redactSecretLines, scanForSecrets } from "./evidence.js";
30
31
  import { CLAUDE_LIMITS } from "./scope.js";
31
32
  import { catalogTool, projectToolsOf, toolLaunchFor } from "./project-tools.js";
32
- import { researchToolsOf } from "./mcp-connect.js";
33
+ import { grantOf, researchToolsOf } from "./mcp-connect.js";
33
34
  import { startScoutProxy } from "./scout-net.js";
34
35
  import * as browserCheck from "./scout-browser.js";
35
36
  import { invokeAgent } from "./invoke.js";
@@ -160,6 +161,7 @@ function scoutBrief(title, goal, outOfScope, mailbox, reportFile, answers, struc
160
161
  `\`${reportFile}\`:`,
161
162
  ]),
162
163
  "{",
164
+ ` "version": ${REPORT_VERSION},`,
163
165
  ' "title": "one line",',
164
166
  ' "summary": "one paragraph the operator reads first",',
165
167
  ' "report": "the report as markdown: what you found, the evidence, the risks",',
@@ -183,7 +185,7 @@ function connectedResearch(store, taskRef, runId) {
183
185
  const repo = store.refById(taskRef)?.repo ?? null;
184
186
  if (repo === null)
185
187
  return NO_RESEARCH;
186
- return researchToolsOf(projectToolsOf(store, repo), new Set(toolLaunchFor(store, runId).tools.map(one => one.spec.name)));
188
+ return researchToolsOf(projectToolsOf(store, repo), new Set(toolLaunchFor(store, runId).tools.map(one => one.spec.name)), tool => grantOf(repo, tool));
187
189
  }
188
190
  catch {
189
191
  return NO_RESEARCH;
@@ -325,6 +327,7 @@ async function scoutWith(store, request, outputDir) {
325
327
  allowedTools: proxy === null ? [] : [...(browser === null ? SCOUT_RESEARCH_TOOLS : SCOUT_ALLOWED_TOOLS), ...research.allowed],
326
328
  ...(browser === null ? {} : { extraMcpServers: browser }),
327
329
  readOnlyTools: research.reads,
330
+ ...(research.withheld === undefined ? {} : { researchWithheld: research.withheld }),
328
331
  skipPermissions: false,
329
332
  resumeSession: null,
330
333
  ...(structured ? { jsonSchema: SCOUT_OUTPUT_JSON_SCHEMA } : {}),
@@ -484,7 +487,7 @@ async function scoutWith(store, request, outputDir) {
484
487
  ok: false,
485
488
  kind: "malformed",
486
489
  reason: "malformed-report",
487
- message: `the scout concluded, but the payload is not a report: ${parsed.problems.map(problem => problem.reason).join(", ")}`,
490
+ message: `the scout concluded, but the payload is not a report: ${parsed.problems.map(problem => problem.message).join("; ")}`,
488
491
  problems: parsed.problems,
489
492
  };
490
493
  }
@@ -544,7 +547,7 @@ async function scoutWith(store, request, outputDir) {
544
547
  // the deliverable, and a task whose deliverable does not exist is not
545
548
  // done.
546
549
  try {
547
- const content = Buffer.from(JSON.stringify(report, null, 2), "utf8");
550
+ const content = Buffer.from(JSON.stringify({ version: REPORT_VERSION, ...report }, null, 2), "utf8");
548
551
  const key = writeEvidenceFile(root, request.runId, "report.json", content);
549
552
  return {
550
553
  ok: true,
@@ -630,17 +633,18 @@ function structuredHandback(structuredOutput) {
630
633
  value = JSON.parse(structuredOutput);
631
634
  }
632
635
  catch {
633
- return { kind: "invalid", problems: [{ reason: "structured-not-json", message: "the structured output is not JSON" }] };
636
+ return { kind: "invalid", problems: [{ reason: "structured-not-json", message: "payload: the structured output is not JSON" }] };
634
637
  }
635
638
  const body = typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
636
- const kind = body["kind"];
637
- const inner = kind === "report" ? body["report"] : kind === "question" ? body["decision"] : undefined;
638
- if (kind !== "report" && kind !== "question") {
639
- return { kind: "invalid", problems: [{ reason: "structured-kind", message: 'the structured output must say kind "report" or "question"' }] };
639
+ const read = scoutHandbackSchema.shape.kind.safeParse(body["kind"]);
640
+ if (!read.success) {
641
+ return { kind: "invalid", problems: [{ reason: "structured-kind", message: `kind: must be ${scoutHandbackSchema.shape.kind.options.map(one => JSON.stringify(one)).join(" or ")}` }] };
640
642
  }
643
+ const kind = read.data;
644
+ const inner = kind === "report" ? body["report"] : body["decision"];
641
645
  if (inner === undefined || inner === null) {
642
646
  const field = kind === "report" ? "report" : "decision";
643
- return { kind: "invalid", problems: [{ reason: `structured-missing-${field}`, message: `kind "${kind}" needs its ${field}` }] };
647
+ return { kind: "invalid", problems: [{ reason: `structured-missing-${field}`, message: `${field}: required for kind "${kind}"` }] };
644
648
  }
645
649
  return { kind, raw: JSON.stringify(inner) };
646
650
  }