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/dist/proof.js CHANGED
@@ -19,7 +19,9 @@
19
19
  * so the review itself stays testable without a filesystem or a database.
20
20
  */
21
21
  import { hasForbiddenControls } from "./decision.js";
22
- import { EVIDENCE_KINDS } from "./scope.js";
22
+ import { contractProblemOf } from "./contracts/contract.js";
23
+ import { PROOF_LIMITS, proofPayloadBody, readProofPayload } from "./contracts/proof.js";
24
+ export { PROOF_LIMITS } from "./contracts/proof.js";
23
25
  /** Shared by preflight and final adjudication; presentation text is never a rubric. */
24
26
  function criterionContractProblems(approved, answer) {
25
27
  if (answer === undefined)
@@ -57,248 +59,96 @@ export function proofSubmissionProblems(proof, rubric) {
57
59
  problems.push(...failedMetChecks(proof).map(failedCheckWords));
58
60
  return problems;
59
61
  }
60
- /** Caps are BYTES of UTF-8, matching the scout report's rule: a payload is
61
- * the same size whatever script it is written in. */
62
- export const PROOF_LIMITS = {
63
- payload: 64 * 1024,
64
- criteria: 12,
65
- criterionId: 40,
66
- criterionStatement: 300,
67
- criterionHow: 500,
68
- evidencePerCriterion: 4,
69
- evidenceRef: 300,
70
- checks: 12,
71
- checkCommand: 300,
72
- checkSummary: 300,
73
- changed: 64,
74
- changedPath: 300,
75
- caveats: 8,
76
- caveat: 300,
77
- screenshots: 8,
78
- screenshotPath: 300,
79
- screenshotCaption: 300,
80
- };
81
62
  function refuse(reason, message) {
82
63
  return { ok: false, problems: [{ reason, message }] };
83
64
  }
84
- function describe(value) {
85
- if (value === undefined)
86
- return "nothing";
87
- if (value === null)
88
- return "null";
89
- if (typeof value === "string")
90
- return `a ${value.length}-char string`;
91
- return `a ${Array.isArray(value) ? "array" : typeof value}`;
92
- }
93
- function prose(value, field, cap, problems) {
94
- if (value === undefined || value === null || (typeof value === "string" && value.trim() === "")) {
95
- problems.push({ reason: `missing-${field}`, message: `${field} is required` });
96
- return null;
97
- }
98
- if (typeof value !== "string") {
99
- problems.push({ reason: `bad-${field}`, message: `${field} must be a string (got ${describe(value)})` });
100
- return null;
101
- }
102
- if (Buffer.byteLength(value, "utf8") > cap) {
103
- problems.push({ reason: `${field}-too-long`, message: `${field} is over ${cap} bytes` });
104
- return null;
105
- }
106
- if (hasForbiddenControls(value)) {
107
- problems.push({ reason: `${field}-controls`, message: `${field} carries control characters that could become terminal escapes` });
108
- return null;
109
- }
110
- return value;
111
- }
112
- /** A repository-relative path: no leading slash, no drive letter, no `.`/`..`
113
- * segment, no backslash, no control character. The same shape
114
- * `readVerifiedArtifact` enforces on an evidence key, applied here to a
115
- * path an agent claims rather than one the machine already wrote. */
116
- function relativePath(value, field, cap, problems) {
117
- const raw = prose(value, field, cap, problems);
118
- if (raw === null)
119
- return null;
120
- const segments = raw.split("/");
121
- const wellFormed = !raw.startsWith("/") &&
122
- segments.every(segment => segment.length > 0 && segment !== "." && segment !== ".." && !segment.includes("\\"));
123
- if (!wellFormed) {
124
- problems.push({ reason: `${field}-not-relative`, message: `${field} must be a normalized repository-relative path (got ${describe(value)})` });
125
- return null;
126
- }
127
- return raw;
128
- }
129
- /** `evidence` is optional on a criterion (absent → `[]`, exactly like every
130
- * other list here) — the mandatory PRESENCE of an answer for a SIGNED
131
- * criterion is `adjudicate`'s concern, not the parser's; this only proves
132
- * the shape of what is there. */
133
- function parseEvidenceRefs(value, field, problems) {
134
- if (value === undefined || value === null)
135
- return [];
136
- if (!Array.isArray(value)) {
137
- problems.push({ reason: `bad-${field}`, message: `${field} must be an array (got ${describe(value)})` });
138
- return null;
139
- }
140
- if (value.length > PROOF_LIMITS.evidencePerCriterion) {
141
- problems.push({ reason: `${field}-too-many`, message: `${field} lists ${value.length} — cap is ${PROOF_LIMITS.evidencePerCriterion}` });
142
- return null;
143
- }
144
- const refs = [];
145
- let bad = false;
146
- for (const [index, entry] of value.entries()) {
147
- if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
148
- problems.push({ reason: `${field}[${index}]-shape`, message: `${field}[${index}] must be an object` });
149
- bad = true;
150
- continue;
151
- }
152
- const one = entry;
153
- const kind = one["kind"];
154
- if (typeof kind !== "string" || !EVIDENCE_KINDS.includes(kind)) {
155
- problems.push({
156
- reason: `${field}[${index}]-bad-kind`,
157
- message: `${field}[${index}].kind must draw from ${EVIDENCE_KINDS.join(", ")} (got ${describe(kind)})`,
158
- });
159
- bad = true;
160
- continue;
161
- }
162
- const ref = prose(one["ref"], `${field}[${index}].ref`, PROOF_LIMITS.evidenceRef, problems);
163
- if (ref === null) {
164
- bad = true;
165
- continue;
166
- }
167
- refs.push({ kind: kind, ref });
168
- }
169
- return bad ? null : refs;
170
- }
171
- function parseCriteria(value, problems) {
172
- if (value === undefined || value === null)
173
- return [];
174
- if (!Array.isArray(value)) {
175
- problems.push({ reason: "bad-criteria", message: `criteria must be an array (got ${describe(value)})` });
176
- return [];
177
- }
178
- if (value.length > PROOF_LIMITS.criteria) {
179
- problems.push({ reason: "criteria-too-many", message: `criteria lists ${value.length} — cap is ${PROOF_LIMITS.criteria}` });
180
- return [];
181
- }
182
- const criteria = [];
183
- const seen = new Set();
184
- for (const [index, entry] of value.entries()) {
185
- if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
186
- problems.push({ reason: `criteria[${index}]-shape`, message: `criteria[${index}] must be an object` });
187
- continue;
188
- }
189
- const one = entry;
190
- const id = prose(one["id"], `criteria[${index}].id`, PROOF_LIMITS.criterionId, problems);
191
- if (id !== null && seen.has(id)) {
192
- problems.push({ reason: `criteria[${index}]-duplicate-id`, message: `criterion id "${id}" appears twice` });
193
- }
194
- else if (id !== null) {
195
- seen.add(id);
196
- }
197
- const statement = prose(one["statement"], `criteria[${index}].statement`, PROOF_LIMITS.criterionStatement, problems);
198
- const how = prose(one["how"], `criteria[${index}].how`, PROOF_LIMITS.criterionHow, problems);
199
- const verdict = one["verdict"];
200
- if (verdict !== "met" && verdict !== "not-met" && verdict !== "not-checked" && verdict !== "pending-verification") {
201
- problems.push({
202
- reason: `criteria[${index}]-bad-verdict`,
203
- message: `criteria[${index}].verdict must be "met", "not-met", "not-checked", or "pending-verification" (got ${describe(verdict)})`,
204
- });
205
- continue;
206
- }
207
- const evidence = parseEvidenceRefs(one["evidence"], `criteria[${index}].evidence`, problems);
208
- if (id === null || statement === null || how === null || evidence === null)
209
- continue;
210
- criteria.push({ id, statement, how, verdict, evidence });
211
- }
212
- return criteria;
213
- }
214
- function parseChecks(value, problems) {
215
- if (value === undefined || value === null)
216
- return [];
217
- if (!Array.isArray(value)) {
218
- problems.push({ reason: "bad-checks", message: `checks must be an array (got ${describe(value)})` });
219
- return [];
220
- }
221
- if (value.length > PROOF_LIMITS.checks) {
222
- problems.push({ reason: "checks-too-many", message: `checks lists ${value.length} — cap is ${PROOF_LIMITS.checks}` });
223
- return [];
224
- }
225
- const checks = [];
226
- for (const [index, entry] of value.entries()) {
227
- if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
228
- problems.push({ reason: `checks[${index}]-shape`, message: `checks[${index}] must be an object` });
229
- continue;
230
- }
231
- const one = entry;
232
- const command = prose(one["command"], `checks[${index}].command`, PROOF_LIMITS.checkCommand, problems);
233
- const summary = prose(one["summary"], `checks[${index}].summary`, PROOF_LIMITS.checkSummary, problems);
234
- const exitCode = one["exitCode"];
235
- if (typeof exitCode !== "number" || !Number.isInteger(exitCode) || exitCode < 0 || exitCode > 255) {
236
- problems.push({
237
- reason: `checks[${index}]-bad-exit-code`,
238
- message: `checks[${index}].exitCode must be an integer 0-255 (got ${describe(exitCode)})`,
65
+ /** Every text field of a proof body (version 2 shape) that the schema saw as a string. */
66
+ function textFields(body) {
67
+ const out = [];
68
+ const text = (path, value, bytes, relative) => {
69
+ if (typeof value === "string")
70
+ out.push({ path, value, bytes, ...(relative ? { relative } : {}) });
71
+ };
72
+ const records = (field) => {
73
+ const list = body[field];
74
+ return Array.isArray(list) ? list.flatMap((one, index) => (typeof one === "object" && one !== null && !Array.isArray(one) ? [[index, one]] : [])) : [];
75
+ };
76
+ for (const [index, one] of records("criteria")) {
77
+ text(`criteria[${index}].id`, one["id"], PROOF_LIMITS.criterionId);
78
+ text(`criteria[${index}].statement`, one["statement"], PROOF_LIMITS.criterionStatement);
79
+ text(`criteria[${index}].how`, one["how"], PROOF_LIMITS.criterionHow);
80
+ const evidence = one["evidence"];
81
+ if (Array.isArray(evidence)) {
82
+ evidence.forEach((ref, at) => {
83
+ if (typeof ref === "object" && ref !== null)
84
+ text(`criteria[${index}].evidence[${at}].ref`, ref["ref"], PROOF_LIMITS.evidenceRef);
239
85
  });
240
- continue;
241
86
  }
242
- if (command === null || summary === null)
243
- continue;
244
- checks.push({ command, summary, exitCode });
245
87
  }
246
- return checks;
247
- }
248
- function parseStringList(value, field, cap, itemCap, problems) {
249
- if (value === undefined || value === null)
250
- return [];
251
- if (!Array.isArray(value)) {
252
- problems.push({ reason: `bad-${field}`, message: `${field} must be an array (got ${describe(value)})` });
253
- return [];
88
+ for (const [index, one] of records("checks")) {
89
+ text(`checks[${index}].command`, one["command"], PROOF_LIMITS.checkCommand);
90
+ text(`checks[${index}].summary`, one["summary"], PROOF_LIMITS.checkSummary);
254
91
  }
255
- if (value.length > cap) {
256
- problems.push({ reason: `${field}-too-many`, message: `${field} lists ${value.length} — cap is ${cap}` });
257
- return [];
92
+ for (const field of ["changed", "caveats"]) {
93
+ const list = body[field];
94
+ if (Array.isArray(list))
95
+ list.forEach((one, index) => text(`${field}[${index}]`, one, field === "changed" ? PROOF_LIMITS.changedPath : PROOF_LIMITS.caveat));
258
96
  }
259
- const out = [];
260
- for (const [index, entry] of value.entries()) {
261
- const item = prose(entry, `${field}[${index}]`, itemCap, problems);
262
- if (item !== null)
263
- out.push(item);
97
+ for (const [index, one] of records("screenshots")) {
98
+ text(`screenshots[${index}].path`, one["path"], PROOF_LIMITS.screenshotPath, true);
99
+ text(`screenshots[${index}].caption`, one["caption"], PROOF_LIMITS.screenshotCaption);
264
100
  }
265
101
  return out;
266
102
  }
267
- function parseScreenshots(value, problems) {
268
- if (value === undefined || value === null)
269
- return [];
270
- if (!Array.isArray(value)) {
271
- problems.push({ reason: "bad-screenshots", message: `screenshots must be an array (got ${describe(value)})` });
272
- return [];
273
- }
274
- if (value.length > PROOF_LIMITS.screenshots) {
275
- problems.push({ reason: "screenshots-too-many", message: `screenshots lists ${value.length} — cap is ${PROOF_LIMITS.screenshots}` });
276
- return [];
277
- }
278
- const screenshots = [];
279
- const seen = new Set();
280
- for (const [index, entry] of value.entries()) {
281
- if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
282
- problems.push({ reason: `screenshots[${index}]-shape`, message: `screenshots[${index}] must be an object` });
283
- continue;
284
- }
285
- const one = entry;
286
- const path = relativePath(one["path"], `screenshots[${index}].path`, PROOF_LIMITS.screenshotPath, problems);
287
- const caption = prose(one["caption"], `screenshots[${index}].caption`, PROOF_LIMITS.screenshotCaption, problems);
288
- if (path !== null && seen.has(path)) {
289
- problems.push({ reason: `screenshots[${index}]-duplicate-path`, message: `screenshot path "${path}" appears twice` });
290
- continue;
291
- }
292
- if (path === null || caption === null)
103
+ /**
104
+ * The rules JSON Schema cannot state, in plain code with path-named errors: blank text, UTF-8 byte limits, control
105
+ * characters, normalized repository-relative screenshot paths (no leading slash, `.`/`..` segment or backslash — the
106
+ * shape `readVerifiedArtifact` enforces on an evidence key), and unique criterion ids and screenshot paths. A field
107
+ * the schema already refused is not reported twice.
108
+ */
109
+ function proofRuleProblems(body, contract) {
110
+ const named = (path) => contract.some(problem => problem.message.startsWith(`${path}:`));
111
+ const problems = [];
112
+ for (const field of textFields(body)) {
113
+ if (named(field.path))
293
114
  continue;
294
- seen.add(path);
295
- screenshots.push({ path, caption });
296
- }
297
- return screenshots;
115
+ const { path, value } = field;
116
+ if (value.trim() === "")
117
+ problems.push({ reason: `missing-${path}`, message: `${path}: must not be blank` });
118
+ else if (Buffer.byteLength(value, "utf8") > field.bytes)
119
+ problems.push({ reason: `${path}-too-long`, message: `${path}: over ${field.bytes.toLocaleString("en-US")} bytes` });
120
+ else if (hasForbiddenControls(value))
121
+ problems.push({ reason: `${path}-controls`, message: `${path}: carries control characters that could become terminal escapes` });
122
+ else if (field.relative && (value.startsWith("/") || !value.split("/").every(segment => segment.length > 0 && segment !== "." && segment !== ".." && !segment.includes("\\")))) {
123
+ problems.push({ reason: `${path}-not-relative`, message: `${path}: must be a normalized repository-relative path` });
124
+ }
125
+ }
126
+ const unique = (field, key, what) => {
127
+ const list = body[field];
128
+ if (!Array.isArray(list) || named(field))
129
+ return;
130
+ const seen = new Set();
131
+ list.forEach((one, index) => {
132
+ const value = typeof one === "object" && one !== null ? one[key] : undefined;
133
+ if (typeof value !== "string")
134
+ return;
135
+ if (seen.has(value))
136
+ problems.push({ reason: `${field}[${index}]-duplicate-${key}`, message: `${field}[${index}].${key}: ${what} ${JSON.stringify(value)} appears twice` });
137
+ seen.add(value);
138
+ });
139
+ };
140
+ unique("criteria", "id", "criterion id");
141
+ unique("screenshots", "path", "screenshot path");
142
+ return problems;
298
143
  }
144
+ /**
145
+ * Read a proof: the payload byte cap and JSON first, then the proof schema (version 2 as itself, the version 1 a
146
+ * builder writes upgraded, a newer one refused plainly), then the plain-code rules. Every problem names its path and
147
+ * is reported at once; fail closed.
148
+ */
299
149
  export function parseProof(raw) {
300
150
  if (Buffer.byteLength(raw, "utf8") > PROOF_LIMITS.payload) {
301
- return refuse("too-large", `the payload is over ${PROOF_LIMITS.payload} bytes`);
151
+ return refuse("too-large", `payload: over ${PROOF_LIMITS.payload.toLocaleString("en-US")} bytes`);
302
152
  }
303
153
  let parsed;
304
154
  try {
@@ -307,29 +157,20 @@ export function parseProof(raw) {
307
157
  catch (error) {
308
158
  return refuse("not-json", `the payload is not JSON: ${error instanceof Error ? error.message : String(error)}`);
309
159
  }
310
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
311
- return refuse("not-an-object", "the payload must be one JSON object");
312
- }
313
- const body = parsed;
314
- const problems = [];
315
- if (body["version"] !== 1) {
316
- problems.push({ reason: "bad-version", message: `version must be 1 (got ${describe(body["version"])})` });
317
- }
318
- const criteria = parseCriteria(body["criteria"], problems);
319
- const checks = parseChecks(body["checks"], problems);
320
- const changed = parseStringList(body["changed"], "changed", PROOF_LIMITS.changed, PROOF_LIMITS.changedPath, problems);
321
- const caveats = parseStringList(body["caveats"], "caveats", PROOF_LIMITS.caveats, PROOF_LIMITS.caveat, problems);
322
- const screenshots = parseScreenshots(body["screenshots"], problems);
323
- if (problems.length > 0)
160
+ const body = proofPayloadBody(parsed);
161
+ if (body === null)
162
+ return refuse("not-an-object", "payload: must be one JSON object");
163
+ const read = readProofPayload(parsed);
164
+ const contract = read.ok ? [] : read.issues.map(contractProblemOf);
165
+ if (contract.some(problem => problem.reason === "newer-version" || problem.reason === "bad-version"))
166
+ return { ok: false, problems: contract };
167
+ const problems = [...contract, ...proofRuleProblems(body, contract)];
168
+ if (!read.ok || problems.length > 0)
324
169
  return { ok: false, problems };
325
- return {
326
- ok: true,
327
- proof: { version: 1, criteria, checks, changed, caveats, screenshots },
328
- };
170
+ return { ok: true, proof: read.value };
329
171
  }
330
- /** Re-serialize the validated shape, never the agent's raw bytes (the
331
- * scout report's rule, `scout.ts`): what is stored and later hash-verified
332
- * is exactly what this parser admitted, key order and all. */
172
+ /** Serialize the validated shape, never the agent's raw bytes (the scout report's rule, `scout.ts`): what is stored
173
+ * and later hash-verified is exactly what the schema admitted, in its key order. */
333
174
  export function serializeProof(proof) {
334
175
  return JSON.stringify(proof, null, 2);
335
176
  }
@@ -0,0 +1,42 @@
1
+ /** Shared verified readers for result screens and the compact CLI review. */
2
+ import type { Artifact } from "./store.js";
3
+ export declare const SCREENSHOT_CAPTURE: RegExp;
4
+ /** What the run page shows of the terminal diff — verified bytes or a named problem, never silence. */
5
+ export type TerminalDiffView = {
6
+ patch: {
7
+ text: string;
8
+ truncated: boolean;
9
+ artifactId: number;
10
+ } | {
11
+ problem: string;
12
+ } | null;
13
+ stat: {
14
+ base: string;
15
+ head: string;
16
+ fileCount: number;
17
+ additions: number;
18
+ deletions: number;
19
+ binaryCount: number;
20
+ filesTruncated: boolean;
21
+ files: {
22
+ path: string;
23
+ additions: number | null;
24
+ deletions: number | null;
25
+ renamedFrom?: string;
26
+ }[];
27
+ } | {
28
+ problem: string;
29
+ } | null;
30
+ };
31
+ /** A compact, typed view of the agent-authored handoff. Older v1
32
+ * artifacts simply have no lists, while v2 can present the useful answer
33
+ * before the raw diff and evidence below it. */
34
+ export type StructuredHandoffView = {
35
+ conclusion: string;
36
+ changes: string[];
37
+ verification: string[];
38
+ followUps: string[];
39
+ };
40
+ export declare function structuredHandoffView(artifacts: Artifact[], root: string, displayText?: (text: string) => string): StructuredHandoffView | null;
41
+ /** A failed capture or unverifiable bytes keep their problem; absence returns null. */
42
+ export declare function terminalDiffView(artifacts: Artifact[], root: string): TerminalDiffView | null;
@@ -0,0 +1,91 @@
1
+ import { readVerifiedArtifact } from "./evidence.js";
2
+ import { hasForbiddenControls } from "./decision.js";
3
+ import { parseHandoffArtifact } from "./contracts/handoff.js";
4
+ const CAPTURE_EXIT = /\(exit ([0-9]{1,4})\)\s*$/;
5
+ export const SCREENSHOT_CAPTURE = /^agent-claimed screenshot at (.+) \(validated (?:png|jpeg)\)/;
6
+ const oneLineOf = (text, cap) => {
7
+ const flat = text.replace(/\s+/g, " ").trim();
8
+ return flat.length <= cap ? flat : `${flat.slice(0, cap - 1)}…`;
9
+ };
10
+ export function structuredHandoffView(artifacts, root, displayText = text => text) {
11
+ const artifact = [...artifacts].reverse().find(one => one.kind === "handoff");
12
+ if (artifact === undefined)
13
+ return null;
14
+ const read = readVerifiedArtifact(root, artifact);
15
+ if (!read.ok)
16
+ return null;
17
+ const parsed = parseHandoffArtifact(read.content.toString("utf8"));
18
+ if (!parsed.ok)
19
+ return null;
20
+ const handoff = parsed.value;
21
+ const conclusion = oneLineOf(displayText(handoff.conclusion), 600);
22
+ if (conclusion === "" || hasForbiddenControls(conclusion))
23
+ return null;
24
+ const list = (items) => (items ?? [])
25
+ .filter(one => one.trim() !== "" && !hasForbiddenControls(one))
26
+ .slice(0, 8)
27
+ .map(one => oneLineOf(one, 240));
28
+ return { conclusion, changes: list(handoff.changes), verification: list(handoff.verification), followUps: list(handoff.followUps) };
29
+ }
30
+ /** A failed capture or unverifiable bytes keep their problem; absence returns null. */
31
+ export function terminalDiffView(artifacts, root) {
32
+ const patchArtifact = artifacts.find(one => one.kind === "terminal-diff");
33
+ const statArtifact = artifacts.find(one => one.kind === "diff-stat");
34
+ if (patchArtifact === undefined && statArtifact === undefined)
35
+ return null;
36
+ const view = { patch: null, stat: null };
37
+ if (patchArtifact !== undefined) {
38
+ const exit = CAPTURE_EXIT.exec(patchArtifact.capture);
39
+ if (exit !== null && exit[1] !== "0") {
40
+ view.patch = { problem: `capture failed — ${patchArtifact.capture}` };
41
+ }
42
+ else {
43
+ const read = readVerifiedArtifact(root, patchArtifact);
44
+ view.patch = read.ok
45
+ ? { text: read.content.toString("utf8"), truncated: patchArtifact.truncated, artifactId: patchArtifact.id }
46
+ : { problem: `stored but unverifiable — ${read.problem}` };
47
+ }
48
+ }
49
+ if (statArtifact !== undefined) {
50
+ const exit = CAPTURE_EXIT.exec(statArtifact.capture);
51
+ if (exit !== null && exit[1] !== "0") {
52
+ view.stat = { problem: `capture failed — ${statArtifact.capture}` };
53
+ }
54
+ else {
55
+ const read = readVerifiedArtifact(root, statArtifact);
56
+ if (!read.ok) {
57
+ view.stat = { problem: `stored but unverifiable — ${read.problem}` };
58
+ }
59
+ else {
60
+ try {
61
+ const parsed = JSON.parse(read.content.toString("utf8"));
62
+ // Every field this page renders is type-proved (arc 6, finding 7):
63
+ // "an object with a base key" was accepting any shape at all.
64
+ const count = (value) => typeof value === "number" && Number.isFinite(value);
65
+ const wellFormed = parsed !== null &&
66
+ typeof parsed === "object" &&
67
+ typeof parsed["base"] === "string" &&
68
+ typeof parsed["head"] === "string" &&
69
+ count(parsed["fileCount"]) &&
70
+ count(parsed["additions"]) &&
71
+ count(parsed["deletions"]) &&
72
+ count(parsed["binaryCount"]) &&
73
+ typeof parsed["filesTruncated"] === "boolean" &&
74
+ Array.isArray(parsed["files"]) &&
75
+ parsed["files"].every(one => one !== null &&
76
+ typeof one === "object" &&
77
+ typeof one["path"] === "string" &&
78
+ (count(one["additions"]) || one["additions"] === null) &&
79
+ (count(one["deletions"]) || one["deletions"] === null));
80
+ view.stat = wellFormed
81
+ ? parsed
82
+ : { problem: "stat is not the shape this page knows" };
83
+ }
84
+ catch {
85
+ view.stat = { problem: "stat did not parse as JSON" };
86
+ }
87
+ }
88
+ }
89
+ }
90
+ return view;
91
+ }