toolroll 0.9.83 → 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 (137) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +94 -1192
  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/assignment-ui.js +5 -1
  8. package/dist/browser/workspace.css +2 -2
  9. package/dist/browser/workspace.js +31 -31
  10. package/dist/browser-crew.js +5 -1
  11. package/dist/browser-workspace.d.ts +12 -0
  12. package/dist/chat-channel.d.ts +10 -1
  13. package/dist/chat-channel.js +35 -5
  14. package/dist/chat-decide.d.ts +16 -4
  15. package/dist/chat-decide.js +239 -31
  16. package/dist/chat-messages.d.ts +5 -0
  17. package/dist/chat-messages.js +1 -1
  18. package/dist/chat-quiet.js +3 -2
  19. package/dist/chat-review.d.ts +4 -0
  20. package/dist/chat-review.js +13 -4
  21. package/dist/chat-terms.d.ts +9 -0
  22. package/dist/chat-terms.js +46 -0
  23. package/dist/contracts/console-api.d.ts +4 -2
  24. package/dist/contracts/console-api.js +7 -2
  25. package/dist/contracts/lead-tools.d.ts +30 -0
  26. package/dist/contracts/lead-tools.js +3 -0
  27. package/dist/contracts/youiyoux-request.d.ts +248 -0
  28. package/dist/contracts/youiyoux-request.js +152 -0
  29. package/dist/control-setup.d.ts +9 -0
  30. package/dist/control-setup.js +31 -4
  31. package/dist/control-ui.d.ts +7 -1
  32. package/dist/control-ui.js +3 -2
  33. package/dist/desktop-update.d.ts +1 -1
  34. package/dist/dispatch.d.ts +15 -0
  35. package/dist/dispatch.js +26 -4
  36. package/dist/first-run.d.ts +5 -2
  37. package/dist/first-run.js +13 -2
  38. package/dist/flow-cadence.d.ts +8 -2
  39. package/dist/flow-cadence.js +16 -3
  40. package/dist/flow-card-task.d.ts +9 -0
  41. package/dist/flow-card-task.js +15 -6
  42. package/dist/folder-look.d.ts +3 -0
  43. package/dist/folder-look.js +8 -4
  44. package/dist/guides.js +4 -3
  45. package/dist/integrations-ui.js +9 -4
  46. package/dist/integrations.d.ts +9 -1
  47. package/dist/integrations.js +43 -2
  48. package/dist/landed-cleanup.d.ts +4 -0
  49. package/dist/landed-cleanup.js +3 -2
  50. package/dist/lead-capabilities.js +2 -1
  51. package/dist/lead-cli.js +3 -1
  52. package/dist/lead-tools.d.ts +2 -0
  53. package/dist/lead-tools.js +30 -4
  54. package/dist/lead.d.ts +2 -0
  55. package/dist/lead.js +3 -3
  56. package/dist/model-catalog.d.ts +5 -0
  57. package/dist/model-catalog.js +11 -2
  58. package/dist/model-choices.d.ts +188 -0
  59. package/dist/model-choices.js +405 -0
  60. package/dist/model-names.d.ts +6 -0
  61. package/dist/model-names.js +34 -0
  62. package/dist/models-ui.d.ts +69 -5
  63. package/dist/models-ui.js +53 -10
  64. package/dist/needs-you.d.ts +8 -0
  65. package/dist/needs-you.js +8 -0
  66. package/dist/operate.d.ts +22 -2
  67. package/dist/operate.js +107 -41
  68. package/dist/project-activity.d.ts +20 -0
  69. package/dist/project-activity.js +23 -0
  70. package/dist/project-admission.d.ts +4 -1
  71. package/dist/project-admission.js +23 -2
  72. package/dist/project-cli.d.ts +1 -0
  73. package/dist/project-cli.js +36 -19
  74. package/dist/project-identity.d.ts +9 -2
  75. package/dist/project-identity.js +80 -33
  76. package/dist/project-knowledge.d.ts +9 -9
  77. package/dist/project-outcomes.d.ts +119 -0
  78. package/dist/project-outcomes.js +79 -0
  79. package/dist/result-actions.d.ts +4 -0
  80. package/dist/result-actions.js +41 -9
  81. package/dist/result-review.d.ts +9 -0
  82. package/dist/result-review.js +45 -5
  83. package/dist/serve.js +60 -13
  84. package/dist/server/chat.js +5 -4
  85. package/dist/server/chrome.d.ts +3 -0
  86. package/dist/server/chrome.js +5 -2
  87. package/dist/server/console.css +17 -2
  88. package/dist/server/flows.js +4 -0
  89. package/dist/server/http.d.ts +1 -0
  90. package/dist/server/http.js +6 -0
  91. package/dist/server/pages.js +53 -7
  92. package/dist/server/people-tokens.js +40 -20
  93. package/dist/server/render-chat.js +3 -2
  94. package/dist/server/render-pages.d.ts +12 -2
  95. package/dist/server/render-pages.js +36 -16
  96. package/dist/server/render-people.d.ts +14 -2
  97. package/dist/server/render-people.js +35 -12
  98. package/dist/server/render-results.d.ts +2 -0
  99. package/dist/server/render-results.js +20 -7
  100. package/dist/server/render-settings.js +1 -0
  101. package/dist/server/render-tasks.d.ts +1 -0
  102. package/dist/server/render-tasks.js +22 -19
  103. package/dist/server/request-context.d.ts +2 -0
  104. package/dist/server/route-table.js +9 -1
  105. package/dist/server/settings.d.ts +58 -1
  106. package/dist/server/settings.js +216 -70
  107. package/dist/server/signup-gate.d.ts +9 -0
  108. package/dist/server/signup-gate.js +40 -0
  109. package/dist/server/task-views.js +6 -0
  110. package/dist/server/tasks.js +54 -8
  111. package/dist/settings-kit-script.d.ts +21 -0
  112. package/dist/settings-kit-script.js +67 -0
  113. package/dist/settings-kit.d.ts +137 -0
  114. package/dist/settings-kit.js +186 -0
  115. package/dist/settings-proofs.d.ts +64 -0
  116. package/dist/settings-proofs.js +101 -0
  117. package/dist/setup-guide.js +4 -2
  118. package/dist/spend-report.d.ts +87 -0
  119. package/dist/spend-report.js +265 -0
  120. package/dist/spend-ui.d.ts +29 -8
  121. package/dist/spend-ui.js +74 -39
  122. package/dist/store.d.ts +46 -3
  123. package/dist/store.js +165 -6
  124. package/dist/task-sizing.d.ts +5 -3
  125. package/dist/task-sizing.js +4 -4
  126. package/dist/task-source.d.ts +13 -1
  127. package/dist/task-source.js +17 -1
  128. package/dist/telegram-lead.js +7 -5
  129. package/dist/telegram.js +1 -1
  130. package/dist/toolroll-update.js +2 -2
  131. package/dist/webhooks.d.ts +6 -0
  132. package/dist/webhooks.js +25 -0
  133. package/dist/work-index.d.ts +4 -0
  134. package/dist/work-index.js +32 -0
  135. package/dist/youiyoux-intake.d.ts +234 -0
  136. package/dist/youiyoux-intake.js +908 -0
  137. package/package.json +11 -8
@@ -0,0 +1,152 @@
1
+ /**
2
+ * A youiyoux request as Toolroll reads it (youiyoux-intake.ts): the published `youi.request/v1` schema (youiyoux's
3
+ * schema/youi.request.schema.json), held to its own limits, and the `youi.proposal/v1` options file an options request's
4
+ * agent writes (schema/youi.proposal.schema.json). Unknown keys are allowed, as the published schemas allow them;
5
+ * everything Toolroll reads is checked here, and every string is outside text all the same.
6
+ */
7
+ import { z } from "zod";
8
+ import { parseContract } from "./contract.js";
9
+ export const REQUEST_ID = /^r-[0-9a-f]{10}$/;
10
+ /** An options file's id: `p-` and the same 10 hex as the options request it answers. */
11
+ export const PROPOSAL_ID = /^p-[0-9a-f]{10}$/;
12
+ /** The provenance every task filed from a request carries (task_ref.filed_via): immutable, and the seal reads it. */
13
+ export const YOUIYOUX_FILED_VIA = "youiyoux";
14
+ /** The one chat card a pass's filings make for a project (youiyoux-intake.ts), drawn by chat-decide.ts: the new tasks,
15
+ * Approve all and each one's own Approve and Open. */
16
+ export const YOUIYOUX_CARD_KIND = "youiyoux-requests";
17
+ /** Where a person sees and approves everything waiting on them in the console (flow-triggers.ts NEEDS_YOU_HREF). */
18
+ export const YOUIYOUX_HOME_PATH = "/work?view=needs-you";
19
+ const text = (max) => z.string().min(1).max(max);
20
+ const who = z.object({ kind: z.enum(["person", "agent"]), name: text(100) });
21
+ // The published schema's date-time: an ISO timestamp with a zone.
22
+ const when = z.string().max(40).regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/);
23
+ export const YOUIYOUX_EVIDENCE = ["screenshot", "check", "changed-path", "manual-review"];
24
+ /** A component prop as youiyoux names one: what an option may set. */
25
+ const PROP = /^[A-Za-z_$][\w$]{0,63}$/;
26
+ const value = z.union([z.string().max(2000), z.number().finite(), z.boolean()]);
27
+ /** What an options request offers its agent: the one file to write (`.youi/proposals/<proposal>.json`), the props an
28
+ * option may set (enum values from `values`), and whether it may give the component new text. */
29
+ export const youiyouxOfferSchema = z.object({
30
+ proposal: z.string().regex(PROPOSAL_ID),
31
+ file: text(500),
32
+ choices: z.record(z.string().regex(PROP), z.object({
33
+ type: z.enum(["enum", "string", "boolean", "number"]),
34
+ values: z.array(text(200)).max(100).optional(),
35
+ default: value.optional(),
36
+ })).refine(choices => Object.keys(choices).length <= 50, "at most 50 props"),
37
+ text: z.boolean(),
38
+ });
39
+ export const youiyouxTaskSchema = z.object({
40
+ title: text(120),
41
+ goal: text(4000),
42
+ touches: z.array(text(500)).max(50),
43
+ touchesKnown: z.boolean(),
44
+ why: text(500).optional(),
45
+ not: z.array(text(300)).max(10),
46
+ acceptance: z.array(z.object({ statement: text(300), evidence: z.array(z.enum(YOUIYOUX_EVIDENCE)).min(1) })).min(1).max(10),
47
+ /** An options request's: what its agent writes (optionsProblem holds it to the request). */
48
+ offer: youiyouxOfferSchema.optional(),
49
+ });
50
+ export const youiyouxRequestSchema = z.object({
51
+ schema: z.literal("youi.request/v1"),
52
+ id: z.string().regex(REQUEST_ID),
53
+ project: text(200),
54
+ kind: z.enum(["comment", "change", "options"]),
55
+ status: z.enum(["open", "addressed", "closed"]),
56
+ body: text(4000),
57
+ author: who,
58
+ createdAt: when,
59
+ updatedAt: when,
60
+ // Where the person pointed: the page, the component, and (when youiyoux knew it) the file and line that draw it.
61
+ target: z.object({ route: text(300), scope: z.enum(["here", "element"]), name: text(200), file: text(500).optional(), line: z.int().positive().max(Number.MAX_SAFE_INTEGER).optional() }),
62
+ task: youiyouxTaskSchema.optional(),
63
+ thread: z.array(z.unknown()).max(500),
64
+ });
65
+ export function readYouiyouxRequest(raw) {
66
+ return parseContract(youiyouxRequestSchema, raw);
67
+ }
68
+ /** The one file an options request's agent may write: `.youi/proposals/p-<the request's hex>.json`. */
69
+ export const proposalIdOf = (request) => `p-${request.slice(2)}`;
70
+ export const proposalFileOf = (proposal) => `.youi/proposals/${proposal}.json`;
71
+ /** Why an options request can't be filed as youiyoux's contract has it, or null: it carries a task with an offer whose
72
+ * proposal is `p-<its own hex>`, whose file is that proposal's, and the task may touch that one file and nothing else. */
73
+ export function optionsProblem(request) {
74
+ if (request.kind !== "options")
75
+ return null;
76
+ const offer = request.task?.offer;
77
+ if (request.task === undefined || offer === undefined)
78
+ return "it asks for options with no offer";
79
+ const proposal = proposalIdOf(request.id);
80
+ if (offer.proposal !== proposal)
81
+ return `its offer is for ${offer.proposal}, not ${proposal}`;
82
+ if (offer.file !== proposalFileOf(proposal))
83
+ return `its offer's file isn't ${proposalFileOf(proposal)}`;
84
+ if (!request.task.touchesKnown || request.task.touches.length !== 1 || request.task.touches[0] !== offer.file)
85
+ return "its task may touch more than its options file";
86
+ return null;
87
+ }
88
+ /** `youi.proposal/v1`: the options an agent puts on youiyoux's Screens canvas (youiyoux's packages/core/src/proposals.ts,
89
+ * mirrored). Toolroll checks only the shape and what it reads; youiyoux checks each option against the component's
90
+ * contract when it reads the file. */
91
+ const OPTION_IDS = ["a", "b", "c", "d"];
92
+ export const PROPOSAL_STATUSES = ["open", "chosen", "dismissed"];
93
+ export const youiyouxProposalSchema = z.object({
94
+ $schema: z.string().max(500).optional(),
95
+ schema: z.literal("youi.proposal/v1"),
96
+ id: z.string().regex(PROPOSAL_ID),
97
+ project: text(200),
98
+ route: text(300),
99
+ target: z.object({
100
+ name: text(200), element: text(200).optional(), file: text(500).optional(),
101
+ line: z.int().positive().max(Number.MAX_SAFE_INTEGER).optional(), column: z.int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional(),
102
+ box: z.object({ x: z.number().finite(), y: z.number().finite(), width: z.number().finite().nonnegative(), height: z.number().finite().nonnegative() }),
103
+ page: z.object({ width: z.number().finite().positive(), height: z.number().finite().positive() }).optional(),
104
+ }),
105
+ ask: text(1000),
106
+ author: who,
107
+ createdAt: when,
108
+ updatedAt: when,
109
+ options: z.array(z.object({
110
+ id: z.enum(OPTION_IDS),
111
+ label: text(60),
112
+ why: text(500),
113
+ set: z.array(z.object({ prop: z.string().regex(PROP), value })).max(20),
114
+ text: text(2000).nullable(),
115
+ }).refine(option => option.set.length > 0 || option.text !== null, "an option changes a prop or the text")).min(2).max(4),
116
+ status: z.enum(PROPOSAL_STATUSES),
117
+ choice: z.enum(OPTION_IDS).optional(),
118
+ decidedBy: who.optional(),
119
+ decidedAt: when.optional(),
120
+ request: z.string().regex(REQUEST_ID).optional(),
121
+ for: z.string().regex(REQUEST_ID),
122
+ }).refine(doc => new Set(doc.options.map(option => option.id)).size === doc.options.length, "each option has its own letter")
123
+ .refine(doc => doc.choice === undefined || doc.options.some(option => option.id === doc.choice), "the choice is one of its options");
124
+ export function readYouiyouxProposal(raw) {
125
+ return parseContract(youiyouxProposalSchema, raw);
126
+ }
127
+ /** What Toolroll writes beside a request (`youi.request-task/v1`): the task it became, how that task stands, and, when
128
+ * the console has an address, the task's link and `home`, where a person sees and approves all waiting work. */
129
+ export const YOUIYOUX_TASK_STATUSES = ["queued", "needs-you", "building", "ready", "done", "failed", "stopped"];
130
+ /** A link youiyoux opens: http or https, at most 2,000 characters, with no user name or password in it. */
131
+ export const youiyouxLinkSchema = z.string().max(2000).refine(value => {
132
+ try {
133
+ const url = new URL(value);
134
+ return (url.protocol === "http:" || url.protocol === "https:") && url.username === "" && url.password === "";
135
+ }
136
+ catch {
137
+ return false;
138
+ }
139
+ }, "an http or https address with no credentials");
140
+ export const youiyouxRequestTaskSchema = z.strictObject({
141
+ schema: z.literal("youi.request-task/v1"),
142
+ request: z.string().regex(REQUEST_ID),
143
+ runner: z.literal("Toolroll"),
144
+ task: z.strictObject({ id: z.string().min(1).max(120), url: youiyouxLinkSchema.nullable() }),
145
+ home: youiyouxLinkSchema.optional(),
146
+ status: z.enum(YOUIYOUX_TASK_STATUSES),
147
+ detail: z.string().max(200),
148
+ updatedAt: when,
149
+ });
150
+ export function readYouiyouxRequestTask(raw) {
151
+ return parseContract(youiyouxRequestTaskSchema, raw);
152
+ }
@@ -13,6 +13,7 @@ export declare function previewSetup(store: Store, repo: string, inputs: SetupIn
13
13
  seconds?: never;
14
14
  provider?: never;
15
15
  model?: never;
16
+ models?: never;
16
17
  } | {
17
18
  message?: never;
18
19
  ok: true;
@@ -21,6 +22,13 @@ export declare function previewSetup(store: Store, repo: string, inputs: SetupIn
21
22
  seconds: number;
22
23
  provider: "claude" | "cline" | "codex" | "cursor" | "gemini" | "goose" | "opencode" | "openrouter" | "qwen";
23
24
  model: string;
25
+ models: {
26
+ rows: (import("./model-choices.js").DiffRow & {
27
+ inherited: import("./model-choices.js").Pair | null;
28
+ })[];
29
+ state: string;
30
+ change: string;
31
+ } | null;
24
32
  };
25
33
  export declare function approveSetup(store: Store, repo: string, inputs: SetupInputs, fingerprint: string, by: string, token: string, now: Date): {
26
34
  ok: false;
@@ -30,6 +38,7 @@ export declare function approveSetup(store: Store, repo: string, inputs: SetupIn
30
38
  seconds?: never;
31
39
  provider?: never;
32
40
  model?: never;
41
+ models?: never;
33
42
  } | {
34
43
  ok: true;
35
44
  };
@@ -2,6 +2,8 @@ import { createHash } from "node:crypto";
2
2
  import { hasDisguisedText } from "./decision.js";
3
3
  import { authenticateApprover } from "./scope.js";
4
4
  import { isProviderId, validateSpec } from "./provider.js";
5
+ import { INSTALLATION_SCOPE } from "./agentconfig.js";
6
+ import { ALL_SLOTS, applyModelChoices, DEFAULT_PRESET, presetChange, previewModelChange, rowAt } from "./model-choices.js";
5
7
  export function previewSetup(store, repo, inputs) {
6
8
  if (!isProviderId(inputs.provider))
7
9
  return { ok: false, message: "Choose an installed provider." };
@@ -18,19 +20,36 @@ export function previewSetup(store, repo, inputs) {
18
20
  const seconds = Number(inputs.seconds);
19
21
  if (!/^[1-9][0-9]*$/.test(inputs.seconds) || !Number.isInteger(seconds) || seconds < 1 || seconds > 3600)
20
22
  return { ok: false, message: "Setup timeout must be 1–3600 seconds." };
21
- const fingerprint = createHash("sha256").update(JSON.stringify({ repo, inputs, beforeSetup: store.liveWorktreeSetup(repo), beforeConfig: store.phaseConfig(repo, "build") })).digest("hex");
22
- return { ok: true, fingerprint, command, seconds, provider: inputs.provider, model: inputs.model.trim() };
23
+ const models = installationModels(store);
24
+ const fingerprint = createHash("sha256").update(JSON.stringify({ repo, inputs, beforeSetup: store.liveWorktreeSetup(repo), beforeConfig: store.phaseConfig(repo, "build"),
25
+ ...(models === null ? {} : { models: [models.state, models.change] }) })).digest("hex");
26
+ return { ok: true, fingerprint, command, seconds, provider: inputs.provider, model: inputs.model.trim(), models };
23
27
  }
24
28
  export function approveSetup(store, repo, inputs, fingerprint, by, token, now) {
25
29
  return store.transact(() => {
26
- if (!authenticateApprover(store, by, token, repo).ok)
30
+ const proved = authenticateApprover(store, by, token, repo);
31
+ if (!proved.ok)
27
32
  return { ok: false, message: "Your operator credential is required." };
28
33
  const preview = previewSetup(store, repo, inputs);
29
34
  if (!preview.ok)
30
35
  return preview;
31
36
  if (preview.fingerprint !== fingerprint)
32
37
  return { ok: false, message: "Setup changed. Review it again." };
33
- store.setPhaseConfig(repo, "build", preview.provider, preview.model, by, now);
38
+ if (preview.models !== null) {
39
+ // The installation's models, through Settings → Models' one write path (an instance operator's act). The proof
40
+ // above ran in this same transaction, so the generation read here is the one it was checked against.
41
+ if (!store.isInstanceOperator(by))
42
+ return { ok: false, message: "Models for this installation aren't chosen yet. An instance operator sets them up first." };
43
+ const applied = applyModelChoices(store, { scope: INSTALLATION_SCOPE, change: presetChange(DEFAULT_PRESET), expected: { state: preview.models.state, change: preview.models.change }, strict: true, knownProject: () => false }, { actor: by, generation: store.accountOf(by)?.generation ?? -1, method: proved.method, live: () => true }, now);
44
+ if (!applied.ok)
45
+ return { ok: false, message: applied.problem };
46
+ }
47
+ // The project's own builder only when it differs from what it inherits: a project left on the installation's
48
+ // routine builder keeps following the installation's sizes and presets.
49
+ const inherited = store.phaseConfig(INSTALLATION_SCOPE, "build");
50
+ const same = inherited !== null && inherited.provider === preview.provider && inherited.model === preview.model;
51
+ if (!same || store.phaseConfig(repo, "build") !== null)
52
+ store.setPhaseConfig(repo, "build", preview.provider, preview.model, by, now);
34
53
  if (preview.command === "")
35
54
  store.clearWorktreeSetup(repo, by, now);
36
55
  else
@@ -38,3 +57,11 @@ export function approveSetup(store, repo, inputs, fingerprint, by, token, now) {
38
57
  return { ok: true };
39
58
  });
40
59
  }
60
+ // Last in the file, so the password tripwire's function positions (password-freshness.test.ts) stay as reviewed.
61
+ /** A new installation, nothing chosen yet: setup applies the default preset ("Sized by job") with the project's
62
+ * builder, previewed here row by row, so the first task files on exact models for every size. */
63
+ function installationModels(store) {
64
+ if (!ALL_SLOTS.every(slot => rowAt(store, INSTALLATION_SCOPE, slot) === null))
65
+ return null;
66
+ return previewModelChange(store, INSTALLATION_SCOPE, presetChange(DEFAULT_PRESET));
67
+ }
@@ -28,4 +28,10 @@ export declare function controlSetupHtml(data: {
28
28
  };
29
29
  }): Html;
30
30
  /** `fields` may still carry the session's csrf (a caller's habit): postForm writes the one CSRF field. */
31
- export declare function setupPreviewHtml(inputs: SetupInputs, fields: Record<string, string>): Html;
31
+ export declare function setupPreviewHtml(inputs: SetupInputs, fields: Record<string, string>, models?: {
32
+ label: string;
33
+ rows: {
34
+ role: string;
35
+ model: string;
36
+ }[];
37
+ } | null): Html;
@@ -27,7 +27,8 @@ export function controlSetupHtml(data) {
27
27
  html `<p>Give the agent the project's Toolroll handoff instructions.</p>${postForm("/control/instructions-preview", html `<button>Review instructions</button>`, { hidden: { repo: data.repo } })}`}</section><p><a href="/fleet">Configure planning, repair, and review agents</a> · <a href="/tasks/new">Describe a task</a></p>`;
28
28
  }
29
29
  /** `fields` may still carry the session's csrf (a caller's habit): postForm writes the one CSRF field. */
30
- export function setupPreviewHtml(inputs, fields) {
30
+ export function setupPreviewHtml(inputs, fields, models = null) {
31
31
  const { csrf: _csrf, ...rest } = fields;
32
- return html `<h1>Approve project setup</h1><div class="card"><p>${fields.repo ?? ""}</p><p><strong>Builder:</strong> ${inputs.provider} · ${inputs.model}</p><p><strong>Preparation:</strong></p><pre class="recap">${inputs.command || "No preparation command"}</pre><p>Preparation timeout: ${inputs.seconds} seconds.</p><p>The builder default applies to new tasks. Preparation applies to subsequent runs, including already approved tasks. Existing signed task settings and verification rules remain in force.</p>${postForm("/control/setup-approve", html `${reauth(html `<label>Your password<input type="password" name="token" autocomplete="current-password" required></label>`)}<button>Approve project setup</button>`, { hidden: { ...rest, ...inputs } })}</div>`;
32
+ const chosen = models === null ? "" : html `<p><strong>Models for every project:</strong> ${models.label}</p><ul>${models.rows.map(row => html `<li>${row.role}: <span class="mono">${row.model}</span></li>`)}</ul><p class="meta">Change them later in Settings → Models.</p>`;
33
+ return html `<h1>Approve project setup</h1><div class="card"><p>${fields.repo ?? ""}</p><p><strong>Builder:</strong> ${inputs.provider} · ${inputs.model}</p>${chosen}<p><strong>Preparation:</strong></p><pre class="recap">${inputs.command || "No preparation command"}</pre><p>Preparation timeout: ${inputs.seconds} seconds.</p><p>The builder default applies to new tasks. Preparation applies to subsequent runs, including already approved tasks. Existing signed task settings and verification rules remain in force.</p>${postForm("/control/setup-approve", html `${reauth(html `<label>Your password<input type="password" name="token" autocomplete="current-password" required></label>`)}<button>Approve project setup</button>`, { hidden: { ...rest, ...inputs } })}</div>`;
33
34
  }
@@ -97,7 +97,6 @@ export declare function sqliteLock(file: string, busyTimeoutMs?: number): Databa
97
97
  export declare function lockHeld(file: string): boolean;
98
98
  export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
99
99
  export declare function desktopUpdateStatus(stateDir: string): {
100
- version?: never;
101
100
  active: boolean;
102
101
  phase: string;
103
102
  detail: string;
@@ -111,6 +110,7 @@ export declare function desktopUpdateStatus(stateDir: string): {
111
110
  wasRunning?: never;
112
111
  backupPath?: never;
113
112
  updateId?: never;
113
+ version?: never;
114
114
  buildId?: never;
115
115
  workDir?: never;
116
116
  } | {
@@ -6,6 +6,7 @@
6
6
  * fleet observations on top for people and agents reading the queue. A
7
7
  * diagnosis can go stale; it can never grant a lease.
8
8
  */
9
+ import { type Runner } from "./runner.js";
9
10
  import { type ChatSnapshot, type ReviewRequestOrigin, type ReviewRetryState, type Store, type TaskState } from "./store.js";
10
11
  export declare const DEFAULT_MAX_OPEN_DECISIONS = 5;
11
12
  export type TaskReadinessBlocker = {
@@ -95,6 +96,20 @@ export declare function dispatchRoleFor(store: Store, taskRef: number, now: Date
95
96
  /** The first requirement this task fails, in words, or null. */
96
97
  export declare function missingCapability(store: Store, taskRef: number, dispatchRepo: string | null, now: Date): string | null;
97
98
  /** The read-side lifecycle answer. Project access limits dependency detail, never the readiness gate. */
99
+ /** The builders that may take work in `repo`, as dispatch decides: registered and not retired (`all`), bound to the
100
+ * project (`eligible`; only the assigned one when a task names one), and checking in now (`alive`). The first-run
101
+ * "Builder connected" step reads the same answer, so it is done only when a task there could start. */
102
+ export declare function buildersFor(store: Store, repo: string, now: Date, assignedRunner?: string | null): {
103
+ all: Runner[];
104
+ eligible: Runner[];
105
+ alive: Runner[];
106
+ };
107
+ /** Whether a task in `repo` could start for want of nothing but a person: a live builder bound to it. */
108
+ export declare function builderReadyFor(store: Store, repo: string | null, now: Date): boolean;
109
+ /** The project the first-run "Builder connected" step speaks for, on Chat and the board alike: the open one; else the
110
+ * saved project of the first filed task the viewer may see (All projects); else, only before any task exists, the
111
+ * first managed project. Null when tasks exist but none sits in a project the viewer may see: then nothing says done. */
112
+ export declare function firstRunBuilderRepo(store: Store, selected: string | null, visible: (repo: string | null) => boolean, managed: readonly string[]): string | null;
98
113
  export declare function diagnoseTaskDispatch(store: Store, taskId: string, now: Date, readableRepos?: readonly string[] | null): DispatchDiagnosis | null;
99
114
  export declare function diagnosisIsDispatchable(diagnosis: DispatchDiagnosis | null): boolean;
100
115
  /** Attach the same lifecycle answer to the bounded fleet snapshot used by
package/dist/dispatch.js CHANGED
@@ -160,6 +160,31 @@ function approvedProfile(scope) {
160
160
  return scope.approvedProfile ?? scope.profile ?? null;
161
161
  }
162
162
  /** The read-side lifecycle answer. Project access limits dependency detail, never the readiness gate. */
163
+ /** The builders that may take work in `repo`, as dispatch decides: registered and not retired (`all`), bound to the
164
+ * project (`eligible`; only the assigned one when a task names one), and checking in now (`alive`). The first-run
165
+ * "Builder connected" step reads the same answer, so it is done only when a task there could start. */
166
+ export function buildersFor(store, repo, now, assignedRunner = null) {
167
+ const all = store.listRunners().filter(one => one.retiredAt === null);
168
+ const bound = all.filter(one => one.repos.includes(repo));
169
+ const eligible = assignedRunner === null ? bound : bound.filter(one => one.name === assignedRunner);
170
+ return { all, eligible, alive: eligible.filter(one => isAlive(one, now)) };
171
+ }
172
+ /** Whether a task in `repo` could start for want of nothing but a person: a live builder bound to it. */
173
+ export function builderReadyFor(store, repo, now) {
174
+ return repo !== null && buildersFor(store, repo, now).alive.length > 0;
175
+ }
176
+ /** The project the first-run "Builder connected" step speaks for, on Chat and the board alike: the open one; else the
177
+ * saved project of the first filed task the viewer may see (All projects); else, only before any task exists, the
178
+ * first managed project. Null when tasks exist but none sits in a project the viewer may see: then nothing says done. */
179
+ export function firstRunBuilderRepo(store, selected, visible, managed) {
180
+ if (selected !== null)
181
+ return selected;
182
+ const placed = store.handle.prepare("SELECT repo FROM task_ref WHERE repo IS NOT NULL ORDER BY id").all();
183
+ const first = placed.find(row => visible(row.repo));
184
+ if (first !== undefined)
185
+ return first.repo;
186
+ return store.hasAnyWork() ? null : managed[0] ?? null;
187
+ }
163
188
  export function diagnoseTaskDispatch(store, taskId, now, readableRepos = null) {
164
189
  const task = store.getTask(taskId);
165
190
  const ref = store.lookupRef(taskId);
@@ -271,9 +296,7 @@ export function diagnoseTaskDispatch(store, taskId, now, readableRepos = null) {
271
296
  const gap = missingCapability(store, ref.id, ref.repo, now);
272
297
  if (gap !== null)
273
298
  return answer("missing-requirement", "waiting", "Missing a requirement", gap, { action: "repair-capability", role });
274
- const all = store.listRunners().filter(one => one.retiredAt === null);
275
- const bound = all.filter(one => one.repos.includes(ref.repo));
276
- const eligible = ref.assignedRunner === null ? bound : bound.filter(one => one.name === ref.assignedRunner);
299
+ const { all, eligible, alive } = buildersFor(store, ref.repo, now, ref.assignedRunner);
277
300
  if (eligible.length === 0) {
278
301
  const detail = ref.assignedRunner === null
279
302
  ? all.length === 0
@@ -282,7 +305,6 @@ export function diagnoseTaskDispatch(store, taskId, now, readableRepos = null) {
282
305
  : `This task is assigned to ${ref.assignedRunner}, but that builder is not connected to this project.`;
283
306
  return answer("no-worker-registered", "waiting", "Builder not connected", detail, { action: "start-worker", role });
284
307
  }
285
- const alive = eligible.filter(one => isAlive(one, now));
286
308
  if (alive.length === 0) {
287
309
  return answer("no-worker-online", "waiting", "Builder disconnected", `${eligible.length === 1 ? "The builder for this project has" : "All builders for this project have"} stopped checking in. This task starts automatically when ${eligible.length === 1 ? "it reconnects" : "one reconnects"}.`, { action: "start-worker", role });
288
310
  }
@@ -25,7 +25,7 @@ export type FirstRunAction = {
25
25
  };
26
26
  /** `checking`: the answer is not in yet, so the step is shown neither done nor to do. */
27
27
  export type FirstRunStep = {
28
- key: "agent" | "project" | "task";
28
+ key: "agent" | "project" | "builder" | "task";
29
29
  title: string;
30
30
  done: boolean;
31
31
  action: FirstRunAction | null;
@@ -39,8 +39,11 @@ export type FirstRunFacts = {
39
39
  firstResultAt: string | null;
40
40
  signInCommand?: string;
41
41
  };
42
- /** The three steps, each done or with the one action that does it; null once the first result has arrived. */
42
+ /** The steps (the builder step is added by withBuilderStep), each done or with the one action that does it; null once the first result has arrived. */
43
43
  export declare function firstRunSteps(facts: FirstRunFacts): FirstRunStep[] | null;
44
+ /** The builder step, before the first task: under `toolroll serve` nothing connects a builder, and a task filed then
45
+ * waits forever, so the steps never all read done without one. It leads to Start a builder, which shows the command. */
46
+ export declare function withBuilderStep(steps: FirstRunStep[] | null, connected: boolean): FirstRunStep[] | null;
44
47
  export type OpenIssue = {
45
48
  number: number;
46
49
  title: string;
package/dist/first-run.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The first run: three plain steps to a first result, and three first tasks
2
+ * The first run: four plain steps to a first result, and three first tasks
3
3
  * to try. Everything here is derived from live facts on each render; the
4
4
  * list retires permanently with the first Ready result (the
5
5
  * 'first-success-at' installation fact). A suggestion only drafts words in
@@ -8,6 +8,7 @@
8
8
  import { run } from "./exec.js";
9
9
  import { hardenedGit } from "./teardown-git.js";
10
10
  import { githubIssues } from "./issues.js";
11
+ import { START_BUILDER_HREF } from "./needs-you.js";
11
12
  export const SIGN_IN_COMMAND = "claude auth login";
12
13
  export const SANDBOX_COMMAND = "npx toolroll demo";
13
14
  /** How a person starts on a real project (the demo hands off to this). */
@@ -27,7 +28,7 @@ export function signInCommandFor(states, platform) {
27
28
  return "codex login";
28
29
  return `npm install -g @anthropic-ai/claude-code${platform === "win32" ? ";" : " &&"} ${SIGN_IN_COMMAND}`;
29
30
  }
30
- /** The three steps, each done or with the one action that does it; null once the first result has arrived. */
31
+ /** The steps (the builder step is added by withBuilderStep), each done or with the one action that does it; null once the first result has arrived. */
31
32
  export function firstRunSteps(facts) {
32
33
  if (facts.firstResultAt !== null)
33
34
  return null;
@@ -39,6 +40,16 @@ export function firstRunSteps(facts) {
39
40
  { key: "task", title: "Your first task", done: facts.hasTask, action: facts.hasTask ? null : { kind: "link", label: "New task", href: "/tasks/new" } },
40
41
  ];
41
42
  }
43
+ /** The builder step, before the first task: under `toolroll serve` nothing connects a builder, and a task filed then
44
+ * waits forever, so the steps never all read done without one. It leads to Start a builder, which shows the command. */
45
+ export function withBuilderStep(steps, connected) {
46
+ if (steps === null)
47
+ return null;
48
+ const builder = { key: "builder", title: "Builder connected", done: connected,
49
+ action: connected ? null : { kind: "link", label: "Start a builder", href: START_BUILDER_HREF } };
50
+ const task = steps.findIndex(one => one.key === "task");
51
+ return task < 0 ? [...steps, builder] : [...steps.slice(0, task), builder, ...steps.slice(task)];
52
+ }
42
53
  export const GENERIC_FIRST_TASKS = [
43
54
  { source: "generic", label: "Add a test for an untested function", draft: "Find one function without a test and add a focused test for it." },
44
55
  { source: "generic", label: "Fix a lint warning", draft: "Fix one lint warning without changing behaviour." },
@@ -13,26 +13,32 @@ import { type TriggerIo, type TriggerPass } from "./flow-triggers.js";
13
13
  import { type ReplyWatch, type ReplyWatchIo } from "./flow-replies.js";
14
14
  import { type StepIo, type StepPass } from "./flow-steps.js";
15
15
  import type { Store } from "./store.js";
16
+ import { type Git } from "./landed-cleanup.js";
17
+ import { type YouiyouxPass } from "./youiyoux-intake.js";
16
18
  /** How often the worker moves cards, checks triggers, reads replies and runs steps, builds or not. */
17
19
  export declare const FLOW_EVERY_MS = 15000;
18
20
  /** One pass of a kind for a project at a time in this process: a second one asked for while it runs is skipped
19
21
  * (`idle`), and the next cadence takes up whatever it would have done. Cards are also claimed in the database,
20
22
  * so passes in other processes can't act twice either. */
21
23
  export declare function alone<T>(kind: string, project: string, idle: T, run: () => Promise<T>): Promise<T>;
24
+ /** `youiyouxGit`: the hardened git an options request's result is read with (the plane's own runner; gitOf by default). */
22
25
  export type FlowIo = {
23
26
  triggers: TriggerIo;
24
27
  replies: ReplyWatchIo;
25
28
  steps: StepIo;
26
29
  evidenceRoot?: string;
30
+ youiyouxGit?: Git;
27
31
  };
28
32
  export type FlowHousekeeping = {
29
33
  triggers: TriggerPass;
34
+ youiyoux: YouiyouxPass;
30
35
  replies: ReplyWatch;
31
36
  steps: StepPass;
32
37
  flows: FlowAdvance;
33
38
  };
34
- /** One project's flow pass: triggers (which may start cards), replies (which move waiting cards), steps, then moves.
35
- * `halted` (a stop or paused admission) skips everything that reaches outside; moves still run. */
39
+ /** One project's flow pass: triggers (which may start cards), youiyoux requests (which file tasks that wait for a
40
+ * person, and move an options request's options to youiyoux's hub and a decision there back), replies (which move waiting cards), steps, then moves. `halted` (a stop or paused admission) skips
41
+ * everything that reaches outside; moves still run. */
36
42
  export declare function flowHousekeeping(store: Store, repo: string, clock: () => Date, io: FlowIo, halted: () => boolean): Promise<FlowHousekeeping>;
37
43
  /** Move one project's cards. Synchronous, so two can't overlap in a process; each move is checked against the
38
44
  * card's visit in its own transaction, so a pass in another process can't move a card twice. */
@@ -12,6 +12,8 @@ import { advanceFlows } from "./flow-engine.js";
12
12
  import { runFlowTriggers } from "./flow-triggers.js";
13
13
  import { watchFlowReplies } from "./flow-replies.js";
14
14
  import { runFlowSteps } from "./flow-steps.js";
15
+ import { gitOf } from "./landed-cleanup.js";
16
+ import { NO_YOUIYOUX_PASS, runYouiyoux } from "./youiyoux-intake.js";
15
17
  /** How often the worker moves cards, checks triggers, reads replies and runs steps, builds or not. */
16
18
  export const FLOW_EVERY_MS = 15_000;
17
19
  const running = new Set();
@@ -30,11 +32,22 @@ export async function alone(kind, project, idle, run) {
30
32
  running.delete(key);
31
33
  }
32
34
  }
33
- /** One project's flow pass: triggers (which may start cards), replies (which move waiting cards), steps, then moves.
34
- * `halted` (a stop or paused admission) skips everything that reaches outside; moves still run. */
35
+ const NO_REQUESTS = NO_YOUIYOUX_PASS;
36
+ /** One project's flow pass: triggers (which may start cards), youiyoux requests (which file tasks that wait for a
37
+ * person, and move an options request's options to youiyoux's hub and a decision there back), replies (which move waiting cards), steps, then moves. `halted` (a stop or paused admission) skips
38
+ * everything that reaches outside; moves still run. */
35
39
  export async function flowHousekeeping(store, repo, clock, io, halted) {
36
40
  const triggers = halted() ? { added: 0, checked: 0, problems: [] }
37
41
  : await alone("triggers", repo, { added: 0, checked: 0, problems: [] }, () => runFlowTriggers(store, repo, clock(), io.triggers));
42
+ const youiyoux = halted() ? NO_REQUESTS
43
+ : await alone("youiyoux", repo, NO_REQUESTS, async () => {
44
+ try {
45
+ return await runYouiyoux(store, repo, clock(), { dir: io.triggers.dir, env: process.env, git: io.youiyouxGit ?? gitOf(), evidenceRoot: io.evidenceRoot ?? null });
46
+ }
47
+ catch (error) {
48
+ return { ...NO_REQUESTS, problems: [`youiyoux: ${error instanceof Error ? error.message : "could not read requests"}`] };
49
+ }
50
+ });
38
51
  // One mailbox serves every project, so its reads are one at a time across them all.
39
52
  const replies = halted() ? { read: 0, taken: 0, problem: null }
40
53
  : await alone("replies", "*", { read: 0, taken: 0, problem: null }, () => watchFlowReplies(store, clock(), io.replies));
@@ -42,7 +55,7 @@ export async function flowHousekeeping(store, repo, clock, io, halted) {
42
55
  : await alone("steps", repo, { ran: 0, problems: [] }, () => runFlowSteps(store, repo, clock(), io.steps));
43
56
  // Last, so a step that just finished, or a card a trigger just added, moves now.
44
57
  const flows = moveCards(store, repo, clock(), io.evidenceRoot);
45
- return { triggers, replies, steps, flows };
58
+ return { triggers, youiyoux, replies, steps, flows };
46
59
  }
47
60
  /** Move one project's cards. Synchronous, so two can't overlap in a process; each move is checked against the
48
61
  * card's visit in its own transaction, so a pass in another process can't move a card twice. */
@@ -1,2 +1,11 @@
1
1
  import type { Store } from "./store.js";
2
+ import { type TaskStatus } from "./task-status.js";
3
+ /** A task's status as its page reads it, from the store alone (no evidence files); null when it can't be read. */
4
+ /** `root`: where results' saved evidence is. Without it the receipt is read without its saved checks, so a completion
5
+ * (bound to the receipt read with them) doesn't read as one: a status that must say Complete passes it. */
6
+ export declare function storedTaskStatus(store: Store, taskId: string, repo: string, now: Date, root?: string): {
7
+ status: TaskStatus;
8
+ activeTaskId: string;
9
+ live: ReturnType<Store["getRun"]>;
10
+ } | null;
2
11
  export declare function flowCardTaskLine(store: Store, taskId: string, repo: string, now: Date, secrets?: readonly string[]): string;
@@ -19,14 +19,17 @@ function cardReason(text, fallback, secrets) {
19
19
  return fallback;
20
20
  return clean.length > 100 ? `${clean.slice(0, 99).trimEnd()}…` : clean;
21
21
  }
22
- export function flowCardTaskLine(store, taskId, repo, now, secrets = []) {
22
+ /** A task's status as its page reads it, from the store alone (no evidence files); null when it can't be read. */
23
+ /** `root`: where results' saved evidence is. Without it the receipt is read without its saved checks, so a completion
24
+ * (bound to the receipt read with them) doesn't read as one: a status that must say Complete passes it. */
25
+ export function storedTaskStatus(store, taskId, repo, now, root) {
23
26
  const access = { principal: "operator", repos: [repo], dependencyRepos: [repo] };
24
- const assignment = assignmentOf(store, taskId, now, access);
27
+ const assignment = assignmentOf(store, taskId, now, access, root);
25
28
  if (assignment === null)
26
- return "Task unavailable";
29
+ return null;
27
30
  const work = taskWorkSummaryOf(store, assignment.activeTaskId, now, access);
28
31
  if (work === null)
29
- return "Task unavailable";
32
+ return null;
30
33
  const live = work.liveRunId === null ? null : store.getRun(work.liveRunId);
31
34
  const facts = assignmentStatusFacts(assignment, { work: work.status, planning: live?.role === "planner" });
32
35
  // The canvas reads no evidence files. Keep the machine's recorded check visible
@@ -35,7 +38,13 @@ export function flowCardTaskLine(store, taskId, repo, now, secrets = []) {
35
38
  // With neither, nothing about the check was read here: the line says nothing about it, never a pass and never an ask.
36
39
  if (facts.checks?.status === "unavailable")
37
40
  facts.checks = check === null ? null : { ...facts.checks, status: check.status, exitCode: check.exitCode };
38
- const status = taskStatusOf(facts);
41
+ return { status: taskStatusOf(facts), activeTaskId: assignment.activeTaskId, live };
42
+ }
43
+ export function flowCardTaskLine(store, taskId, repo, now, secrets = []) {
44
+ const read = storedTaskStatus(store, taskId, repo, now);
45
+ if (read === null)
46
+ return "Task unavailable";
47
+ const { status, live } = read;
39
48
  if (status.headline === "Needs you") {
40
49
  const fallback = status.need?.action.label ?? "Open the task";
41
50
  // The question itself is useful; routine approvals are clearer as the short action.
@@ -44,7 +53,7 @@ export function flowCardTaskLine(store, taskId, repo, now, secrets = []) {
44
53
  return `Waiting on you: ${what}`;
45
54
  }
46
55
  if (status.headline === "Building" && live !== null) {
47
- const ref = store.lookupRef(assignment.activeTaskId);
56
+ const ref = store.lookupRef(read.activeTaskId);
48
57
  const checkpoint = store.latestCheckpointForRun(live.id);
49
58
  const progress = buildProgressOf(checkpoint?.taskRef !== ref?.id ? null : checkpoint?.snapshot.milestones.map(one => ({ description: null, state: one.state, note: one.note })));
50
59
  if (progress?.stuck != null)
@@ -51,9 +51,12 @@ export type FolderLookRun = {
51
51
  };
52
52
  /** Starts one look. Production starts a child process; tests pass their own. */
53
53
  export type FolderLookRunner = (request: FolderLookRequest, deadlineMs: number) => FolderLookRun;
54
+ /** `started`: when the process that answered was started, in this console's order of starts. A caller that joined a
55
+ * look already under way gets that look's start, never its own, so an older answer always reads as older. */
54
56
  export type FolderLook<T> = {
55
57
  ok: true;
56
58
  value: T;
59
+ started?: number;
57
60
  } | {
58
61
  ok: false;
59
62
  why: "no-answer" | "failed";
@@ -189,6 +189,8 @@ export const childLook = (request, deadlineMs) => {
189
189
  /** Looks whose process hasn't ended, by request: callers asking the same thing share one; each holds a child slot
190
190
  * until its process is gone. */
191
191
  const inFlight = new Map();
192
+ /** Looks started so far: each look's place in that order is fixed when its process starts. */
193
+ let starts = 0;
192
194
  /** The same request however its paths were spelled: resolved paths, roots in order. */
193
195
  function lookKey(request) {
194
196
  const sorted = (paths) => paths.map(one => resolve(one)).sort();
@@ -215,13 +217,15 @@ export async function lookFolders(request, options = {}) {
215
217
  if (run === undefined) {
216
218
  if (inFlight.size >= FOLDER_LOOK_CHILDREN)
217
219
  return { ok: false, why: "no-answer" };
220
+ let started;
218
221
  try {
219
- run = (options.runner ?? childLook)(request, deadlineMs);
222
+ started = (options.runner ?? childLook)(request, deadlineMs);
220
223
  }
221
224
  catch {
222
225
  return { ok: false, why: "failed" };
223
226
  }
224
- const mine = run;
227
+ const mine = { ...started, started: ++starts };
228
+ run = mine;
225
229
  inFlight.set(key, mine);
226
230
  const forget = () => { if (inFlight.get(key) === mine)
227
231
  inFlight.delete(key); };
@@ -238,7 +242,7 @@ export async function lookFolders(request, options = {}) {
238
242
  const answer = first.value;
239
243
  if (answer === null || typeof answer !== "object" || answer.ok !== true)
240
244
  return { ok: false, why: "failed" };
241
- return { ok: true, value: answer.value };
245
+ return { ok: true, value: answer.value, started: run.started };
242
246
  }
243
247
  finally {
244
248
  clearTimeout(timer);
@@ -287,7 +291,7 @@ export async function lookIdentities(paths, options = {}) {
287
291
  if (row !== null && typeof row === "object" && text(row.path) && (row.real === null || text(row.real)))
288
292
  out.set(row.path, row.real);
289
293
  }
290
- return { ok: true, value: out };
294
+ return { ok: true, value: out, ...(looked.started === undefined ? {} : { started: looked.started }) };
291
295
  }
292
296
  /** A project's own `.mcp.json` (parsed object, or null when absent or not one), read off the thread. */
293
297
  export async function lookProjectMcp(repo, options = {}) {