toolroll 0.9.84 → 0.9.85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +94 -1193
  3. package/dist/agent-fence.d.ts +3 -3
  4. package/dist/agent-fence.js +3 -3
  5. package/dist/agent-onboard.d.ts +2 -0
  6. package/dist/agent-onboard.js +3 -0
  7. package/dist/browser/workspace.css +1 -1
  8. package/dist/browser/workspace.js +31 -31
  9. package/dist/browser-workspace.d.ts +5 -0
  10. package/dist/chat-channel.d.ts +10 -1
  11. package/dist/chat-channel.js +35 -5
  12. package/dist/chat-decide.d.ts +16 -4
  13. package/dist/chat-decide.js +239 -31
  14. package/dist/chat-messages.d.ts +5 -0
  15. package/dist/chat-messages.js +1 -1
  16. package/dist/chat-quiet.js +3 -2
  17. package/dist/chat-review.d.ts +4 -0
  18. package/dist/chat-review.js +13 -4
  19. package/dist/chat-terms.d.ts +9 -0
  20. package/dist/chat-terms.js +46 -0
  21. package/dist/contracts/console-api.d.ts +2 -1
  22. package/dist/contracts/console-api.js +2 -1
  23. package/dist/contracts/lead-tools.d.ts +30 -0
  24. package/dist/contracts/lead-tools.js +3 -0
  25. package/dist/contracts/youiyoux-request.d.ts +248 -0
  26. package/dist/contracts/youiyoux-request.js +152 -0
  27. package/dist/flow-cadence.d.ts +8 -2
  28. package/dist/flow-cadence.js +16 -3
  29. package/dist/flow-card-task.d.ts +9 -0
  30. package/dist/flow-card-task.js +15 -6
  31. package/dist/integrations-ui.js +9 -4
  32. package/dist/integrations.d.ts +9 -1
  33. package/dist/integrations.js +43 -2
  34. package/dist/landed-cleanup.d.ts +4 -0
  35. package/dist/landed-cleanup.js +3 -2
  36. package/dist/lead-capabilities.js +2 -1
  37. package/dist/lead-cli.js +3 -1
  38. package/dist/lead-tools.d.ts +2 -0
  39. package/dist/lead-tools.js +30 -4
  40. package/dist/lead.d.ts +2 -0
  41. package/dist/lead.js +3 -3
  42. package/dist/operate.d.ts +1 -2
  43. package/dist/operate.js +11 -15
  44. package/dist/project-activity.d.ts +20 -0
  45. package/dist/project-activity.js +23 -0
  46. package/dist/project-cli.d.ts +1 -0
  47. package/dist/project-cli.js +36 -19
  48. package/dist/project-knowledge.d.ts +3 -3
  49. package/dist/project-outcomes.d.ts +119 -0
  50. package/dist/project-outcomes.js +79 -0
  51. package/dist/result-actions.d.ts +4 -0
  52. package/dist/result-actions.js +41 -9
  53. package/dist/result-review.d.ts +9 -0
  54. package/dist/result-review.js +45 -5
  55. package/dist/server/console.css +12 -0
  56. package/dist/server/pages.js +11 -1
  57. package/dist/server/render-chat.js +3 -2
  58. package/dist/server/render-pages.d.ts +2 -2
  59. package/dist/server/render-pages.js +7 -2
  60. package/dist/server/render-results.d.ts +2 -0
  61. package/dist/server/render-results.js +20 -7
  62. package/dist/server/render-tasks.d.ts +1 -0
  63. package/dist/server/render-tasks.js +15 -5
  64. package/dist/server/route-table.js +1 -0
  65. package/dist/server/settings.js +18 -1
  66. package/dist/server/task-views.js +6 -0
  67. package/dist/server/tasks.js +42 -6
  68. package/dist/store.d.ts +46 -3
  69. package/dist/store.js +165 -6
  70. package/dist/task-source.d.ts +13 -1
  71. package/dist/task-source.js +17 -1
  72. package/dist/telegram-lead.js +7 -5
  73. package/dist/telegram.js +1 -1
  74. package/dist/toolroll-update.js +2 -2
  75. package/dist/webhooks.d.ts +6 -0
  76. package/dist/webhooks.js +25 -0
  77. package/dist/work-index.d.ts +4 -0
  78. package/dist/work-index.js +32 -0
  79. package/dist/youiyoux-intake.d.ts +234 -0
  80. package/dist/youiyoux-intake.js +908 -0
  81. package/package.json +11 -8
@@ -0,0 +1,908 @@
1
+ /**
2
+ * youiyoux change and options requests become tasks (docs/guide/getting-started.md, "Change requests from youiyoux").
3
+ *
4
+ * youiyoux is a design-system tool: in its hub a person points at a component on a screenshot of their app and asks
5
+ * for a change, and it saves the request as `<project>/.youi/requests/<id>.json` (`youi.request/v1`). A project that
6
+ * uses youiyoux (a `youi.project.json` at its root, or a `.youi/requests` folder) has this intake on with no step of
7
+ * its own, unless a person turned it off in Settings → Integrations.
8
+ *
9
+ * On the worker's flow pass this reads the project's main checkout — the path from Toolroll's own records, resolved
10
+ * with realpath — and files each open change request with a task exactly once, keyed by the project and the request
11
+ * id in `youiyoux_request`: never again, whatever the file says later. Everything in the folder is outside text,
12
+ * written by whoever can write the project's folder:
13
+ *
14
+ * - the folders and files must be real (never a link), a file a single link to itself, at most 64 KB, read without
15
+ * following a link or waiting on a pipe, and at most 200 are read in one pass;
16
+ * - a file whose name, schema or id is wrong is counted (the integration says so) and never filed;
17
+ * - text that looks like a key or password is left out (recorded once, never filed), as flow triggers do;
18
+ * - a path the task may touch is kept only when youiyoux knew them all and every one is a plain relative path;
19
+ * - the task is filed through the one filing door (proposal.ts) by the "youiyoux" automation, unplanned, and waits
20
+ * for a person: its provenance (`filed_via = 'youiyoux'`) keeps every seal no person looked at off it (store.ts,
21
+ * the outside-request quarantine), so no operating mode, grant, schedule or automatic seal approves it. A person
22
+ * approves it in Toolroll, or with their own two taps in their paired chat where they turned approving from chat
23
+ * on (chat-decide.ts), bound to the exact scope the card showed.
24
+ *
25
+ * Beside each request it writes `<id>.task.json` (`youi.request-task/v1`): the task and its status, and, when the
26
+ * console has an address (the one the installation names, else this machine's own console where `up` serves it, on
27
+ * loopback), the task's link and `home`, where a person approves all waiting work. It is written when filed, whenever
28
+ * the status changes, and once more when that address changes. Beside an options request's options file (below), it is
29
+ * the only file it writes in the checkout: written whole (a temporary file in the same folder, then renamed over),
30
+ * refused when the folder or the target is a link, never the request itself. The status is also kept in the store;
31
+ * nothing Toolroll decides reads the file back.
32
+ *
33
+ * Status words (task-status.ts headline → youiyoux): Queued → queued; Planning, Building and Waiting (work going on
34
+ * by itself) → building; Needs you → needs-you; Ready for review → ready; Complete → done; Failed → failed;
35
+ * Stopped → stopped.
36
+ *
37
+ * An options request (`kind: "options"`) asks an agent for two to four options for one component, which youiyoux shows
38
+ * on its Screens canvas. It is filed the same way, once, waiting for a person, and only as youiyoux's contract has it:
39
+ * its offer names `p-<its own hex>`, that proposal's file `.youi/proposals/p-<hex>.json`, and the task may touch that
40
+ * one file (anything else is counted as a file that couldn't be read, and never filed). The proposal it names is kept
41
+ * when filed; the request is never read for it again. Then (runYouiyoux, the worker's pass):
42
+ *
43
+ * - When the task is Ready for review (or Complete), its result is read: the file at the result's commit, the commit
44
+ * Toolroll recorded for the build (never the checkout the agent had, which it controls and the pool hands on), read
45
+ * with the hardened git (teardown-git.ts). It must be a plain file in that commit (never a link), at most 64 KB, a
46
+ * `youi.proposal/v1` file whose id is the proposal, which answers this request, still open (an agent proposes; only a
47
+ * person decides), by an agent, with no key-like text. Then it is copied, byte for byte, into the project's main
48
+ * checkout as `.youi/proposals/<id>.json` the way the status file is written (folders real, never through a link;
49
+ * a temporary file, then linked into place, which never replaces a file already there: a person may have decided on
50
+ * it). A file missing from the result, or one that isn't safe, is said in the status file and never copied; a newer
51
+ * result is read again.
52
+ * - Only a file Toolroll copied is read for a decision: one already at that name is left as it was (a person may
53
+ * have decided on it) and never read for one, unless it is byte for byte the result's (an earlier pass's copy).
54
+ * - When that copy says a person chose an option or dismissed them all (youiyoux's hub writes that), and it is still
55
+ * the proposal copied (only its status, choice, decidedBy, decidedAt, request and updatedAt may differ), decided by
56
+ * a person, at a time, the task is marked complete by Toolroll, as landed work is (result-completion.ts LANDED_ACTOR), without
57
+ * merging or publishing its branch: its deliverable was the options, and youiyoux has used them. Only the exact
58
+ * result whose file was copied, and only through every guard a person's completion passes (landed-cleanup.ts
59
+ * completionProblem): otherwise it stays Ready for review and the ledger says why. The ledger keeps who decided
60
+ * what, as youiyoux wrote it (outside text, cleaned). A decision never approves, publishes or merges anything.
61
+ */
62
+ import { createHash, randomBytes } from "node:crypto";
63
+ import { closeSync, constants, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, realpathSync, renameSync, unlinkSync, writeSync } from "node:fs";
64
+ import { join } from "node:path";
65
+ import { fileTaskProposal } from "./proposal.js";
66
+ import { canonicalProject } from "./project.js";
67
+ import { scanForSecrets } from "./evidence.js";
68
+ import { hasDisguisedText } from "./decision.js";
69
+ import { storedTaskStatus } from "./flow-card-task.js";
70
+ import { loadConsoleUrl, localConsoleUrl } from "./webhooks.js";
71
+ import { assignmentOf } from "./assignment.js";
72
+ import { canonicalJson } from "./canonical-json.js";
73
+ import { noteAssignmentStatus } from "./assignment-status.js";
74
+ import { completionProblem } from "./landed-cleanup.js";
75
+ import { COMPLETION_ACTION, LANDED_ACTOR } from "./result-completion.js";
76
+ import { optionsProblem, PROPOSAL_ID, proposalFileOf, proposalIdOf, readYouiyouxProposal, readYouiyouxRequest, readYouiyouxRequestTask, YOUIYOUX_CARD_KIND, YOUIYOUX_FILED_VIA, YOUIYOUX_HOME_PATH } from "./contracts/youiyoux-request.js";
77
+ export { YOUIYOUX_FILED_VIA };
78
+ /** Who files the tasks: an automation, like a flow trigger. */
79
+ export const YOUIYOUX_ACTOR = "youiyoux";
80
+ export const REQUEST_BYTES = 64 * 1024;
81
+ export const REQUESTS_PER_PASS = 200;
82
+ export const FILED_PER_PASS = 10;
83
+ const STATUS_PER_PASS = 50;
84
+ /** Options requests whose result is read, or whose copy in the hub is read for a decision, in one pass (each). */
85
+ const OPTIONS_PER_PASS = 20;
86
+ /** The ledger's word for a decision youiyoux's hub made on a task's options. */
87
+ export const OPTIONS_DECIDED_ACTION = "youiyoux options decided";
88
+ export const OPTIONS_COPIED_ACTION = "youiyoux options copied";
89
+ /** The flow pass runs every 15 seconds; the folder is read at most this often. */
90
+ export const INTAKE_EVERY_MS = 30_000;
91
+ const REQUEST_FILE = /^(r-[0-9a-f]{10})\.json$/;
92
+ export const STATUS_OF_HEADLINE = {
93
+ Queued: "queued", Planning: "building", "Needs you": "needs-you", Waiting: "building", Building: "building",
94
+ "Ready for review": "ready", Complete: "done", Failed: "failed", Stopped: "stopped",
95
+ };
96
+ const plural = (count, one) => `${count} ${one}${count === 1 ? "" : "s"}`;
97
+ const isMissing = (error) => error?.code === "ENOENT";
98
+ // ------------------------------------------------------------ the project
99
+ /** Whether a project uses youiyoux: a `youi.project.json` at its root, or a `.youi` folder. Only their presence is
100
+ * read here (a link counts, so the integration can say it is Broken); requestsFolder decides what may be read. */
101
+ export function usesYouiyoux(repo) {
102
+ const there = (path) => { try {
103
+ lstatSync(path);
104
+ return true;
105
+ }
106
+ catch {
107
+ return false;
108
+ } };
109
+ return there(join(repo, "youi.project.json")) || there(join(repo, ".youi"));
110
+ }
111
+ /** `.youi/<part>` of a recorded project, checked now: null when there is none. Links are refused, never followed.
112
+ * `create`: a missing `<part>` (never `.youi` itself) is made, as a real folder, and checked again. */
113
+ function youiFolder(repo, part, create = false) {
114
+ const what = part === "requests" ? "its requests aren't read" : "nothing is written in it";
115
+ let root;
116
+ try {
117
+ root = realpathSync(repo);
118
+ }
119
+ catch {
120
+ return { ok: false, problem: "The project folder can't be read." };
121
+ }
122
+ if (root !== repo)
123
+ return { ok: false, problem: `The project folder moved or is a link, so ${what}.` };
124
+ let dir = root;
125
+ for (const name of [".youi", part]) {
126
+ dir = join(dir, name);
127
+ let stats;
128
+ try {
129
+ stats = lstatSync(dir);
130
+ }
131
+ catch (error) {
132
+ if (!isMissing(error))
133
+ return { ok: false, problem: `${name} can't be read.` };
134
+ if (!create || name === ".youi")
135
+ return null;
136
+ try {
137
+ mkdirSync(dir, 0o755);
138
+ stats = lstatSync(dir);
139
+ }
140
+ catch {
141
+ return { ok: false, problem: `.youi/${part} couldn't be made.` };
142
+ }
143
+ }
144
+ if (stats.isSymbolicLink())
145
+ return { ok: false, problem: `${dir.slice(root.length + 1)} is a link, so ${what}. Make it a real folder.` };
146
+ if (!stats.isDirectory())
147
+ return { ok: false, problem: `${dir.slice(root.length + 1)} isn't a folder.` };
148
+ }
149
+ try {
150
+ if (realpathSync(dir) !== dir)
151
+ return { ok: false, problem: `.youi/${part} resolves outside the project.` };
152
+ }
153
+ catch {
154
+ return { ok: false, problem: `.youi/${part} can't be read.` };
155
+ }
156
+ return { ok: true, dir };
157
+ }
158
+ const requestsFolder = (repo) => youiFolder(repo, "requests");
159
+ const NOFOLLOW = constants.O_NOFOLLOW ?? 0;
160
+ const NONBLOCK = constants.O_NONBLOCK ?? 0;
161
+ /** One request file's text: a real file with one link, at most REQUEST_BYTES, read without following a link. */
162
+ function readRequestFile(dir, name) {
163
+ const path = join(dir, name);
164
+ let stats;
165
+ try {
166
+ stats = lstatSync(path);
167
+ }
168
+ catch {
169
+ return { ok: false, why: "it can't be read" };
170
+ }
171
+ if (stats.isSymbolicLink() || !stats.isFile())
172
+ return { ok: false, why: "it isn't a plain file" };
173
+ let fd;
174
+ try {
175
+ fd = openSync(path, constants.O_RDONLY | NOFOLLOW | NONBLOCK);
176
+ }
177
+ catch {
178
+ return { ok: false, why: "it can't be read" };
179
+ }
180
+ try {
181
+ const opened = fstatSync(fd);
182
+ if (!opened.isFile() || opened.nlink !== 1 || opened.ino !== stats.ino || opened.dev !== stats.dev)
183
+ return { ok: false, why: "it isn't a plain file" };
184
+ if (opened.size > REQUEST_BYTES)
185
+ return { ok: false, why: "it is over 64 KB" };
186
+ const buffer = Buffer.alloc(REQUEST_BYTES + 1);
187
+ let length = 0;
188
+ for (let read = -1; read !== 0 && length < buffer.length;) {
189
+ read = readSync(fd, buffer, length, buffer.length - length, null);
190
+ length += read;
191
+ }
192
+ if (length > REQUEST_BYTES)
193
+ return { ok: false, why: "it is over 64 KB" };
194
+ return { ok: true, text: buffer.subarray(0, length).toString("utf8") };
195
+ }
196
+ catch {
197
+ return { ok: false, why: "it can't be read" };
198
+ }
199
+ finally {
200
+ closeSync(fd);
201
+ }
202
+ }
203
+ /** The open change and options requests in the folder not yet taken, and the files that couldn't be read as requests
204
+ * (an options request that doesn't hold to youiyoux's contract among them). */
205
+ function readRequests(dir, taken) {
206
+ const names = readdirSync(dir).filter(name => REQUEST_FILE.test(name) && !taken.has(name.slice(0, 12))).sort();
207
+ const out = { requests: [], invalid: [], more: names.length > REQUESTS_PER_PASS };
208
+ for (const name of names.slice(0, REQUESTS_PER_PASS)) {
209
+ const id = name.slice(0, 12);
210
+ const file = readRequestFile(dir, name);
211
+ if (!file.ok) {
212
+ out.invalid.push(`${name}: ${file.why}`);
213
+ continue;
214
+ }
215
+ let raw;
216
+ try {
217
+ raw = JSON.parse(file.text);
218
+ }
219
+ catch {
220
+ out.invalid.push(`${name}: it isn't JSON`);
221
+ continue;
222
+ }
223
+ const parsed = readYouiyouxRequest(raw);
224
+ if (!parsed.ok) {
225
+ out.invalid.push(`${name}: it isn't a youi.request/v1 request (${parsed.issues[0]?.line ?? "unreadable"})`);
226
+ continue;
227
+ }
228
+ if (parsed.value.id !== id) {
229
+ out.invalid.push(`${name}: its id is ${parsed.value.id}`);
230
+ continue;
231
+ }
232
+ // Comments, requests already addressed or closed, and changes with no task are someone else's to act on.
233
+ if (parsed.value.kind === "comment" || parsed.value.status !== "open")
234
+ continue;
235
+ const options = optionsProblem(parsed.value);
236
+ if (options !== null) {
237
+ out.invalid.push(`${name}: ${options}`);
238
+ continue;
239
+ }
240
+ if (parsed.value.task === undefined)
241
+ continue;
242
+ out.requests.push(parsed.value);
243
+ }
244
+ return out;
245
+ }
246
+ // ------------------------------------------------------------ the task
247
+ /** One line of outside text: control and hidden characters out, runs of space as one, at most `max` characters. */
248
+ function line(text, max) {
249
+ const clean = text.replace(/[\p{Cc}\p{Cf}\u2028\u2029]+/gu, " ").replace(/\s+/g, " ").trim();
250
+ return clean.length > max ? `${clean.slice(0, max - 1).trimEnd()}…` : clean;
251
+ }
252
+ /** Outside paragraphs: control characters out (newlines and tabs kept), hidden characters out. */
253
+ function paragraphs(text) {
254
+ return text.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\p{Cf}\u2028\u2029]/gu, "").trim();
255
+ }
256
+ /** The paths a request may touch: every one a plain relative path inside the project, or none at all. */
257
+ export function safeTouches(touches) {
258
+ const ok = (path) => path !== "" && path.length <= 200 && !path.includes("\u0000") && !/[\p{Cc}\p{Cf}]/u.test(path)
259
+ && !path.startsWith("/") && !path.startsWith("\\") && !/^[A-Za-z]:/.test(path) && !path.startsWith("~")
260
+ && !path.split(/[\\/]/).some(part => part === ".." || part === "") && path.trim() === path;
261
+ return touches.every(ok) ? [...new Set(touches)] : null;
262
+ }
263
+ /** A criterion statement in a rubric's own terms: one line, and no `|` (the rubric's field separator). */
264
+ function statement(text) {
265
+ return line(text.replace(/\|/g, "/"), 300);
266
+ }
267
+ /** The task a request asks for, in Toolroll's terms. Text is cleaned here; whether it may be filed is decided by the caller. */
268
+ export function requestTask(request) {
269
+ const task = request.task;
270
+ const touches = task.touchesKnown ? safeTouches(task.touches) ?? [] : [];
271
+ const not = task.not.map(one => line(one, 300)).filter(one => one !== "");
272
+ return {
273
+ title: line(task.title, 120) || `youiyoux request ${request.id}`,
274
+ goal: `From youiyoux request ${request.id} on ${line(request.target.route, 300)}.\n\n${paragraphs(task.goal)}`,
275
+ outOfScope: not.length === 0 ? null : not.map(one => `- ${one}`).join("\n"),
276
+ touches,
277
+ acceptance: task.acceptance.map((one, index) => ({ id: `c${index + 1}`, statement: statement(one.statement), how: null, evidence: [...new Set(one.evidence)] }))
278
+ .filter(one => one.statement !== ""),
279
+ pointed: { route: line(request.target.route, 300), component: line(request.target.name, 200), at: pointedAt(request.target) },
280
+ };
281
+ }
282
+ /** `file:line` (or the file alone) where youiyoux knew a plain relative path; null otherwise. */
283
+ function pointedAt(target) {
284
+ const file = target.file === undefined ? null : safeTouches([target.file])?.[0] ?? null;
285
+ return file === null ? null : target.line === undefined ? file : `${file}:${target.line}`;
286
+ }
287
+ /** Why a request's task can't be filed as it stands, or null. */
288
+ function refusalOf(request, task) {
289
+ const given = request.task;
290
+ // Read as written and as cleaned: cleaning must never be what lets a key through.
291
+ const raw = [given.title, given.goal, request.target.route, request.target.name, request.target.file ?? "", ...given.not, ...given.touches, ...given.acceptance.map(one => one.statement)];
292
+ const all = [task.title, task.goal, task.outOfScope ?? "", ...task.touches, ...task.acceptance.map(one => one.statement), task.pointed.route, task.pointed.component, task.pointed.at ?? ""];
293
+ if ([...raw, ...all].some(one => scanForSecrets(one).length > 0))
294
+ return "it looked like it held a key or password";
295
+ if (all.some(one => hasDisguisedText(one)))
296
+ return "it carried hidden characters";
297
+ if (task.acceptance.length === 0)
298
+ return "it had no requirement to check";
299
+ return null;
300
+ }
301
+ function intakeRow(store, repo) {
302
+ const row = store.handle.prepare("SELECT * FROM youiyoux_intake WHERE repo = ?").get(repo);
303
+ if (row === undefined)
304
+ return { enabled: null, uses: false, checkedAt: null, invalid: 0, problem: null, changedBy: null };
305
+ return { enabled: row["enabled"] === null ? null : Number(row["enabled"]) === 1, uses: Number(row["uses"]) === 1, checkedAt: row["checked_at"] === null ? null : String(row["checked_at"]),
306
+ invalid: Number(row["invalid"] ?? 0), problem: row["problem"] === null ? null : String(row["problem"]), changedBy: row["changed_by"] === null ? null : String(row["changed_by"]) };
307
+ }
308
+ function recordPass(store, repo, now, uses, invalid, problem) {
309
+ store.handle.prepare(`INSERT INTO youiyoux_intake (repo, uses, checked_at, invalid, problem) VALUES (?, ?, ?, ?, ?)
310
+ ON CONFLICT (repo) DO UPDATE SET uses = excluded.uses, checked_at = excluded.checked_at, invalid = excluded.invalid, problem = excluded.problem`).run(repo, uses ? 1 : 0, now.toISOString(), invalid, problem);
311
+ }
312
+ /** A person turns the intake off or back on for a project; the ledger keeps who. */
313
+ export function setYouiyouxIntake(store, repo, on, by, now) {
314
+ store.transact(() => {
315
+ store.handle.prepare(`INSERT INTO youiyoux_intake (repo, enabled, changed_by, changed_at) VALUES (?, ?, ?, ?)
316
+ ON CONFLICT (repo) DO UPDATE SET enabled = excluded.enabled, changed_by = excluded.changed_by, changed_at = excluded.changed_at`).run(repo, on ? 1 : 0, by, now.toISOString());
317
+ store.recordAction({ at: now.toISOString(), actor: by, repo, taskId: null, runId: null, action: on ? "youiyoux requests on" : "youiyoux requests off", outcome: "changed", source: "access" });
318
+ });
319
+ }
320
+ /** How the intake stands for one project, from the store alone: a page reads it on every render, so it never reads the
321
+ * project's folder (one that doesn't answer must not hold the console). Whether it uses youiyoux is the last pass's. */
322
+ export function youiyouxState(store, repo) {
323
+ const row = intakeRow(store, repo);
324
+ const uses = row.uses;
325
+ const counts = store.handle.prepare(`SELECT count(task_id) AS filed, sum(CASE WHEN task_id IS NOT NULL AND (status IS NULL OR status NOT IN ('done','failed','stopped')) THEN 1 ELSE 0 END) AS queued
326
+ FROM youiyoux_request WHERE repo = ?`).get(repo);
327
+ return { uses, on: uses && row.enabled !== false, off: row.enabled === false, queued: Number(counts?.["queued"] ?? 0), filed: Number(counts?.["filed"] ?? 0),
328
+ invalid: row.invalid, problem: row.problem, checkedAt: row.checkedAt };
329
+ }
330
+ /** The one line `toolroll onboard` says for a project that uses youiyoux; null when it doesn't. */
331
+ export function youiyouxOnboardLine(store, repo) {
332
+ if (!usesYouiyoux(repo))
333
+ return null;
334
+ return intakeRow(store, repo).enabled === false ? "change requests: off (turn on in Settings → Integrations)" : "change requests become tasks that wait for your approval";
335
+ }
336
+ // ------------------------------------------------------------ the status file
337
+ /** The console the links name: the address the installation names (CONSOLE_URL or the console-url file), else, with
338
+ * none named, this machine's own console where `up` serves it (loopback http only, never a guessed host); null without
339
+ * either. Either way http(s) with no credentials, query or fragment. */
340
+ export function consoleBase(env, dir) {
341
+ if (dir === null)
342
+ return null;
343
+ const given = loadConsoleUrl(env, dir) ?? localConsoleUrl(dir);
344
+ if (given === null)
345
+ return null;
346
+ let url;
347
+ try {
348
+ url = new URL(given);
349
+ }
350
+ catch {
351
+ return null;
352
+ }
353
+ if ((url.protocol !== "https:" && url.protocol !== "http:") || url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "")
354
+ return null;
355
+ return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
356
+ }
357
+ /** The task's page in that console; null without one. */
358
+ export function taskLink(env, dir, taskId) {
359
+ const base = consoleBase(env, dir);
360
+ return base === null ? null : `${base}/t/${encodeURIComponent(taskId)}`;
361
+ }
362
+ /**
363
+ * Write `<id>.task.json` beside its request, whole. The folders are checked again now (a link is refused), the request
364
+ * must still be a plain file, the target must be a plain file or absent, and the content goes to a fresh temporary file
365
+ * in the same folder (created exclusively, never through a link) that is then renamed over the target.
366
+ */
367
+ export function writeStatusFile(repo, body) {
368
+ const folder = requestsFolder(repo);
369
+ if (folder === null)
370
+ return { ok: false, problem: "The requests folder is gone." };
371
+ if (!folder.ok)
372
+ return folder;
373
+ const plain = (path) => { try {
374
+ const stats = lstatSync(path);
375
+ return !stats.isSymbolicLink() && stats.isFile();
376
+ }
377
+ catch (error) {
378
+ return isMissing(error) ? null : false;
379
+ } };
380
+ if (plain(join(folder.dir, `${body.request}.json`)) !== true)
381
+ return { ok: false, problem: "The request file is gone." };
382
+ if (!readYouiyouxRequestTask(body).ok)
383
+ return { ok: false, problem: "The status didn't meet its own contract, so it wasn't written." };
384
+ const target = join(folder.dir, `${body.request}.task.json`);
385
+ if (plain(target) === false)
386
+ return { ok: false, problem: `${body.request}.task.json isn't a plain file, so it wasn't replaced.` };
387
+ const temporary = join(folder.dir, `.${body.request}.task.json.${randomBytes(6).toString("hex")}.tmp`);
388
+ let fd = null;
389
+ try {
390
+ fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NOFOLLOW, 0o644);
391
+ writeSync(fd, `${JSON.stringify(body, null, 2)}\n`);
392
+ fsyncSync(fd);
393
+ closeSync(fd);
394
+ fd = null;
395
+ // At the moment of use: the folder and the target are still what was checked.
396
+ const again = requestsFolder(repo);
397
+ if (again === null || !again.ok || again.dir !== folder.dir || plain(target) === false)
398
+ throw new Error("The requests folder changed while the status was written.");
399
+ renameSync(temporary, target);
400
+ return { ok: true };
401
+ }
402
+ catch (error) {
403
+ if (fd !== null)
404
+ try {
405
+ closeSync(fd);
406
+ }
407
+ catch { /* already closed */ }
408
+ try {
409
+ unlinkSync(temporary);
410
+ }
411
+ catch { /* never made, or already renamed */ }
412
+ return { ok: false, problem: error instanceof Error && error.message.startsWith("The requests folder") ? error.message : "The status couldn't be written beside the request." };
413
+ }
414
+ }
415
+ /**
416
+ * Put an options file into the project's main checkout as `.youi/proposals/<id>.json`, whole, and never over a file
417
+ * already there (a person may have decided on it). The folders are checked as the status file's are (`.youi/proposals`
418
+ * is made, as a real folder, when missing), the content goes to a fresh temporary file in the same folder (created
419
+ * exclusively, never through a link), the folders are checked again, and the temporary file is linked into place, which
420
+ * fails rather than replace anything at that name, a link included. `same`: that exact content was already there;
421
+ * `taken`: something else was, and is left as it was.
422
+ */
423
+ export function writeProposalFile(repo, id, text) {
424
+ if (!PROPOSAL_ID.test(id))
425
+ return { ok: false, problem: "The options file's name isn't youiyoux's." };
426
+ const folder = youiFolder(repo, "proposals", true);
427
+ if (folder === null)
428
+ return { ok: false, problem: "The project has no .youi folder." };
429
+ if (!folder.ok)
430
+ return folder;
431
+ const name = `${id}.json`;
432
+ const target = join(folder.dir, name);
433
+ const already = () => {
434
+ const there = readRequestFile(folder.dir, name);
435
+ if (!there.ok)
436
+ return { ok: false, problem: `.youi/proposals/${name} is already there and ${there.why}, so it was left alone.` };
437
+ return { ok: true, made: there.text === text ? "same" : "taken" };
438
+ };
439
+ try {
440
+ lstatSync(target);
441
+ return already();
442
+ }
443
+ catch (error) {
444
+ if (!isMissing(error))
445
+ return { ok: false, problem: `.youi/proposals/${name} can't be read.` };
446
+ }
447
+ const temporary = join(folder.dir, `.${name}.${randomBytes(6).toString("hex")}.tmp`);
448
+ let fd = null;
449
+ try {
450
+ fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NOFOLLOW, 0o644);
451
+ writeSync(fd, text);
452
+ fsyncSync(fd);
453
+ closeSync(fd);
454
+ fd = null;
455
+ // At the moment of use: the folder is still what was checked.
456
+ const again = youiFolder(repo, "proposals");
457
+ if (again === null || !again.ok || again.dir !== folder.dir)
458
+ throw new Error("The proposals folder changed while the options were written.");
459
+ try {
460
+ linkSync(temporary, target);
461
+ }
462
+ catch (error) {
463
+ if (error?.code === "EEXIST")
464
+ return already();
465
+ throw error;
466
+ }
467
+ return { ok: true, made: "written" };
468
+ }
469
+ catch (error) {
470
+ return { ok: false, problem: error instanceof Error && error.message.startsWith("The proposals folder") ? error.message : "The options couldn't be written into .youi/proposals." };
471
+ }
472
+ finally {
473
+ if (fd !== null)
474
+ try {
475
+ closeSync(fd);
476
+ }
477
+ catch { /* already closed */ }
478
+ try {
479
+ unlinkSync(temporary);
480
+ }
481
+ catch { /* never made */ }
482
+ }
483
+ }
484
+ function statusOf(store, taskId, repo, now, root) {
485
+ // With the saved evidence, so a completed task reads Complete (its completion is bound to the receipt read with it).
486
+ const read = storedTaskStatus(store, taskId, repo, now, root ?? undefined);
487
+ return read === null ? null : { status: STATUS_OF_HEADLINE[read.status.headline], detail: read.status.headline };
488
+ }
489
+ const clip = (text) => text.length > 200 ? `${text.slice(0, 199).trimEnd()}…` : text;
490
+ /** The status file's words for an options request, where its options stand (else the headline): what a person decided
491
+ * in the hub, and while the result is Ready (or Complete), whether its options went to the hub, and why not. */
492
+ function optionsDetail(row, status, headline) {
493
+ if (row?.["kind"] !== "options")
494
+ return headline;
495
+ if (row["decided"] != null && typeof row["decided_note"] === "string")
496
+ return clip(row["decided_note"]);
497
+ if (status !== "ready" && status !== "done")
498
+ return headline;
499
+ const note = typeof row["proposal_note"] === "string" ? row["proposal_note"] : "";
500
+ switch (row["proposal_state"]) {
501
+ case "copied": return "Options are ready in the hub.";
502
+ case "taken": return "An options file was already in the hub, so Toolroll left it; the task waits for its review.";
503
+ case "missing": return "The agent didn't write the options file, so nothing went to the hub.";
504
+ case "unsafe": return clip(`The options file wasn't copied to the hub: ${note}.`);
505
+ case "retry": return clip(`The options aren't in the hub yet: ${note}.`);
506
+ default: return headline;
507
+ }
508
+ }
509
+ /** Write a filed task's status where it changed, where its last write failed, or where the console its links name
510
+ * changed since (`written_base`: '' for none), so a file written before a console address existed gets its links. */
511
+ function writeStatus(store, repo, request, taskId, now, io, force) {
512
+ const known = store.handle.prepare(`SELECT status, detail, written_at, written_base, kind, proposal_state, proposal_note, decided, decided_note
513
+ FROM youiyoux_request WHERE repo = ? AND request = ?`).get(repo, request);
514
+ const read = statusOf(store, taskId, repo, now, io.evidenceRoot ?? null);
515
+ if (read === null)
516
+ return false;
517
+ const status = { status: read.status, detail: optionsDetail(known, read.status, read.detail) };
518
+ const base = consoleBase(io.env, io.dir);
519
+ if (!force && known?.["status"] === status.status && known?.["written_at"] !== null && known?.["written_base"] === (base ?? ""))
520
+ return false;
521
+ const body = { schema: "youi.request-task/v1", request, runner: "Toolroll", task: { id: taskId, url: base === null ? null : `${base}/t/${encodeURIComponent(taskId)}` },
522
+ ...(base === null ? {} : { home: `${base}${YOUIYOUX_HOME_PATH}` }), status: status.status, detail: status.detail, updatedAt: now.toISOString() };
523
+ const wrote = writeStatusFile(repo, body);
524
+ store.handle.prepare(`UPDATE youiyoux_request SET status = ?, detail = ?, status_at = ?, written_at = CASE WHEN ? THEN ? ELSE written_at END,
525
+ written_base = CASE WHEN ? THEN ? ELSE written_base END, write_problem = ? WHERE repo = ? AND request = ?`)
526
+ .run(status.status, status.detail, now.toISOString(), wrote.ok ? 1 : 0, now.toISOString(), wrote.ok ? 1 : 0, base ?? "", wrote.ok ? null : wrote.problem, repo, request);
527
+ return wrote.ok;
528
+ }
529
+ export const NO_YOUIYOUX_PASS = Object.freeze({ filed: 0, skipped: 0, invalid: 0, updated: 0, copied: 0, decided: 0, problems: [] });
530
+ const NOTHING = () => ({ ...NO_YOUIYOUX_PASS, problems: [] });
531
+ /** File one request's task, exactly once: the claim and the filing are one transaction (two passes, in two processes
532
+ * or one, make one task). */
533
+ export function fileYouiyouxRequest(store, repo, request, now) {
534
+ const task = requestTask(request);
535
+ return store.transact(() => {
536
+ if (store.handle.prepare("SELECT 1 FROM youiyoux_request WHERE repo = ? AND request = ?").get(repo, request.id) !== undefined)
537
+ return { made: "seen" };
538
+ const kind = request.kind === "options" ? "options" : "change";
539
+ const proposal = kind === "options" ? proposalIdOf(request.id) : null;
540
+ const leaveOut = (note) => {
541
+ store.handle.prepare("INSERT INTO youiyoux_request (repo, request, task_id, note, filed_at, kind, proposal) VALUES (?, ?, NULL, ?, ?, ?, ?)").run(repo, request.id, note, now.toISOString(), kind, proposal);
542
+ return { made: "skipped", note };
543
+ };
544
+ const refused = refusalOf(request, task);
545
+ if (refused !== null)
546
+ return leaveOut(refused);
547
+ const filed = fileTaskProposal(store, {
548
+ title: task.title, repo, goal: task.goal, outOfScope: task.outOfScope, touches: task.touches, acceptance: task.acceptance,
549
+ filedVia: YOUIYOUX_FILED_VIA, filedBy: { name: YOUIYOUX_ACTOR, kind: "automation" }, planning: "skip", admittedRepos: [repo],
550
+ }, now);
551
+ if (!filed.ok)
552
+ return leaveOut(`Toolroll refused it: ${filed.message}`);
553
+ store.handle.prepare("INSERT INTO youiyoux_request (repo, request, task_id, filed_at, route, component, source_at, kind, proposal) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
554
+ .run(repo, request.id, filed.id, now.toISOString(), task.pointed.route, task.pointed.component, task.pointed.at, kind, proposal);
555
+ store.recordAction({ at: now.toISOString(), actor: YOUIYOUX_ACTOR, repo, taskId: filed.id, runId: null, action: "filed from a youiyoux request", outcome: "waits for approval", source: "work",
556
+ detail: proposal === null ? request.id : `${request.id} (options: ${proposal})` });
557
+ return { made: "filed", taskId: filed.id };
558
+ });
559
+ }
560
+ /**
561
+ * One message for what a pass filed, however many: the person's paired chats draw it as one card (chat-decide.ts) that
562
+ * lists each new task, with Approve all and each one's own Approve and Open, and a phone push links to what needs
563
+ * them. Keyed by the pass's first task, so a pass says it once; the tasks are the ones filed with it (one project, one
564
+ * moment: requestPassOf). Titles are cleaned outside text, as filed.
565
+ */
566
+ export function noteYouiyouxFiled(store, taskIds, now) {
567
+ const first = store.lookupRef(taskIds[0]);
568
+ if (first === null)
569
+ return;
570
+ const titles = taskIds.map(id => store.getTask(id)?.title ?? id);
571
+ const what = requestKindWord(store, taskIds);
572
+ store.enqueueNotification({
573
+ dedupeKey: `${YOUIYOUX_CARD_KIND}:${first.id}`, kind: YOUIYOUX_CARD_KIND, pushClass: "attention", link: YOUIYOUX_HOME_PATH, source: { taskRef: first.id },
574
+ subject: taskIds.length === 1 ? `A youiyoux ${what} waits for your approval` : `${taskIds.length} youiyoux ${what}s wait for your approval`,
575
+ body: titles.map(title => `• ${title}`).join("\n"),
576
+ }, now);
577
+ }
578
+ /** What a pass's tasks were asked for, in a word: "change request", "options request", or "request" for both. */
579
+ export function requestKindWord(store, taskIds) {
580
+ const kinds = new Set(taskIds.map(id => store.youiyouxOriginOf(id)?.kind ?? "change"));
581
+ return kinds.size > 1 ? "request" : kinds.has("options") ? "options request" : "change request";
582
+ }
583
+ /** The tasks one pass filed beside this one (one project, one moment), in request order; empty for any other task. */
584
+ export function requestPassOf(store, taskId) {
585
+ return store.handle.prepare(`SELECT r.task_id FROM youiyoux_request me JOIN youiyoux_request r ON r.repo = me.repo AND r.filed_at = me.filed_at
586
+ WHERE me.task_id = ? AND r.task_id IS NOT NULL ORDER BY r.request LIMIT ?`).all(taskId, FILED_PER_PASS).map(row => String(row["task_id"]));
587
+ }
588
+ /**
589
+ * One project's pass: read new requests and file them, then write each filed task's status where it changed (no
590
+ * options moved: runYouiyoux does that too). The folder read is the project in Toolroll's records that `given`
591
+ * resolves to; anything else is left alone.
592
+ */
593
+ export function runYouiyouxIntake(store, given, now, io, options = {}) {
594
+ return intake(store, given, now, io, options).pass;
595
+ }
596
+ /** The worker's pass for one project (flow-cadence.ts): the intake, then each options request's options, both ways. */
597
+ export async function runYouiyoux(store, given, now, io, options = {}) {
598
+ const { pass, repo } = intake(store, given, now, io, options);
599
+ if (repo !== null)
600
+ await optionsPass(store, repo, now, io, pass);
601
+ return pass;
602
+ }
603
+ /** The intake's pass; `repo` is the project when its requests folder was read whole (and options may move). */
604
+ function intake(store, given, now, io, options) {
605
+ const pass = NOTHING();
606
+ const none = { pass, repo: null };
607
+ // The project as Toolroll recorded it, resolved: a folder that isn't one of its projects is never read.
608
+ const repo = canonicalProject(given);
609
+ if (repo === null || !store.listProjects().some(one => one.path === repo))
610
+ return none;
611
+ const row = intakeRow(store, repo);
612
+ if (row.enabled === false)
613
+ return none;
614
+ if (options.force !== true && row.checkedAt !== null && now.getTime() - Date.parse(row.checkedAt) < INTAKE_EVERY_MS)
615
+ return none;
616
+ if (!usesYouiyoux(repo)) {
617
+ if (row.uses)
618
+ recordPass(store, repo, now, false, 0, null);
619
+ return none;
620
+ }
621
+ const folder = requestsFolder(repo);
622
+ if (folder === null) {
623
+ recordPass(store, repo, now, true, 0, null);
624
+ return none;
625
+ }
626
+ if (!folder.ok) {
627
+ recordPass(store, repo, now, true, 0, folder.problem);
628
+ pass.problems.push(`youiyoux: ${folder.problem}`);
629
+ return none;
630
+ }
631
+ const taken = new Set(store.handle.prepare("SELECT request FROM youiyoux_request WHERE repo = ?").all(repo).map(one => String(one["request"])));
632
+ let read;
633
+ try {
634
+ read = readRequests(folder.dir, taken);
635
+ }
636
+ catch {
637
+ recordPass(store, repo, now, true, 0, ".youi/requests can't be read.");
638
+ pass.problems.push("youiyoux: .youi/requests can't be read.");
639
+ return none;
640
+ }
641
+ pass.invalid = read.invalid.length;
642
+ const filedNow = [];
643
+ for (const request of read.requests.slice(0, FILED_PER_PASS)) {
644
+ const made = fileYouiyouxRequest(store, repo, request, now);
645
+ if (made.made === "filed") {
646
+ pass.filed++;
647
+ filedNow.push(made.taskId);
648
+ if (writeStatus(store, repo, request.id, made.taskId, now, io, true))
649
+ pass.updated++;
650
+ }
651
+ else if (made.made === "skipped")
652
+ pass.skipped++;
653
+ }
654
+ if (filedNow.length > 0)
655
+ noteYouiyouxFiled(store, filedNow, now);
656
+ // Each filed task's status, where it changed, its last write failed, or the console its links name changed.
657
+ const filed = store.handle.prepare(`SELECT request, task_id FROM youiyoux_request WHERE repo = ? AND task_id IS NOT NULL
658
+ AND (status IS NULL OR status <> 'done' OR written_at IS NULL OR written_base IS NOT ?) ORDER BY status_at LIMIT ?`).all(repo, consoleBase(io.env, io.dir) ?? "", STATUS_PER_PASS);
659
+ for (const one of filed) {
660
+ try {
661
+ if (writeStatus(store, repo, String(one["request"]), String(one["task_id"]), now, io, false))
662
+ pass.updated++;
663
+ }
664
+ catch (error) {
665
+ pass.problems.push(`youiyoux ${String(one["request"])}: ${error instanceof Error ? error.message : "status unreadable"}`);
666
+ }
667
+ }
668
+ const problem = read.invalid.length === 0 ? null
669
+ : `${plural(read.invalid.length, "request file")} couldn't be read: ${read.invalid[0]}${read.invalid.length > 1 ? ", and others" : ""}.`;
670
+ recordPass(store, repo, now, true, read.invalid.length, problem);
671
+ return { pass, repo };
672
+ }
673
+ const optionsRow = (row) => ({ request: String(row["request"]), taskId: String(row["task_id"]), proposal: String(row["proposal"]),
674
+ state: row["proposal_state"] == null ? null : String(row["proposal_state"]), run: row["proposal_run"] == null ? null : Number(row["proposal_run"]),
675
+ digest: row["proposal_digest"] == null ? null : String(row["proposal_digest"]) });
676
+ /** What a person's decision in the hub may change in an options file (youiyoux seals it, with a key only the person
677
+ * holds: `seal`); everything else must stay as Toolroll copied it. */
678
+ const DECISION_FIELDS = new Set(["status", "choice", "decidedBy", "decidedAt", "request", "seal", "updatedAt"]);
679
+ /** The digest of an options file apart from what a decision changes: the proposal itself, every other key included. */
680
+ export function proposalIdentity(raw) {
681
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
682
+ return null;
683
+ const kept = Object.fromEntries(Object.entries(raw).filter(([key]) => !DECISION_FIELDS.has(key)));
684
+ return createHash("sha256").update(canonicalJson(kept)).digest("hex");
685
+ }
686
+ const SHA = /^[0-9a-f]{40}$/;
687
+ /** The task's current result, as its assignment reads it (with the saved evidence where the worker has it): the run and
688
+ * the commit Toolroll recorded for it. Null without a finished result. */
689
+ function resultOf(store, taskId, repo, now, root) {
690
+ const assignment = assignmentOf(store, taskId, now, { principal: "operator", repos: [repo] }, root ?? undefined);
691
+ const receipt = assignment?.receipt ?? null;
692
+ if (assignment === null || receipt === null || receipt.completionKind === null || receipt.completionKind === "research-report" || receipt.head === null || !SHA.test(receipt.head))
693
+ return null;
694
+ return { assignment, receipt, head: receipt.head };
695
+ }
696
+ /** Why an agent's options file can't go to the hub as it stands, or null. Toolroll checks what makes it safe to copy
697
+ * and this request's; youiyoux checks each option against the component's contract when it reads it. */
698
+ export function proposalProblem(text, proposal, request) {
699
+ let raw;
700
+ try {
701
+ raw = JSON.parse(text);
702
+ }
703
+ catch {
704
+ return "it isn't JSON";
705
+ }
706
+ const parsed = readYouiyouxProposal(raw);
707
+ if (!parsed.ok) {
708
+ const issue = parsed.issues[0];
709
+ return `it isn't a youi.proposal/v1 file (${issue === undefined ? "unreadable" : `${issue.path}: ${issue.kind}`})`;
710
+ }
711
+ const doc = parsed.value;
712
+ if (doc.id !== proposal)
713
+ return `its id isn't ${proposal}`;
714
+ if (doc.for !== request)
715
+ return `it doesn't answer ${request}`;
716
+ // An agent proposes; only a person decides, in the hub. A file that arrives decided would decide for them.
717
+ if (doc.status !== "open" || doc.choice !== undefined || doc.decidedBy !== undefined || doc.decidedAt !== undefined || doc.request !== undefined || Object.hasOwn(raw, "seal"))
718
+ return "it arrived already decided, and only a person decides";
719
+ if (doc.author.kind !== "agent")
720
+ return "its author isn't an agent";
721
+ if (scanForSecrets(text).length > 0)
722
+ return "it looked like it held a key or password";
723
+ return null;
724
+ }
725
+ /** Read the options file from the result's commit (never the agent's checkout) and copy it to the hub. */
726
+ async function bringProposal(git, repo, request, proposal, head) {
727
+ const file = proposalFileOf(proposal);
728
+ const listed = await git(repo, ["ls-tree", "-z", "--full-tree", head, "--", file]);
729
+ if (listed.code !== 0)
730
+ return { state: "retry", note: "git couldn't read the result" };
731
+ const entries = listed.stdout.split("\0").filter(one => one !== "");
732
+ if (entries.length === 0)
733
+ return { state: "missing", note: null };
734
+ const entry = /^(\d{6}) (\w+) ([0-9a-f]{40})\t(.*)$/s.exec(entries[0]);
735
+ if (entries.length !== 1 || entry === null || entry[4] !== file)
736
+ return { state: "unsafe", note: "it isn't a plain file" };
737
+ if (entry[1] === "120000")
738
+ return { state: "unsafe", note: "it is a link" };
739
+ if (entry[2] !== "blob" || (entry[1] !== "100644" && entry[1] !== "100755"))
740
+ return { state: "unsafe", note: "it isn't a plain file" };
741
+ const sized = await git(repo, ["cat-file", "-s", entry[3]]);
742
+ const size = Number(sized.stdout.trim());
743
+ if (sized.code !== 0 || !Number.isSafeInteger(size))
744
+ return { state: "retry", note: "git couldn't read the result" };
745
+ if (size > REQUEST_BYTES)
746
+ return { state: "unsafe", note: "it is over 64 KB" };
747
+ const blob = await git(repo, ["cat-file", "blob", entry[3]]);
748
+ if (blob.code !== 0)
749
+ return { state: "retry", note: "git couldn't read the result" };
750
+ if (Buffer.byteLength(blob.stdout, "utf8") !== size)
751
+ return { state: "unsafe", note: "it isn't UTF-8 text" };
752
+ const problem = proposalProblem(blob.stdout, proposal, request);
753
+ if (problem !== null)
754
+ return { state: "unsafe", note: problem };
755
+ const wrote = writeProposalFile(repo, proposal, blob.stdout);
756
+ if (!wrote.ok)
757
+ return { state: "retry", note: wrote.problem.replace(/\.$/, "") };
758
+ // Only Toolroll's own copy (written now, or byte for byte an earlier pass's) is ever read for a decision.
759
+ if (wrote.made === "taken")
760
+ return { state: "taken", note: "a file was already there; the task waits for its review" };
761
+ return { state: "copied", note: null, digest: proposalIdentity(JSON.parse(blob.stdout)) };
762
+ }
763
+ /** What a person decided on the options in the hub, read from the copy in the main checkout; null while they are open or
764
+ * the file can't be read as this request's. Who and the option's label are youiyoux's words: cleaned, and left out
765
+ * when they look like a key. */
766
+ function decisionOf(repo, proposal, request, copied) {
767
+ const folder = youiFolder(repo, "proposals");
768
+ if (folder === null || !folder.ok)
769
+ return null;
770
+ const file = readRequestFile(folder.dir, `${proposal}.json`);
771
+ if (!file.ok)
772
+ return null;
773
+ let raw;
774
+ try {
775
+ raw = JSON.parse(file.text);
776
+ }
777
+ catch {
778
+ return null;
779
+ }
780
+ const parsed = readYouiyouxProposal(raw);
781
+ if (!parsed.ok || parsed.value.id !== proposal || parsed.value.for !== request)
782
+ return null;
783
+ // Still the proposal Toolroll copied: its options, ask, target and every other key, apart from what deciding changes.
784
+ if (proposalIdentity(raw) !== copied)
785
+ return null;
786
+ const doc = parsed.value;
787
+ if (doc.status === "open" || (doc.status === "chosen" && doc.choice === undefined))
788
+ return null;
789
+ // A person's decision, at a time: an agent proposes, and never decides.
790
+ if (doc.decidedBy?.kind !== "person" || doc.decidedAt === undefined)
791
+ return null;
792
+ const said = (text, max) => {
793
+ if (text === undefined)
794
+ return null;
795
+ const clean = line(text, max);
796
+ return clean === "" || scanForSecrets(text).length > 0 || scanForSecrets(clean).length > 0 || hasDisguisedText(clean) ? null : clean;
797
+ };
798
+ return { status: doc.status, choice: doc.choice ?? null, label: said(doc.options.find(one => one.id === doc.choice)?.label, 60),
799
+ by: said(doc.decidedBy?.name, 100), at: doc.decidedAt ?? null };
800
+ }
801
+ /**
802
+ * A person decided in the hub: mark the task complete as landed work is, bound to the exact result whose options were
803
+ * copied, through every guard a person's completion passes, or leave it Ready for review and say why. Once per request,
804
+ * in one transaction with the read. Waits (false) while the task isn't Ready, Complete or cancelled.
805
+ */
806
+ export function actOnDecision(store, repo, row, decision, now, root) {
807
+ return store.transact(() => {
808
+ const current = store.handle.prepare("SELECT decided, proposal_run FROM youiyoux_request WHERE repo = ? AND request = ?").get(repo, row.request);
809
+ if (current === undefined || current["decided"] !== null)
810
+ return false;
811
+ const assignment = assignmentOf(store, row.taskId, now, { principal: "operator", repos: [repo] }, root);
812
+ if (assignment === null)
813
+ return false;
814
+ const receipt = assignment.receipt;
815
+ const who = decision.by ?? "a person";
816
+ const what = decision.status === "chosen" ? `option ${decision.choice}${decision.label === null ? "" : ` (“${decision.label}”)`}` : null;
817
+ const words = what === null ? `${who} dismissed the options in youiyoux's hub` : `${who} picked ${what} in youiyoux's hub`;
818
+ const picked = what === null ? "The options were dismissed in the hub" : `Option ${decision.choice} was picked in the hub`;
819
+ let done, note, completes = false;
820
+ if (assignment.state === "complete" || assignment.state === "cancelled") {
821
+ done = `the task was already ${assignment.state}`;
822
+ note = `${picked}.`;
823
+ }
824
+ else if (assignment.state !== "ready-to-check" || receipt === null) {
825
+ return false;
826
+ }
827
+ else {
828
+ const why = receipt.runId !== Number(current["proposal_run"]) ? "its newest result isn't the one whose options went to the hub" : completionProblem(store, assignment, root);
829
+ if (why === null) {
830
+ completes = true;
831
+ done = "Toolroll marked the task complete without merging or publishing its branch";
832
+ note = `${picked}, so the task is complete.`;
833
+ }
834
+ else {
835
+ done = `the task stays ready for review in Toolroll: ${why}`;
836
+ note = `${picked}; the task waits for review in Toolroll.`;
837
+ }
838
+ }
839
+ const at = now.toISOString();
840
+ if (completes) {
841
+ store.recordAction({ at, actor: LANDED_ACTOR, repo, taskId: assignment.rootId, runId: receipt.runId, action: COMPLETION_ACTION, outcome: receipt.digest, source: "work",
842
+ detail: `its options went to youiyoux, where ${words}` });
843
+ const ref = store.lookupRef(assignment.rootId);
844
+ if (ref !== null)
845
+ store.noteTaskAct(ref.id, "completed", now, undefined, { account: LANDED_ACTOR, lead: false });
846
+ store.bumpWake();
847
+ }
848
+ store.recordAction({ at, actor: YOUIYOUX_ACTOR, repo, taskId: assignment.rootId, runId: receipt?.runId ?? null, action: OPTIONS_DECIDED_ACTION, outcome: decision.status, source: "work",
849
+ detail: `${words}${decision.at === null ? "" : ` (${decision.at})`}; ${done}` });
850
+ store.handle.prepare("UPDATE youiyoux_request SET decided = ?, decided_at = ?, decided_note = ? WHERE repo = ? AND request = ?").run(decision.status, at, note, repo, row.request);
851
+ if (completes)
852
+ noteAssignmentStatus(store, assignmentOf(store, assignment.rootId, now, { principal: "operator", repos: [repo] }, root), now);
853
+ return true;
854
+ });
855
+ }
856
+ /** Each options request's options, both ways: a Ready result's file to the hub, and a decision in the hub back. */
857
+ async function optionsPass(store, repo, now, io, pass) {
858
+ const root = io.evidenceRoot ?? null;
859
+ if (io.git === undefined || root === null)
860
+ return;
861
+ const bring = store.handle.prepare(`SELECT request, task_id, proposal, proposal_state, proposal_run, proposal_digest FROM youiyoux_request WHERE repo = ? AND kind = 'options' AND task_id IS NOT NULL
862
+ AND (proposal_state IS NULL OR proposal_state NOT IN ('copied','taken')) AND status IN ('ready','done') ORDER BY status_at LIMIT ?`).all(repo, OPTIONS_PER_PASS).map(optionsRow);
863
+ for (const row of bring) {
864
+ try {
865
+ const result = resultOf(store, row.taskId, repo, now, root);
866
+ if (result === null)
867
+ continue;
868
+ // Said once for each result: a newer one (a revision, a build again) is read again.
869
+ if ((row.state === "missing" || row.state === "unsafe") && row.run === result.receipt.runId)
870
+ continue;
871
+ const brought = await bringProposal(io.git, repo, row.request, row.proposal, result.head);
872
+ const at = now.toISOString();
873
+ store.transact(() => {
874
+ store.handle.prepare("UPDATE youiyoux_request SET proposal_state = ?, proposal_run = ?, proposal_note = ?, proposal_at = ?, proposal_digest = ? WHERE repo = ? AND request = ?")
875
+ .run(brought.state, result.receipt.runId, brought.note, at, brought.digest ?? null, repo, row.request);
876
+ if (brought.state !== "retry")
877
+ store.recordAction({ at, actor: YOUIYOUX_ACTOR, repo, taskId: result.assignment.rootId, runId: result.receipt.runId, action: OPTIONS_COPIED_ACTION,
878
+ outcome: brought.state === "copied" ? "copied" : brought.state === "taken" ? "left as it was" : "not copied", source: "work",
879
+ detail: `${proposalFileOf(row.proposal)}${brought.state === "missing" ? ": not in the result" : brought.note === null ? "" : `: ${brought.note}`}` });
880
+ });
881
+ if (brought.state === "copied")
882
+ pass.copied++;
883
+ if (brought.state === "retry")
884
+ pass.problems.push(`youiyoux ${row.request}: ${brought.note}`);
885
+ if (writeStatus(store, repo, row.request, row.taskId, now, io, true))
886
+ pass.updated++;
887
+ }
888
+ catch (error) {
889
+ pass.problems.push(`youiyoux ${row.request}: ${error instanceof Error ? error.message : "options unreadable"}`);
890
+ }
891
+ }
892
+ // Only Toolroll's own copies: a file that was already there (`taken`) is never read for a decision.
893
+ const watch = store.handle.prepare(`SELECT request, task_id, proposal, proposal_state, proposal_run, proposal_digest FROM youiyoux_request WHERE repo = ? AND kind = 'options' AND task_id IS NOT NULL
894
+ AND proposal_state = 'copied' AND proposal_digest IS NOT NULL AND decided IS NULL ORDER BY proposal_at LIMIT ?`).all(repo, OPTIONS_PER_PASS).map(optionsRow);
895
+ for (const row of watch) {
896
+ try {
897
+ const decision = decisionOf(repo, row.proposal, row.request, row.digest);
898
+ if (decision === null || !actOnDecision(store, repo, row, decision, now, root))
899
+ continue;
900
+ pass.decided++;
901
+ if (writeStatus(store, repo, row.request, row.taskId, now, io, true))
902
+ pass.updated++;
903
+ }
904
+ catch (error) {
905
+ pass.problems.push(`youiyoux ${row.request}: ${error instanceof Error ? error.message : "decision unreadable"}`);
906
+ }
907
+ }
908
+ }