toolroll 0.9.33 → 0.9.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +7 -0
  3. package/dist/assignment.d.ts +1 -0
  4. package/dist/assignment.js +2 -2
  5. package/dist/browser/workspace.css +1 -1
  6. package/dist/browser/workspace.js +17 -17
  7. package/dist/browser-crew.js +1 -0
  8. package/dist/browser-workspace.d.ts +5 -3
  9. package/dist/builder.js +4 -7
  10. package/dist/chat-acceptance.d.ts +1 -1
  11. package/dist/cli.d.ts +1 -1
  12. package/dist/cli.js +124 -63
  13. package/dist/contracts/contract-test.d.ts +45 -0
  14. package/dist/contracts/contract-test.js +69 -0
  15. package/dist/contracts/contract.d.ts +87 -0
  16. package/dist/contracts/contract.js +308 -0
  17. package/dist/contracts/flow-propose.d.ts +325 -0
  18. package/dist/contracts/flow-propose.js +41 -0
  19. package/dist/contracts/flow.d.ts +19400 -0
  20. package/dist/contracts/flow.js +359 -0
  21. package/dist/contracts/handoff.d.ts +91 -0
  22. package/dist/contracts/handoff.js +82 -0
  23. package/dist/contracts/plan.d.ts +58 -0
  24. package/dist/contracts/plan.js +67 -0
  25. package/dist/contracts/proof.d.ts +136 -0
  26. package/dist/contracts/proof.js +110 -0
  27. package/dist/contracts/verification-receipt.d.ts +148 -0
  28. package/dist/contracts/verification-receipt.js +87 -0
  29. package/dist/demo.js +28 -11
  30. package/dist/desktop-update.d.ts +1 -1
  31. package/dist/evidence.d.ts +3 -40
  32. package/dist/evidence.js +7 -3
  33. package/dist/flow-gallery.d.ts +3 -2
  34. package/dist/flow-share.d.ts +10 -32
  35. package/dist/flow-share.js +65 -83
  36. package/dist/flow-starters.d.ts +2 -1
  37. package/dist/flow-triggers.d.ts +15 -72
  38. package/dist/flow-triggers.js +94 -83
  39. package/dist/flows.d.ts +37 -218
  40. package/dist/flows.js +350 -385
  41. package/dist/mate-tools.js +14 -44
  42. package/dist/mcp-connect.d.ts +3 -0
  43. package/dist/mcp-connect.js +16 -4
  44. package/dist/operate.js +83 -12
  45. package/dist/plan.d.ts +12 -12
  46. package/dist/plan.js +109 -72
  47. package/dist/planner-source.js +2 -1
  48. package/dist/planner.js +55 -25
  49. package/dist/process-recovery-eligibility.d.ts +2 -2
  50. package/dist/process-recovery-eligibility.js +13 -8
  51. package/dist/project-admission.d.ts +93 -0
  52. package/dist/project-admission.js +134 -0
  53. package/dist/project-delete-ui.js +5 -2
  54. package/dist/proof.d.ts +15 -91
  55. package/dist/proof.js +90 -249
  56. package/dist/result-evidence-readers.d.ts +42 -0
  57. package/dist/result-evidence-readers.js +91 -0
  58. package/dist/runner.d.ts +10 -0
  59. package/dist/runner.js +16 -0
  60. package/dist/scope.js +4 -3
  61. package/dist/serve.d.ts +4 -0
  62. package/dist/serve.js +112 -160
  63. package/dist/setup-guide.d.ts +2 -2
  64. package/dist/store.d.ts +2 -0
  65. package/dist/store.js +4 -0
  66. package/dist/surface.js +16 -3
  67. package/dist/task-review-brief.d.ts +75 -0
  68. package/dist/task-review-brief.js +179 -0
  69. package/dist/text-limits.d.ts +87 -0
  70. package/dist/text-limits.js +86 -0
  71. package/dist/verification-evidence.js +24 -12
  72. package/dist/when-html.d.ts +2 -0
  73. package/dist/when-html.js +13 -0
  74. package/package.json +3 -2
package/dist/serve.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { SCREENSHOT_CAPTURE, structuredHandoffView, terminalDiffView } from "./result-evidence-readers.js";
1
2
  import { checkLeadIdentity, leadIdentityOf, leadNameOf, LEAD_NAME_MAX, LEAD_PERSONA_MAX } from "./lead-identity.js";
2
3
  import { aboutYouOf, checkAboutYou, saveAboutYou, ABOUT_YOU_LINE_MAX, ABOUT_YOU_MAX_LINES } from "./lead-about.js";
3
4
  import { withActor } from "./actor.js";
@@ -53,7 +54,7 @@ import { FORM_PATH, flowFormPage, receiveFlowForm, shareFlowButton, stopSharingF
53
54
  import { addFlowTriggerTo, checkFlowTriggerNow, HOOK_PATH, pressFlowButton, receiveFlowHook, removeFlowTrigger, renewFlowHook, removeLinearKey, saveHooksBase, saveLinearKey, saveLinearSigningSecret } from "./flow-triggers.js";
54
55
  import { addCardToFlow, advanceFlows, cancelFlowCard, crossProjectProblem, decideFlowCard, FLOW_HREF, flowDefinitionOf, moveCardInFlow } from "./flow-engine.js";
55
56
  import { chooseFlowCard } from "./flow-send.js";
56
- import { FLOW_TEMPLATES, validateFlowDefinition } from "./flows.js";
57
+ import { FlowContractError, FLOW_TEMPLATES, validateFlowDefinition, withZoneNames } from "./flows.js";
57
58
  import { TOOL_CATALOG, addToolTo, catalogTool, discoverTools, localAppOf, projectToolsOf, removeToolFrom, secretsSetFor, setToolSecret, splitCommandLine, testToolOf } from "./project-tools.js";
58
59
  import { changeLearning, learningView } from "./project-learning.js";
59
60
  import { applySavedKnowledge, changeKnowledge, knowledgeView, knowledgeVersion, readKnowledgeSnapshot } from "./project-knowledge.js";
@@ -178,12 +179,13 @@ import { parseReport } from "./scout-report.js";
178
179
  import { buildDataDocument, composeRequest, credentialKeyOf, isDirectChatProvider, isSubscriptionChatProvider, parseAssistantEnvelope, performChatRequest, priceOf, priceForConfig, worstCaseForPrice, settleForPrice, subscriptionCredentialKey, fetchOpenRouterCatalog, plausibleChatKey, PRICED_MODELS, settleMicrousd, worstCaseMicrousd, CHAT_KEY_ENV, TURN_WALL_CLOCK_MS, } from "./converse.js";
179
180
  import { fileTaskProposal, fileRoutineProposal } from "./proposal.js";
180
181
  import { TASK_TEXT_LIMITS, validateTaskText } from "./task-text.js";
182
+ import { TEXT_LIMITS } from "./text-limits.js";
181
183
  import {} from "./store.js";
182
184
  import { attendedLivenessState } from "./liveness.js";
183
185
  import { approvalOf, approve as approveScope, attendedDigestOf, attendedTermsJson, authenticateApprover as checkApproverPassword, canonicalProfileJson, chainFromJson, profileDigestOf, proposeGuarded, scopeAuthorityOf, acceptanceLinesToInput, acceptanceToLines, acceptanceWords, parseAcceptanceCriteria, } from "./scope.js";
184
186
  import { isQualityMode, qualityModeTitle } from "./quality.js";
185
187
  import { LIMITS, hasForbiddenControls, validateNote } from "./decision.js";
186
- import { parseExecutionPlanDocument, PLAN_LIMITS, milestonesOf } from "./plan.js";
188
+ import { parseExecutionPlanDocument, milestonesOf } from "./plan.js";
187
189
  import { contractChangesOf, decodePlanContractRecord, describeContractChanges } from "./planner-source.js";
188
190
  import { observeWorktree, parseBaseTreeSnapshot, aggregateNewNames, PEEK_LIMITS } from "./peek.js";
189
191
  import { readLiveWindow } from "./live.js";
@@ -194,7 +196,8 @@ import { parseGithubRepo, previewGithubRepo, cloneGithubRepo, listGithubRepos, i
194
196
  import { verifiedAuthor, LEAD_THREAD, isDigestTime, RESULT_SCREENSHOTS, MATE_ASK_OTHER } from "./store.js";
195
197
  import { RESULT_SHOT_CHOICES } from "./result-shots.js";
196
198
  import { digestTimes } from "./digest-times.js";
197
- import { updateRepos, addRepos, removeRepos } from "./repos.js";
199
+ import { updateRepos, removeRepos } from "./repos.js";
200
+ import { admitProject, releaseProject } from "./project-admission.js";
198
201
  import { run as execRun } from "./exec.js";
199
202
  import { findFirstTasks, firstResultWords, firstRunSteps, firstTaskJourney, firstTaskSuggestions, HOW_IT_WORKS, SANDBOX_COMMAND, signInCommandFor, START_COMMAND } from "./first-run.js";
200
203
  /** A user agent, reduced to safe display words — never echoed raw. */
@@ -276,6 +279,8 @@ import { PACKAGE_VERSION } from "./version.js";
276
279
  import { updateNoticeWords } from "./update-notice.js";
277
280
  import { whenHtml } from "./when-html.js";
278
281
  const SESSION_COOKIE = "standing-orders_session";
282
+ /** Stands in for "no project" in a ceiling: no folder resolves to it, so it admits nothing. */
283
+ const NO_PROJECT = "/\0no-project";
279
284
  /** Where `up`'s one-time sign-in link points, and how long it works. */
280
285
  export const SIGN_IN_LINK_PATH = "/login/once/";
281
286
  export const SIGN_IN_LINK_MS = 10 * 60_000;
@@ -632,7 +637,13 @@ export function createDecisionServer(options) {
632
637
  /** No ceiling configured at all: the legacy trust-everything mode, named. */
633
638
  const unscopedMode = ceiling.repos.length === 0 && ceiling.roots.length === 0;
634
639
  /** Per-row visibility under the ceiling — the authorization question for reads. */
635
- const liveCeiling = () => ({ repos: [...ceiling.repos, ...(options.additionalProjectRepos?.() ?? [])], roots: ceiling.roots });
640
+ /** The exact repositories this console serves now: `up`'s live admitted set when it supplies one, else startup's. */
641
+ const exactRepos = () => options.admittedRepos?.() ?? ceiling.repos;
642
+ const liveCeiling = () => {
643
+ const repos = [...exactRepos(), ...(options.additionalProjectRepos?.() ?? [])];
644
+ // An admitted set that is empty for now is an empty ceiling, never the legacy unscoped mode.
645
+ return { repos: repos.length === 0 && ceiling.roots.length === 0 && !unscopedMode ? [NO_PROJECT] : repos, roots: ceiling.roots };
646
+ };
636
647
  // Native tools share the installation's login and can read beyond a project.
637
648
  // Account project membership cannot provide an OS read boundary.
638
649
  function codingActorAllowed(actor) {
@@ -651,12 +662,21 @@ export function createDecisionServer(options) {
651
662
  * ceiling; this server still filters every row through its own ceiling. */
652
663
  /** Projects deleted while this console runs: gone from its lists though a builder started with them still watches. */
653
664
  const deletedRepos = new Set();
665
+ /** Those still gone: a project added again (from here, `repos add`, or the lead) has its row back and shows again. */
666
+ const goneRepos = () => {
667
+ if (deletedRepos.size === 0)
668
+ return deletedRepos;
669
+ for (const one of store.listProjects())
670
+ deletedRepos.delete(one.path);
671
+ return deletedRepos;
672
+ };
654
673
  const managedRepos = () => {
655
674
  const seen = new Set();
656
675
  const repos = [];
657
- for (const path of [...ceiling.repos, ...(options.currentRepos?.() ?? [])]) {
676
+ const gone = goneRepos();
677
+ for (const path of [...exactRepos(), ...(options.currentRepos?.() ?? [])]) {
658
678
  const canonical = canonicalProject(path) ?? path;
659
- if (seen.has(canonical) || !visible(canonical) || deletedRepos.has(canonical))
679
+ if (seen.has(canonical) || !visible(canonical) || gone.has(canonical))
660
680
  continue;
661
681
  seen.add(canonical);
662
682
  repos.push(canonical);
@@ -761,6 +781,13 @@ export function createDecisionServer(options) {
761
781
  const admissionList = () => restricted() ? [...(store.accountOf(requestContext.getStore().actor)?.projects ?? [])].filter(visible) : unscopedMode
762
782
  ? null
763
783
  : [...new Set([...managedRepos(), ...(ceiling.roots.length === 0 ? [] : store.knownRepos().filter(visible))])];
784
+ /** Every project this person may pick in the console — the switcher, Projects, Settings, Tools and Flows read this
785
+ * one list: the live admitted set (taskless projects included) and every project with saved work, each through
786
+ * the ceiling and this account's project grants. A project removed or deleted here is gone from it. */
787
+ const consoleProjects = () => {
788
+ const gone = goneRepos();
789
+ return [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(one => visible(one) && !gone.has(one));
790
+ };
764
791
  /** The task behind a resource, for the ceiling check; null = no ref (visible). */
765
792
  const taskRepoOf = (taskRef) => store.refForId(taskRef)?.repo ?? null;
766
793
  const server = createServer((request, response) => {
@@ -1110,6 +1137,8 @@ export function createDecisionServer(options) {
1110
1137
  response.end(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="refresh" content="0;url=${escape(to)}"><title>Toolroll</title>${HANDOFF_STYLE}<p>${escape(words)} <a href="${escape(to)}">Back to Toolroll</a></p>`);
1111
1138
  };
1112
1139
  const state = url.searchParams.get("state") ?? "";
1140
+ // Every arrival is logged (never the code or state): a sign-in that never came back is then visible as an absence.
1141
+ logEvent("info", "connect.callback", { error: url.searchParams.get("error"), known: connectVisits.has(state) });
1113
1142
  if (!startedHere(request, state))
1114
1143
  return done("/settings/tools", "problem", "That sign-in was started in another browser. Connect again from this one.");
1115
1144
  const visit = connectVisits.get(state);
@@ -2519,7 +2548,7 @@ export function createDecisionServer(options) {
2519
2548
  return void response.end(spendCsv(items, teammateNames));
2520
2549
  }
2521
2550
  const shift = (by) => { const at = new Date(`${month.name}-01T00:00:00.000Z`); at.setUTCMonth(at.getUTCMonth() + by); return at.toISOString().slice(0, 7); };
2522
- const projects = [...new Set([...managedRepos(), ...store.knownRepos()])].filter(visible);
2551
+ const projects = consoleProjects();
2523
2552
  const people = store.accountFacts().filter(one => one.revokedAt === null).map(one => one.name);
2524
2553
  const view = {
2525
2554
  month: month.name, previous: shift(-1), next: month.name === current.name ? null : shift(1),
@@ -2535,7 +2564,7 @@ export function createDecisionServer(options) {
2535
2564
  }
2536
2565
  if (url.pathname === "/ledger") {
2537
2566
  const admitted = admissionList();
2538
- const projects = admitted ?? [...new Set([...managedRepos(), ...store.knownRepos()])].filter(visible);
2567
+ const projects = admitted ?? consoleProjects();
2539
2568
  const chosen = url.searchParams.get("project") ?? "";
2540
2569
  if (chosen !== "" && !projects.includes(chosen))
2541
2570
  return refuse(response, who, 403, "That project is outside your access.", "/ledger");
@@ -2694,7 +2723,7 @@ export function createDecisionServer(options) {
2694
2723
  return seen;
2695
2724
  };
2696
2725
  const csrf = who.via === "cookie" ? who.session.csrf : "";
2697
- const projectChoices = [...new Set([...managedRepos(), ...store.knownRepos()])].filter(visible);
2726
+ const projectChoices = consoleProjects();
2698
2727
  const accessFields = (selected) => `<label>Project access<select name="access"><option value="selected"${selected !== null ? " selected" : ""}>Selected projects</option><option value="all"${selected === null ? " selected" : ""}>All projects</option></select></label>` +
2699
2728
  `<p class="meta">Operators with all-project access also manage the instance.</p><fieldset><legend>Projects</legend>${projectChoices.length === 0 ? `<p class="meta">Add a project before granting selected access.</p>` : projectChoices.map(repo => `<label class="row"><input type="checkbox" name="projects" value="${escape(repo)}"${selected?.includes(repo) ? " checked" : ""}>${escape(projectName(repo))}</label>`).join("")}</fieldset>`;
2700
2729
  const cards = accounts.map(one => {
@@ -3192,7 +3221,7 @@ export function createDecisionServer(options) {
3192
3221
  }
3193
3222
  // Flows → New: the gallery, and each template's page.
3194
3223
  if (url.pathname === "/flows/new") {
3195
- const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
3224
+ const projects = consoleProjects();
3196
3225
  const canUse = who.via === "cookie" && who.role === "approver" && projects.length > 0;
3197
3226
  // The chosen project, else the one being looked at: its tools are what each card says is connected.
3198
3227
  const asked = url.searchParams.get("repo");
@@ -3202,7 +3231,7 @@ export function createDecisionServer(options) {
3202
3231
  const galleryRead = /^\/flows\/new\/([a-z-]{1,40})$/.exec(url.pathname);
3203
3232
  if (galleryRead !== null) {
3204
3233
  const template = galleryTemplateOf(galleryRead[1]);
3205
- const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
3234
+ const projects = consoleProjects();
3206
3235
  if (template === null)
3207
3236
  return refuse(response, who, 404, "There's no template by that name.", "/flows/new");
3208
3237
  if (who.via !== "cookie" || who.role !== "approver")
@@ -3216,13 +3245,13 @@ export function createDecisionServer(options) {
3216
3245
  return sendGalleryUse(response, who, template, projects, repo, null, template.name, problem === null ? null : problem.slice(0, 400), said === null ? null : said.slice(0, 400));
3217
3246
  }
3218
3247
  if (url.pathname === "/flows") {
3219
- const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
3248
+ const projects = consoleProjects();
3220
3249
  const flows = store.listFlows(projects);
3221
3250
  return sendScreen(response, 200, screen("Flows", `<h1>Flows</h1>${flowsListHtml(store, flows, projects, who.via === "cookie" ? who.session.csrf : "", who.via === "cookie" && who.role === "approver", url.searchParams.get("problem"))}`, { chrome: chromeFor(project, "flows"), functional: { script: FLOW_IMPORT_SCRIPT } }));
3222
3251
  }
3223
3252
  // starter kits — the gallery, and each kit's checklist in a project.
3224
3253
  if (url.pathname === "/kits" || /^\/kits\/[a-z-]{1,40}$/.test(url.pathname)) {
3225
- const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
3254
+ const projects = consoleProjects();
3226
3255
  const csrf = who.via === "cookie" ? who.session.csrf : "", canSetUp = who.via === "cookie" && who.role === "approver";
3227
3256
  if (url.pathname === "/kits") {
3228
3257
  return sendScreen(response, 200, screen("Starter kits", `<h1>Starter kits</h1>${kitsGalleryHtml(store, projects, projectName, csrf, canSetUp, { problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "flows") }));
@@ -3235,7 +3264,7 @@ export function createDecisionServer(options) {
3235
3264
  }
3236
3265
  // v92: AI teammates — the team, and one page per teammate.
3237
3266
  if (url.pathname === "/teammates") {
3238
- const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
3267
+ const projects = consoleProjects();
3239
3268
  return sendScreen(response, 200, screen("Teammates", `<h1>Teammates</h1>${teammatesListHtml(store, store.teammates(projects), projects, projectName, who.via === "cookie" ? who.session.csrf : "", who.via === "cookie" && who.role === "approver", { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "flows") }));
3240
3269
  }
3241
3270
  const teammateRead = /^\/teammates\/([1-9][0-9]{0,9})(\/soul\.md)?$/.exec(url.pathname);
@@ -3307,7 +3336,7 @@ export function createDecisionServer(options) {
3307
3336
  if (flow === null || flow.state !== "active" || !visible(flow.repo))
3308
3337
  return refuse(response, who, 404, "No such flow in your projects.", "/flows");
3309
3338
  const selected = Number(url.searchParams.get("card")), start = Number(url.searchParams.get("start"));
3310
- const view = flowView(store, flow, { name: who.name, approver: who.via === "cookie" && who.role === "approver" }, Number.isSafeInteger(selected) && selected > 0 ? selected : null, { dir: options.configDir ?? null, repos: [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible), startTrigger: Number.isSafeInteger(start) && start > 0 ? start : null,
3339
+ const view = flowView(store, flow, { name: who.name, approver: who.via === "cookie" && who.role === "approver" }, Number.isSafeInteger(selected) && selected > 0 ? selected : null, { dir: options.configDir ?? null, repos: consoleProjects(), startTrigger: Number.isSafeInteger(start) && start > 0 ? start : null,
3311
3340
  sortReady: keyStatus("openrouter", providerHome).set, toolHome });
3312
3341
  if (url.searchParams.get("format") === "json")
3313
3342
  return respond(response, 200, "application/json; charset=utf-8", JSON.stringify(view));
@@ -3886,7 +3915,7 @@ export function createDecisionServer(options) {
3886
3915
  return sendScreen(response, 200, screen("Models", `<p><a href="/settings">Settings</a></p><h1>Models</h1>${modelsHtml(view)}`, { chrome: chromeFor(project, "settings"), functional: { script: modelsScript() } }));
3887
3916
  }
3888
3917
  if (url.pathname === "/settings/skills") {
3889
- const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
3918
+ const projects = consoleProjects();
3890
3919
  const chosen = url.searchParams.get("repo") ?? project ?? projects[0] ?? "";
3891
3920
  if (chosen && !projects.includes(chosen))
3892
3921
  return refuse(response, who, 403, "That project is outside your access.", "/projects");
@@ -3904,7 +3933,7 @@ export function createDecisionServer(options) {
3904
3933
  return sendScreen(response, 200, screen('Skills', `<p><a href="/settings">Settings</a></p><h1>Skills</h1>${url.searchParams.get('saved') === '1' ? '<p role="status">Saved.</p>' : ''}${selector}${content}`, { chrome: chromeFor(chosen || project, 'settings'), functional: { script: skillsScript() } }));
3905
3934
  }
3906
3935
  if (url.pathname === "/settings/tools") {
3907
- const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
3936
+ const projects = consoleProjects();
3908
3937
  const chosen = url.searchParams.get("repo") ?? project ?? projects[0] ?? "";
3909
3938
  if (chosen && !projects.includes(chosen))
3910
3939
  return refuse(response, who, 403, "That project is outside your access.", "/projects");
@@ -3928,7 +3957,7 @@ export function createDecisionServer(options) {
3928
3957
  }
3929
3958
  // Settings → Project: what Toolroll holds for a project; an instance operator deletes it here.
3930
3959
  if (url.pathname === "/settings/project") {
3931
- const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible).filter(one => !deletedRepos.has(one));
3960
+ const projects = consoleProjects();
3932
3961
  const asked = url.searchParams.get("repo");
3933
3962
  const chosen = asked ?? (project != null && projects.includes(project) ? project : projects[0] ?? "");
3934
3963
  if (chosen && !projects.includes(chosen))
@@ -3940,7 +3969,7 @@ export function createDecisionServer(options) {
3940
3969
  }
3941
3970
  // v102: Settings → Approval rules, a project's separation of duties. Anyone who sees the project reads them; an instance operator sets them.
3942
3971
  if (url.pathname === "/settings/approval") {
3943
- const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
3972
+ const projects = consoleProjects();
3944
3973
  const chosen = url.searchParams.get("repo") ?? project ?? projects[0] ?? "";
3945
3974
  if (chosen && !projects.includes(chosen))
3946
3975
  return refuse(response, who, 403, "That project is outside your access.", "/settings/approval");
@@ -3951,7 +3980,7 @@ export function createDecisionServer(options) {
3951
3980
  }
3952
3981
  // Sprint 8: Settings → Policy, the organisation policy and its history. Anyone signed in reads it; an instance operator changes it.
3953
3982
  if (url.pathname === "/settings/policy") {
3954
- const repos = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
3983
+ const repos = consoleProjects();
3955
3984
  const toolNames = [...new Set(repos.flatMap(repo => store.projectTools(repo).map(one => one.name)))].sort();
3956
3985
  const view = { policy: store.orgPolicy(), history: store.policyHistory(50), canChange: who.via === "cookie" && store.isInstanceOperator(who.name), toolNames };
3957
3986
  return sendScreen(response, 200, screen("Policy", `<p><a href="/settings">Settings</a></p><h1>Policy</h1>${policyHtml(view, who.via === "cookie" ? who.session.csrf : "", { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
@@ -3969,7 +3998,7 @@ export function createDecisionServer(options) {
3969
3998
  // Settings → Flows: the starter flows, each switched on with one yes. ?starter= marks the one a task's
3970
3999
  // "Do this every time…" asked about.
3971
4000
  if (url.pathname === "/settings/flows") {
3972
- const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
4001
+ const projects = consoleProjects();
3973
4002
  const chosen = url.searchParams.get("repo") ?? project ?? projects[0] ?? "";
3974
4003
  if (chosen && !projects.includes(chosen))
3975
4004
  return refuse(response, who, 403, "That project is outside your access.", "/settings");
@@ -4093,13 +4122,13 @@ export function createDecisionServer(options) {
4093
4122
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || !options.configDir)
4094
4123
  return refuse(response, who, 403, "An instance operator sets up sign-in.", "/settings");
4095
4124
  const origin = consoleOrigin(request.headers.host);
4096
- const projects = [...new Set([...managedRepos(), ...store.knownRepos()])].filter(visible).map(path => ({ path, name: projectName(path) }));
4125
+ const projects = consoleProjects().map(path => ({ path, name: projectName(path) }));
4097
4126
  const settings = ssoSettings();
4098
4127
  const html = ssoSettingsHtml({ settings, redirect: origin === null ? null : `${origin}${SSO_CALLBACK}`, projects, linked: settings !== null && store.ssoIdentitiesOf(who.name).some(one => one.issuer.replace(/\/+$/, "") === settings.issuer) }, who.session.csrf, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") });
4099
4128
  return sendScreen(response, 200, screen("Sign-in", `<p><a href="/settings">Settings</a></p><h1>Sign-in</h1>${html}`, { chrome: chromeFor(project, "settings") }));
4100
4129
  }
4101
4130
  if (url.pathname === "/settings/knowledge") {
4102
- const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
4131
+ const projects = consoleProjects();
4103
4132
  const chosen = url.searchParams.get("repo") ?? project ?? projects[0] ?? "";
4104
4133
  if (chosen && (!projects.includes(chosen) || !visible(chosen)))
4105
4134
  return refuse(response, who, 403, "That project is outside your access.", "/settings/knowledge");
@@ -4137,7 +4166,7 @@ export function createDecisionServer(options) {
4137
4166
  return sendScreen(response, 200, screen('Knowledge', `<p><a href="/settings">Settings</a></p><h1>Knowledge</h1>${url.searchParams.get('saved') === '1' ? '<p role="status">Saved for future tasks.</p>' : ''}${selector}${content}`, { chrome: chromeFor(chosen || project, 'settings') }));
4138
4167
  }
4139
4168
  if (url.pathname === "/settings/learning") {
4140
- const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
4169
+ const projects = consoleProjects();
4141
4170
  const chosen = url.searchParams.get("repo") ?? project ?? projects[0] ?? "";
4142
4171
  if (chosen && (!projects.includes(chosen) || !visible(chosen)))
4143
4172
  return refuse(response, who, 403, "That project is outside your access.", "/settings/learning");
@@ -5231,7 +5260,7 @@ export function createDecisionServer(options) {
5231
5260
  ...store.listProjects().map(one => ({ path: one.path, name: one.name })),
5232
5261
  ...managedRepos().map(path => ({ path, name: projectName(path) })),
5233
5262
  ]) {
5234
- if (seen.has(one.path) || !visible(one.path))
5263
+ if (seen.has(one.path) || !visible(one.path) || goneRepos().has(one.path))
5235
5264
  continue;
5236
5265
  seen.add(one.path);
5237
5266
  rows.push(one);
@@ -5475,7 +5504,7 @@ export function createDecisionServer(options) {
5475
5504
  const recent = store.listProjects().filter(one => visible(one.path));
5476
5505
  const recentPaths = new Set(recent.map(one => one.path));
5477
5506
  const candidates = new Set();
5478
- for (const path of [...managedRepos(), ...store.knownRepos()]) {
5507
+ for (const path of consoleProjects()) {
5479
5508
  const canonical = canonicalProject(path) ?? path;
5480
5509
  if (!recentPaths.has(canonical) && visible(canonical))
5481
5510
  candidates.add(canonical);
@@ -6765,7 +6794,7 @@ export function createDecisionServer(options) {
6765
6794
  if (who.via !== 'cookie' || who.role !== 'approver')
6766
6795
  return refuse(response, who, 403, 'Sign in as an approver to manage skills.', '/settings/skills');
6767
6796
  const repo = body.get('repo') ?? '', back = `/settings/skills?repo=${encodeURIComponent(repo)}`;
6768
- if (!visible(repo) || ![...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()].includes(repo))
6797
+ if (!visible(repo) || !consoleProjects().includes(repo))
6769
6798
  return refuse(response, who, 403, 'That project is outside your access.', '/projects');
6770
6799
  if ([...new Set(body.keys())].some(k => body.getAll(k).length !== 1))
6771
6800
  return refuse(response, who, 400, 'Invalid skills form.', back);
@@ -6821,7 +6850,7 @@ export function createDecisionServer(options) {
6821
6850
  if (who.via !== "cookie" || who.role !== "approver")
6822
6851
  return refuse(response, who, 403, "Sign in as an approver to set kits up.", "/kits");
6823
6852
  const kit = kitOf(kitPost[1]);
6824
- const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
6853
+ const projects = consoleProjects();
6825
6854
  const repo = body.get("repo") ?? "";
6826
6855
  if (kit === null || !projects.includes(repo) || !store.accountCanAccess(who.name, repo))
6827
6856
  return redirect(response, `/kits?problem=${encodeURIComponent("Choose one of your projects.")}`);
@@ -6866,7 +6895,7 @@ export function createDecisionServer(options) {
6866
6895
  if (who.role !== "approver")
6867
6896
  return refuse(response, who, 403, "Sign in as an approver to look after teammates.", "/teammates");
6868
6897
  if (teammatePost[2] === "new") {
6869
- const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
6898
+ const projects = consoleProjects();
6870
6899
  const repo = body.get("repo") ?? "";
6871
6900
  if (!projects.includes(repo))
6872
6901
  return redirect(response, `/teammates?problem=${encodeURIComponent("Choose one of your projects.")}`);
@@ -6938,7 +6967,7 @@ export function createDecisionServer(options) {
6938
6967
  const galleryPost = /^\/flows\/new\/([a-z-]{1,40})$/.exec(url.pathname);
6939
6968
  if (galleryPost !== null) {
6940
6969
  const template = galleryTemplateOf(galleryPost[1]);
6941
- const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
6970
+ const projects = consoleProjects();
6942
6971
  if (template === null)
6943
6972
  return refuse(response, who, 404, "There's no template by that name.", "/flows/new");
6944
6973
  if (who.via !== "cookie" || who.role !== "approver" || store.isDemo())
@@ -6970,7 +6999,7 @@ export function createDecisionServer(options) {
6970
6999
  if (url.pathname === "/flows/new" || url.pathname === "/flows/example" || url.pathname === "/flows/import" || flowPost !== null || triggerPost !== null) {
6971
7000
  const now = clock();
6972
7001
  const answer = (status, payload) => respond(response, status, "application/json; charset=utf-8", JSON.stringify(payload));
6973
- const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
7002
+ const projects = consoleProjects();
6974
7003
  if (who.via !== "cookie" || who.role !== "approver")
6975
7004
  return url.pathname === "/flows/new" || url.pathname === "/flows/example" || url.pathname === "/flows/import" ? refuse(response, who, 403, "Sign in as an approver to create flows.", "/flows") : answer(403, { ok: false, said: "Sign in as an approver to change flows." });
6976
7005
  if (url.pathname === "/flows/example") {
@@ -7153,12 +7182,13 @@ export function createDecisionServer(options) {
7153
7182
  return redirect(response, "/flows");
7154
7183
  }
7155
7184
  if (action === "save") {
7156
- let saved;
7185
+ let saved, drawn;
7157
7186
  try {
7158
- saved = validateFlowDefinition(JSON.parse(body.get("definition") ?? "null"));
7187
+ drawn = JSON.parse(body.get("definition") ?? "null");
7188
+ saved = validateFlowDefinition(drawn);
7159
7189
  }
7160
7190
  catch (error) {
7161
- return answer(400, { ok: false, said: error instanceof SyntaxError ? "That flow couldn't be read." : error instanceof Error ? error.message : "That flow isn't valid." });
7191
+ return answer(400, { ok: false, said: error instanceof SyntaxError ? "That flow couldn't be read." : error instanceof FlowContractError ? withZoneNames(error.lines, drawn) : error instanceof Error ? error.message : "That flow isn't valid." });
7162
7192
  }
7163
7193
  const name = (body.get("name") ?? flow.name).replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, 80) || flow.name;
7164
7194
  // The owner (v86) is whom "the owner decides" zones ask: someone who can approve on this project.
@@ -7244,7 +7274,7 @@ export function createDecisionServer(options) {
7244
7274
  return back("problem", "Choose what the budget is for.");
7245
7275
  const scope = target[1], key = target[2];
7246
7276
  const known = scope === "installation" ? key === "*"
7247
- : scope === "project" ? [...new Set([...managedRepos(), ...store.knownRepos()])].filter(visible).includes(key)
7277
+ : scope === "project" ? consoleProjects().includes(key)
7248
7278
  : scope === "person" ? store.accountFacts().some(one => one.name === key && one.revokedAt === null)
7249
7279
  : store.teammates([...new Set([...managedRepos(), ...store.knownRepos()])]).some(one => String(one.id) === key);
7250
7280
  if (!known)
@@ -7266,7 +7296,7 @@ export function createDecisionServer(options) {
7266
7296
  // Settings → Project → Builds at once: an approver for the project sets it; the ledger keeps before → after.
7267
7297
  if (url.pathname === "/settings/project/concurrency") {
7268
7298
  const repo = body.get("repo") ?? "";
7269
- const known = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
7299
+ const known = consoleProjects();
7270
7300
  if (!known.includes(repo))
7271
7301
  return refuse(response, who, 403, "That project is outside your access.", "/settings/project");
7272
7302
  if (who.via !== "cookie" || who.role !== "approver" || !store.accountCanAccess(who.name, repo))
@@ -7287,7 +7317,7 @@ export function createDecisionServer(options) {
7287
7317
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
7288
7318
  return refuse(response, who, 403, "An instance operator deletes projects.", "/settings/project");
7289
7319
  const repo = body.get("repo") ?? "";
7290
- const known = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
7320
+ const known = consoleProjects();
7291
7321
  if (!known.includes(repo))
7292
7322
  return refuse(response, who, 403, "That project is outside your access.", "/settings/project");
7293
7323
  const back = (key, words, to = repo) => redirect(response, `/settings/project?${to === "" ? "" : `repo=${encodeURIComponent(to)}&`}${key}=${encodeURIComponent(words)}`);
@@ -7343,7 +7373,7 @@ export function createDecisionServer(options) {
7343
7373
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
7344
7374
  return refuse(response, who, 403, "An instance operator sets approval rules.", "/settings/approval");
7345
7375
  const repo = body.get("repo") ?? "";
7346
- const known = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
7376
+ const known = consoleProjects();
7347
7377
  if (!known.includes(repo))
7348
7378
  return refuse(response, who, 403, "That project is outside your access.", "/settings/approval");
7349
7379
  const back = (key, words) => redirect(response, `/settings/approval?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
@@ -7744,7 +7774,7 @@ export function createDecisionServer(options) {
7744
7774
  if (who.via !== "cookie" || who.role !== "approver")
7745
7775
  return refuse(response, who, 403, "Sign in as an approver to connect tools.", "/settings/tools");
7746
7776
  const repo = body.get("repo") ?? "";
7747
- const reachable = (one) => visible(one) && [...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()].includes(one);
7777
+ const reachable = (one) => visible(one) && consoleProjects().includes(one);
7748
7778
  if (!reachable(repo))
7749
7779
  return refuse(response, who, 403, "That project is outside your access.", "/projects");
7750
7780
  // "Also connect to …": a service connected on the shown project, signed in again for another project.
@@ -7817,13 +7847,13 @@ export function createDecisionServer(options) {
7817
7847
  if (who.via !== "cookie" || who.role !== "approver")
7818
7848
  return refuse(response, who, 403, "Sign in as an approver to manage tools.", "/settings/tools");
7819
7849
  const repo = body.get("repo") ?? "";
7820
- if (!visible(repo) || ![...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()].includes(repo))
7850
+ if (!visible(repo) || !consoleProjects().includes(repo))
7821
7851
  return refuse(response, who, 403, "That project is outside your access.", "/projects");
7822
7852
  const back = (key, words, anchor = "") => redirect(response, `/settings/tools?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}${anchor}`);
7823
7853
  // As for Connect: a post from a page that showed another project is refused, never applied here.
7824
7854
  const shown = body.get("shown") ?? "";
7825
7855
  if (shown !== repo) {
7826
- const page = visible(shown) && [...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()].includes(shown) ? shown : repo;
7856
+ const page = visible(shown) && consoleProjects().includes(shown) ? shown : repo;
7827
7857
  return redirect(response, `/settings/tools?repo=${encodeURIComponent(page)}&problem=${encodeURIComponent("The project changed; try again.")}`);
7828
7858
  }
7829
7859
  const action = body.get("action") ?? "";
@@ -7986,7 +8016,7 @@ export function createDecisionServer(options) {
7986
8016
  if (who.via !== 'cookie' || who.role !== 'approver')
7987
8017
  return refuse(response, who, 403, 'Sign in as an approver to refresh project context.', '/settings/knowledge');
7988
8018
  const repo = body.get('repo') ?? '';
7989
- if (!visible(repo) || !store.accountCanAccess(who.name, repo) || ![...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()].includes(repo))
8019
+ if (!visible(repo) || !store.accountCanAccess(who.name, repo) || !consoleProjects().includes(repo))
7990
8020
  return refuse(response, who, 403, 'That project is outside your access.', '/settings/knowledge');
7991
8021
  const refreshed = await repositoryContext({ repo, query: '', refresh: true, cacheRoot: join(dirname(evidenceRoot), 'repository-context') });
7992
8022
  return sendScreen(response, refreshed.index.status === 'ready' ? 200 : 503, screen('Project context', `<h1>Project context</h1><p>${refreshed.index.status === 'ready' ? 'Code index refreshed.' : 'The index is unavailable. Source search still works.'}</p><a class="button-link" href="/settings/knowledge?repo=${encodeURIComponent(repo)}">Open knowledge</a>`, { chrome: chromeFor(repo, 'settings') }));
@@ -7995,7 +8025,7 @@ export function createDecisionServer(options) {
7995
8025
  if (who.via !== 'cookie' || who.role !== 'approver')
7996
8026
  return refuse(response, who, 403, 'Sign in as an approver to decide memory proposals.', '/settings/knowledge');
7997
8027
  const repo = body.get('repo') ?? '', decision = body.get('decision') ?? '';
7998
- if (!visible(repo) || ![...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()].includes(repo))
8028
+ if (!visible(repo) || !consoleProjects().includes(repo))
7999
8029
  return refuse(response, who, 403, 'That project is outside your access.', '/settings/knowledge');
8000
8030
  if (!['accept', 'reject'].includes(decision) || !/^[0-9]{1,12}$/.test(body.get('proposal') ?? ''))
8001
8031
  return refuse(response, who, 400, 'Choose accept or reject for one proposal.', '/settings/knowledge');
@@ -8011,7 +8041,7 @@ export function createDecisionServer(options) {
8011
8041
  if (who.via !== 'cookie' || who.role !== 'approver')
8012
8042
  return refuse(response, who, 403, 'Sign in as an approver to change project decisions.', '/settings/knowledge');
8013
8043
  const repo = body.get('repo') ?? '', action = body.get('action') ?? '';
8014
- if (!visible(repo) || ![...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()].includes(repo))
8044
+ if (!visible(repo) || !consoleProjects().includes(repo))
8015
8045
  return refuse(response, who, 403, 'That project is outside your access.', '/settings/knowledge');
8016
8046
  if (['repo', 'action', 'claim', 'why', 'decision', 'reason'].some(k => body.getAll(k).length > 1) || !['record', 'retire'].includes(action))
8017
8047
  return refuse(response, who, 400, 'Choose record or retire.', '/settings/knowledge');
@@ -8063,7 +8093,7 @@ export function createDecisionServer(options) {
8063
8093
  if (who.via !== "cookie")
8064
8094
  return refuse(response, who, 403, "Sign in to change learning.", "/settings/learning");
8065
8095
  const repo = body.get("repo") ?? "", action = body.get("action") ?? "";
8066
- if (!visible(repo) || ![...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()].includes(repo))
8096
+ if (!visible(repo) || !consoleProjects().includes(repo))
8067
8097
  return refuse(response, who, 403, "That project is outside your access.", "/settings/learning");
8068
8098
  if (["repo", "action", "identity", "revision", "lesson", "version", "sha"].some(k => body.getAll(k).length > 1) || !["adopt", "disable", "reset", "enable", "pause"].includes(action) || !/^[0-9]+$/.test(body.get("revision") ?? ""))
8069
8099
  return refuse(response, who, 400, "Invalid learning form.", "/settings/learning");
@@ -8505,6 +8535,25 @@ export function createDecisionServer(options) {
8505
8535
  sessions.persist(who.session);
8506
8536
  return redirect(response, safeReturn(body.get("return")));
8507
8537
  }
8538
+ if (url.pathname === "/projects/remove") {
8539
+ // The reversible remove: off every list and the builder; tasks, results and settings stay. Deleting is
8540
+ // Settings → Project → Delete project. The same admission code as `repos remove` (project-admission.ts).
8541
+ if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
8542
+ return refuse(response, who, 403, "An instance operator removes projects.", "/settings/project");
8543
+ const repo = body.get("repo") ?? "";
8544
+ if (!consoleProjects().includes(repo))
8545
+ return refuse(response, who, 403, "That project is outside your access.", "/settings/project");
8546
+ const released = await releaseProject(store, { registryFile: options.registryPath ?? null, path: repo, actor: { label: who.name, origin: "console" }, now });
8547
+ if (!released.ok)
8548
+ return redirect(response, `/settings/project?repo=${encodeURIComponent(repo)}&problem=${encodeURIComponent(`${projectName(repo)} wasn't removed: ${released.message}`)}`);
8549
+ deletedRepos.add(repo);
8550
+ if (who.session.project === repo) {
8551
+ who.session.project = null;
8552
+ who.session.projectRevision += 1;
8553
+ sessions.persist(who.session);
8554
+ }
8555
+ return redirect(response, `/settings/project?said=${encodeURIComponent(`Removed ${projectName(repo)}. Its tasks and results stay saved.`)}`);
8556
+ }
8508
8557
  if (url.pathname === "/projects/open") {
8509
8558
  // Sessions only: a bearer caller names its project per request and has
8510
8559
  // no session to mutate — refusing here keeps that boundary legible.
@@ -8526,18 +8575,19 @@ export function createDecisionServer(options) {
8526
8575
  if (!(await isGitRepo(canonical))) {
8527
8576
  return void projectsScreen(response, who, "that path is not a git repository", 400, safeReturn(body.get("return")));
8528
8577
  }
8529
- // Opening an allowed repository enrolls it in this machine's durable
8530
- // project list. A co-located `up` notices that list and connects its
8531
- // builder; there is no separate restart or runner-binding step.
8532
- if (options.registryPath !== undefined) {
8533
- const enrolled = await updateRepos(options.registryPath, repos => addRepos(repos, [canonical]));
8534
- deletedRepos.delete(canonical);
8535
- if (!enrolled.ok) {
8536
- return void projectsScreen(response, who, `that project is valid, but it could not be added — ${enrolled.message}`, 400, safeReturn(body.get("return")));
8537
- }
8578
+ // An account limited to listed projects never widens its own list.
8579
+ if (!visible(canonical)) {
8580
+ return void projectsScreen(response, who, "That project is outside your access.", 403, safeReturn(body.get("return")));
8581
+ }
8582
+ // Opening an allowed repository admits it the same way `repos add` does
8583
+ // (project-admission.ts). A co-located `up` notices the durable list and
8584
+ // connects its builder; there is no separate restart or binding step.
8585
+ const admitted = await admitProject(store, { registryFile: options.registryPath ?? null, path: canonical, actor: { label: who.name, origin: "console" }, now });
8586
+ if (!admitted.ok) {
8587
+ return void projectsScreen(response, who, admitted.reason === "not-git" ? "that path is not a git repository" : `that project is valid, but it could not be added — ${admitted.message}`, 400, safeReturn(body.get("return")));
8538
8588
  }
8539
- store.upsertProject(canonical, projectName(canonical), now);
8540
- who.session.project = canonical;
8589
+ deletedRepos.delete(admitted.repo);
8590
+ who.session.project = admitted.repo;
8541
8591
  who.session.projectRevision++;
8542
8592
  // The switcher (board pass) opens a project from any screen and
8543
8593
  // returns there — a same-site path only, never an off-site road.
@@ -9270,14 +9320,11 @@ export function createDecisionServer(options) {
9270
9320
  if (admitted === null) {
9271
9321
  return projectsScreen(response, who, `the clone landed at ${cloned.target} but did not prove under the ceiling — it was left in place; enroll it by hand`, 400);
9272
9322
  }
9273
- if (options.registryPath !== undefined) {
9274
- const enrolled = await updateRepos(options.registryPath, (repos) => addRepos(repos, [admitted]));
9275
- deletedRepos.delete(admitted);
9276
- if (!enrolled.ok) {
9277
- return projectsScreen(response, who, `${cloned.target} is cloned but not enrolled — ${enrolled.message}`, 400);
9278
- }
9323
+ const joined = await admitProject(store, { registryFile: options.registryPath ?? null, path: admitted, actor: { label: who.name, origin: "console" }, now });
9324
+ if (!joined.ok) {
9325
+ return projectsScreen(response, who, `${cloned.target} is cloned but not added — ${joined.message}`, 400);
9279
9326
  }
9280
- store.upsertProject(admitted, projectName(admitted), now);
9327
+ deletedRepos.delete(admitted);
9281
9328
  who.session.project = admitted;
9282
9329
  who.session.projectRevision += 1;
9283
9330
  return projectsScreen(response, who, options.upConsole === true
@@ -10853,8 +10900,8 @@ export function createDecisionServer(options) {
10853
10900
  if (document === current.document.trim())
10854
10901
  return redirect(response, taskHref(taskId));
10855
10902
  const content = Buffer.from(`${document}\n`, "utf8");
10856
- if (content.length > PLAN_LIMITS.document) {
10857
- return taskScreen(response, who, taskId, `plan not saved: it is over ${PLAN_LIMITS.document} bytes`, 400);
10903
+ if (content.length > TEXT_LIMITS.planDocumentBytes) {
10904
+ return taskScreen(response, who, taskId, `plan not saved: it is over ${TEXT_LIMITS.planDocumentBytes} bytes`, 400);
10858
10905
  }
10859
10906
  try {
10860
10907
  const name = `plan-edit-${randomUUID().replace(/-/g, "")}.md`;
@@ -22461,32 +22508,6 @@ function runsPage(chrome, rows, liveIds, nextCursor, verdicts = new Map(), accep
22461
22508
  const older = nextCursor === null ? "" : `<p><a href="/runs?before=${nextCursor}">older →</a></p>`;
22462
22509
  return screen("builds", [`<h1>Builds <a class="badge" href="/peek">Peek at the live ones \u2192</a></h1>`, buildsViews("builds"), `<p class="hint">one build = one attempt by an agent to complete a task, on its own branch</p>`, list, older].join("\n"), { chrome });
22463
22510
  }
22464
- function structuredHandoffView(artifacts, root) {
22465
- const artifact = [...artifacts].reverse().find(one => one.kind === "handoff");
22466
- if (artifact === undefined)
22467
- return null;
22468
- const read = readVerifiedArtifact(root, artifact);
22469
- if (!read.ok)
22470
- return null;
22471
- try {
22472
- const parsed = JSON.parse(read.content.toString("utf8"));
22473
- if (parsed === null || typeof parsed !== "object" || typeof parsed["conclusion"] !== "string")
22474
- return null;
22475
- const conclusion = oneLineOf(parsed["conclusion"], 600);
22476
- if (conclusion === "" || hasForbiddenControls(conclusion))
22477
- return null;
22478
- const list = (name) => Array.isArray(parsed[name])
22479
- ? parsed[name]
22480
- .filter((one) => typeof one === "string" && one.trim() !== "" && !hasForbiddenControls(one))
22481
- .slice(0, 8)
22482
- .map(one => oneLineOf(one, 240))
22483
- : [];
22484
- return { conclusion, changes: list("changes"), verification: list("verification"), followUps: list("followUps") };
22485
- }
22486
- catch {
22487
- return null;
22488
- }
22489
- }
22490
22511
  /** What a scout found, each with its link and picture, then any screenshot no item shows. Every link is the cited
22491
22512
  * http(s) address the report parser admitted; every picture is served from verified evidence. */
22492
22513
  function reportItemsHtml(items, shots, runId) {
@@ -22563,7 +22584,6 @@ function sharedResultFactsOf(run, proof, terminal, handoff, report, publication)
22563
22584
  publicationWords: receiptPublicationWords(publication),
22564
22585
  };
22565
22586
  }
22566
- const SCREENSHOT_CAPTURE = /^agent-claimed screenshot at (.+) \(validated (?:png|jpeg)\)/;
22567
22587
  function proofBundleView(store, run, artifacts, root) {
22568
22588
  const verdictRow = store.proofVerdictFor(run.id);
22569
22589
  const acceptanceRow = store.proofAcceptance(run.id);
@@ -22679,74 +22699,6 @@ function completionReceiptView(store, run, artifacts, root, review = null) {
22679
22699
  facts: sharedResultFactsOf(run, proof, terminal, handoff, report, publication),
22680
22700
  };
22681
22701
  }
22682
- const CAPTURE_EXIT = /\(exit ([0-9]{1,4})\)\s*$/;
22683
- /**
22684
- * Assemble the terminal-diff card's facts. Every branch names its state:
22685
- * a failed capture (nonzero exit recorded in the capture string) reads as
22686
- * the failure it is, unverifiable bytes read as their problem, and absence
22687
- * returns null so old runs simply show nothing rather than a broken card.
22688
- */
22689
- function terminalDiffView(artifacts, root) {
22690
- const patchArtifact = artifacts.find(one => one.kind === "terminal-diff");
22691
- const statArtifact = artifacts.find(one => one.kind === "diff-stat");
22692
- if (patchArtifact === undefined && statArtifact === undefined)
22693
- return null;
22694
- const view = { patch: null, stat: null };
22695
- if (patchArtifact !== undefined) {
22696
- const exit = CAPTURE_EXIT.exec(patchArtifact.capture);
22697
- if (exit !== null && exit[1] !== "0") {
22698
- view.patch = { problem: `capture failed — ${patchArtifact.capture}` };
22699
- }
22700
- else {
22701
- const read = readVerifiedArtifact(root, patchArtifact);
22702
- view.patch = read.ok
22703
- ? { text: read.content.toString("utf8"), truncated: patchArtifact.truncated, artifactId: patchArtifact.id }
22704
- : { problem: `stored but unverifiable — ${read.problem}` };
22705
- }
22706
- }
22707
- if (statArtifact !== undefined) {
22708
- const exit = CAPTURE_EXIT.exec(statArtifact.capture);
22709
- if (exit !== null && exit[1] !== "0") {
22710
- view.stat = { problem: `capture failed — ${statArtifact.capture}` };
22711
- }
22712
- else {
22713
- const read = readVerifiedArtifact(root, statArtifact);
22714
- if (!read.ok) {
22715
- view.stat = { problem: `stored but unverifiable — ${read.problem}` };
22716
- }
22717
- else {
22718
- try {
22719
- const parsed = JSON.parse(read.content.toString("utf8"));
22720
- // Every field this page renders is type-proved (arc 6, finding 7):
22721
- // "an object with a base key" was accepting any shape at all.
22722
- const count = (value) => typeof value === "number" && Number.isFinite(value);
22723
- const wellFormed = parsed !== null &&
22724
- typeof parsed === "object" &&
22725
- typeof parsed["base"] === "string" &&
22726
- typeof parsed["head"] === "string" &&
22727
- count(parsed["fileCount"]) &&
22728
- count(parsed["additions"]) &&
22729
- count(parsed["deletions"]) &&
22730
- count(parsed["binaryCount"]) &&
22731
- typeof parsed["filesTruncated"] === "boolean" &&
22732
- Array.isArray(parsed["files"]) &&
22733
- parsed["files"].every(one => one !== null &&
22734
- typeof one === "object" &&
22735
- typeof one["path"] === "string" &&
22736
- (count(one["additions"]) || one["additions"] === null) &&
22737
- (count(one["deletions"]) || one["deletions"] === null));
22738
- view.stat = wellFormed
22739
- ? parsed
22740
- : { problem: "stat is not the shape this page knows" };
22741
- }
22742
- catch {
22743
- view.stat = { problem: "stat did not parse as JSON" };
22744
- }
22745
- }
22746
- }
22747
- }
22748
- return view;
22749
- }
22750
22702
  const REVIEW_DIFF_LINE_CAP = 4_000;
22751
22703
  /** Parse only the stable structure Git's unified patch format guarantees.
22752
22704
  * Unknown metadata remains visible, and a patch that cannot be structured