toolroll 0.9.33 → 0.9.34

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.
package/dist/serve.js CHANGED
@@ -178,12 +178,13 @@ import { parseReport } from "./scout-report.js";
178
178
  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
179
  import { fileTaskProposal, fileRoutineProposal } from "./proposal.js";
180
180
  import { TASK_TEXT_LIMITS, validateTaskText } from "./task-text.js";
181
+ import { TEXT_LIMITS } from "./text-limits.js";
181
182
  import {} from "./store.js";
182
183
  import { attendedLivenessState } from "./liveness.js";
183
184
  import { approvalOf, approve as approveScope, attendedDigestOf, attendedTermsJson, authenticateApprover as checkApproverPassword, canonicalProfileJson, chainFromJson, profileDigestOf, proposeGuarded, scopeAuthorityOf, acceptanceLinesToInput, acceptanceToLines, acceptanceWords, parseAcceptanceCriteria, } from "./scope.js";
184
185
  import { isQualityMode, qualityModeTitle } from "./quality.js";
185
186
  import { LIMITS, hasForbiddenControls, validateNote } from "./decision.js";
186
- import { parseExecutionPlanDocument, PLAN_LIMITS, milestonesOf } from "./plan.js";
187
+ import { parseExecutionPlanDocument, milestonesOf } from "./plan.js";
187
188
  import { contractChangesOf, decodePlanContractRecord, describeContractChanges } from "./planner-source.js";
188
189
  import { observeWorktree, parseBaseTreeSnapshot, aggregateNewNames, PEEK_LIMITS } from "./peek.js";
189
190
  import { readLiveWindow } from "./live.js";
@@ -194,7 +195,8 @@ import { parseGithubRepo, previewGithubRepo, cloneGithubRepo, listGithubRepos, i
194
195
  import { verifiedAuthor, LEAD_THREAD, isDigestTime, RESULT_SCREENSHOTS, MATE_ASK_OTHER } from "./store.js";
195
196
  import { RESULT_SHOT_CHOICES } from "./result-shots.js";
196
197
  import { digestTimes } from "./digest-times.js";
197
- import { updateRepos, addRepos, removeRepos } from "./repos.js";
198
+ import { updateRepos, removeRepos } from "./repos.js";
199
+ import { admitProject, releaseProject } from "./project-admission.js";
198
200
  import { run as execRun } from "./exec.js";
199
201
  import { findFirstTasks, firstResultWords, firstRunSteps, firstTaskJourney, firstTaskSuggestions, HOW_IT_WORKS, SANDBOX_COMMAND, signInCommandFor, START_COMMAND } from "./first-run.js";
200
202
  /** A user agent, reduced to safe display words — never echoed raw. */
@@ -276,6 +278,8 @@ import { PACKAGE_VERSION } from "./version.js";
276
278
  import { updateNoticeWords } from "./update-notice.js";
277
279
  import { whenHtml } from "./when-html.js";
278
280
  const SESSION_COOKIE = "standing-orders_session";
281
+ /** Stands in for "no project" in a ceiling: no folder resolves to it, so it admits nothing. */
282
+ const NO_PROJECT = "/\0no-project";
279
283
  /** Where `up`'s one-time sign-in link points, and how long it works. */
280
284
  export const SIGN_IN_LINK_PATH = "/login/once/";
281
285
  export const SIGN_IN_LINK_MS = 10 * 60_000;
@@ -632,7 +636,13 @@ export function createDecisionServer(options) {
632
636
  /** No ceiling configured at all: the legacy trust-everything mode, named. */
633
637
  const unscopedMode = ceiling.repos.length === 0 && ceiling.roots.length === 0;
634
638
  /** Per-row visibility under the ceiling — the authorization question for reads. */
635
- const liveCeiling = () => ({ repos: [...ceiling.repos, ...(options.additionalProjectRepos?.() ?? [])], roots: ceiling.roots });
639
+ /** The exact repositories this console serves now: `up`'s live admitted set when it supplies one, else startup's. */
640
+ const exactRepos = () => options.admittedRepos?.() ?? ceiling.repos;
641
+ const liveCeiling = () => {
642
+ const repos = [...exactRepos(), ...(options.additionalProjectRepos?.() ?? [])];
643
+ // An admitted set that is empty for now is an empty ceiling, never the legacy unscoped mode.
644
+ return { repos: repos.length === 0 && ceiling.roots.length === 0 && !unscopedMode ? [NO_PROJECT] : repos, roots: ceiling.roots };
645
+ };
636
646
  // Native tools share the installation's login and can read beyond a project.
637
647
  // Account project membership cannot provide an OS read boundary.
638
648
  function codingActorAllowed(actor) {
@@ -651,12 +661,21 @@ export function createDecisionServer(options) {
651
661
  * ceiling; this server still filters every row through its own ceiling. */
652
662
  /** Projects deleted while this console runs: gone from its lists though a builder started with them still watches. */
653
663
  const deletedRepos = new Set();
664
+ /** Those still gone: a project added again (from here, `repos add`, or the lead) has its row back and shows again. */
665
+ const goneRepos = () => {
666
+ if (deletedRepos.size === 0)
667
+ return deletedRepos;
668
+ for (const one of store.listProjects())
669
+ deletedRepos.delete(one.path);
670
+ return deletedRepos;
671
+ };
654
672
  const managedRepos = () => {
655
673
  const seen = new Set();
656
674
  const repos = [];
657
- for (const path of [...ceiling.repos, ...(options.currentRepos?.() ?? [])]) {
675
+ const gone = goneRepos();
676
+ for (const path of [...exactRepos(), ...(options.currentRepos?.() ?? [])]) {
658
677
  const canonical = canonicalProject(path) ?? path;
659
- if (seen.has(canonical) || !visible(canonical) || deletedRepos.has(canonical))
678
+ if (seen.has(canonical) || !visible(canonical) || gone.has(canonical))
660
679
  continue;
661
680
  seen.add(canonical);
662
681
  repos.push(canonical);
@@ -761,6 +780,13 @@ export function createDecisionServer(options) {
761
780
  const admissionList = () => restricted() ? [...(store.accountOf(requestContext.getStore().actor)?.projects ?? [])].filter(visible) : unscopedMode
762
781
  ? null
763
782
  : [...new Set([...managedRepos(), ...(ceiling.roots.length === 0 ? [] : store.knownRepos().filter(visible))])];
783
+ /** Every project this person may pick in the console — the switcher, Projects, Settings, Tools and Flows read this
784
+ * one list: the live admitted set (taskless projects included) and every project with saved work, each through
785
+ * the ceiling and this account's project grants. A project removed or deleted here is gone from it. */
786
+ const consoleProjects = () => {
787
+ const gone = goneRepos();
788
+ return [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(one => visible(one) && !gone.has(one));
789
+ };
764
790
  /** The task behind a resource, for the ceiling check; null = no ref (visible). */
765
791
  const taskRepoOf = (taskRef) => store.refForId(taskRef)?.repo ?? null;
766
792
  const server = createServer((request, response) => {
@@ -2519,7 +2545,7 @@ export function createDecisionServer(options) {
2519
2545
  return void response.end(spendCsv(items, teammateNames));
2520
2546
  }
2521
2547
  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);
2548
+ const projects = consoleProjects();
2523
2549
  const people = store.accountFacts().filter(one => one.revokedAt === null).map(one => one.name);
2524
2550
  const view = {
2525
2551
  month: month.name, previous: shift(-1), next: month.name === current.name ? null : shift(1),
@@ -2535,7 +2561,7 @@ export function createDecisionServer(options) {
2535
2561
  }
2536
2562
  if (url.pathname === "/ledger") {
2537
2563
  const admitted = admissionList();
2538
- const projects = admitted ?? [...new Set([...managedRepos(), ...store.knownRepos()])].filter(visible);
2564
+ const projects = admitted ?? consoleProjects();
2539
2565
  const chosen = url.searchParams.get("project") ?? "";
2540
2566
  if (chosen !== "" && !projects.includes(chosen))
2541
2567
  return refuse(response, who, 403, "That project is outside your access.", "/ledger");
@@ -2694,7 +2720,7 @@ export function createDecisionServer(options) {
2694
2720
  return seen;
2695
2721
  };
2696
2722
  const csrf = who.via === "cookie" ? who.session.csrf : "";
2697
- const projectChoices = [...new Set([...managedRepos(), ...store.knownRepos()])].filter(visible);
2723
+ const projectChoices = consoleProjects();
2698
2724
  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
2725
  `<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
2726
  const cards = accounts.map(one => {
@@ -3192,7 +3218,7 @@ export function createDecisionServer(options) {
3192
3218
  }
3193
3219
  // Flows → New: the gallery, and each template's page.
3194
3220
  if (url.pathname === "/flows/new") {
3195
- const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
3221
+ const projects = consoleProjects();
3196
3222
  const canUse = who.via === "cookie" && who.role === "approver" && projects.length > 0;
3197
3223
  // The chosen project, else the one being looked at: its tools are what each card says is connected.
3198
3224
  const asked = url.searchParams.get("repo");
@@ -3202,7 +3228,7 @@ export function createDecisionServer(options) {
3202
3228
  const galleryRead = /^\/flows\/new\/([a-z-]{1,40})$/.exec(url.pathname);
3203
3229
  if (galleryRead !== null) {
3204
3230
  const template = galleryTemplateOf(galleryRead[1]);
3205
- const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
3231
+ const projects = consoleProjects();
3206
3232
  if (template === null)
3207
3233
  return refuse(response, who, 404, "There's no template by that name.", "/flows/new");
3208
3234
  if (who.via !== "cookie" || who.role !== "approver")
@@ -3216,13 +3242,13 @@ export function createDecisionServer(options) {
3216
3242
  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
3243
  }
3218
3244
  if (url.pathname === "/flows") {
3219
- const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
3245
+ const projects = consoleProjects();
3220
3246
  const flows = store.listFlows(projects);
3221
3247
  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
3248
  }
3223
3249
  // starter kits — the gallery, and each kit's checklist in a project.
3224
3250
  if (url.pathname === "/kits" || /^\/kits\/[a-z-]{1,40}$/.test(url.pathname)) {
3225
- const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
3251
+ const projects = consoleProjects();
3226
3252
  const csrf = who.via === "cookie" ? who.session.csrf : "", canSetUp = who.via === "cookie" && who.role === "approver";
3227
3253
  if (url.pathname === "/kits") {
3228
3254
  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 +3261,7 @@ export function createDecisionServer(options) {
3235
3261
  }
3236
3262
  // v92: AI teammates — the team, and one page per teammate.
3237
3263
  if (url.pathname === "/teammates") {
3238
- const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
3264
+ const projects = consoleProjects();
3239
3265
  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
3266
  }
3241
3267
  const teammateRead = /^\/teammates\/([1-9][0-9]{0,9})(\/soul\.md)?$/.exec(url.pathname);
@@ -3307,7 +3333,7 @@ export function createDecisionServer(options) {
3307
3333
  if (flow === null || flow.state !== "active" || !visible(flow.repo))
3308
3334
  return refuse(response, who, 404, "No such flow in your projects.", "/flows");
3309
3335
  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,
3336
+ 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
3337
  sortReady: keyStatus("openrouter", providerHome).set, toolHome });
3312
3338
  if (url.searchParams.get("format") === "json")
3313
3339
  return respond(response, 200, "application/json; charset=utf-8", JSON.stringify(view));
@@ -3886,7 +3912,7 @@ export function createDecisionServer(options) {
3886
3912
  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
3913
  }
3888
3914
  if (url.pathname === "/settings/skills") {
3889
- const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
3915
+ const projects = consoleProjects();
3890
3916
  const chosen = url.searchParams.get("repo") ?? project ?? projects[0] ?? "";
3891
3917
  if (chosen && !projects.includes(chosen))
3892
3918
  return refuse(response, who, 403, "That project is outside your access.", "/projects");
@@ -3904,7 +3930,7 @@ export function createDecisionServer(options) {
3904
3930
  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
3931
  }
3906
3932
  if (url.pathname === "/settings/tools") {
3907
- const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
3933
+ const projects = consoleProjects();
3908
3934
  const chosen = url.searchParams.get("repo") ?? project ?? projects[0] ?? "";
3909
3935
  if (chosen && !projects.includes(chosen))
3910
3936
  return refuse(response, who, 403, "That project is outside your access.", "/projects");
@@ -3928,7 +3954,7 @@ export function createDecisionServer(options) {
3928
3954
  }
3929
3955
  // Settings → Project: what Toolroll holds for a project; an instance operator deletes it here.
3930
3956
  if (url.pathname === "/settings/project") {
3931
- const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible).filter(one => !deletedRepos.has(one));
3957
+ const projects = consoleProjects();
3932
3958
  const asked = url.searchParams.get("repo");
3933
3959
  const chosen = asked ?? (project != null && projects.includes(project) ? project : projects[0] ?? "");
3934
3960
  if (chosen && !projects.includes(chosen))
@@ -3940,7 +3966,7 @@ export function createDecisionServer(options) {
3940
3966
  }
3941
3967
  // v102: Settings → Approval rules, a project's separation of duties. Anyone who sees the project reads them; an instance operator sets them.
3942
3968
  if (url.pathname === "/settings/approval") {
3943
- const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
3969
+ const projects = consoleProjects();
3944
3970
  const chosen = url.searchParams.get("repo") ?? project ?? projects[0] ?? "";
3945
3971
  if (chosen && !projects.includes(chosen))
3946
3972
  return refuse(response, who, 403, "That project is outside your access.", "/settings/approval");
@@ -3951,7 +3977,7 @@ export function createDecisionServer(options) {
3951
3977
  }
3952
3978
  // Sprint 8: Settings → Policy, the organisation policy and its history. Anyone signed in reads it; an instance operator changes it.
3953
3979
  if (url.pathname === "/settings/policy") {
3954
- const repos = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
3980
+ const repos = consoleProjects();
3955
3981
  const toolNames = [...new Set(repos.flatMap(repo => store.projectTools(repo).map(one => one.name)))].sort();
3956
3982
  const view = { policy: store.orgPolicy(), history: store.policyHistory(50), canChange: who.via === "cookie" && store.isInstanceOperator(who.name), toolNames };
3957
3983
  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 +3995,7 @@ export function createDecisionServer(options) {
3969
3995
  // Settings → Flows: the starter flows, each switched on with one yes. ?starter= marks the one a task's
3970
3996
  // "Do this every time…" asked about.
3971
3997
  if (url.pathname === "/settings/flows") {
3972
- const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
3998
+ const projects = consoleProjects();
3973
3999
  const chosen = url.searchParams.get("repo") ?? project ?? projects[0] ?? "";
3974
4000
  if (chosen && !projects.includes(chosen))
3975
4001
  return refuse(response, who, 403, "That project is outside your access.", "/settings");
@@ -4093,13 +4119,13 @@ export function createDecisionServer(options) {
4093
4119
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || !options.configDir)
4094
4120
  return refuse(response, who, 403, "An instance operator sets up sign-in.", "/settings");
4095
4121
  const origin = consoleOrigin(request.headers.host);
4096
- const projects = [...new Set([...managedRepos(), ...store.knownRepos()])].filter(visible).map(path => ({ path, name: projectName(path) }));
4122
+ const projects = consoleProjects().map(path => ({ path, name: projectName(path) }));
4097
4123
  const settings = ssoSettings();
4098
4124
  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
4125
  return sendScreen(response, 200, screen("Sign-in", `<p><a href="/settings">Settings</a></p><h1>Sign-in</h1>${html}`, { chrome: chromeFor(project, "settings") }));
4100
4126
  }
4101
4127
  if (url.pathname === "/settings/knowledge") {
4102
- const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
4128
+ const projects = consoleProjects();
4103
4129
  const chosen = url.searchParams.get("repo") ?? project ?? projects[0] ?? "";
4104
4130
  if (chosen && (!projects.includes(chosen) || !visible(chosen)))
4105
4131
  return refuse(response, who, 403, "That project is outside your access.", "/settings/knowledge");
@@ -4137,7 +4163,7 @@ export function createDecisionServer(options) {
4137
4163
  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
4164
  }
4139
4165
  if (url.pathname === "/settings/learning") {
4140
- const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
4166
+ const projects = consoleProjects();
4141
4167
  const chosen = url.searchParams.get("repo") ?? project ?? projects[0] ?? "";
4142
4168
  if (chosen && (!projects.includes(chosen) || !visible(chosen)))
4143
4169
  return refuse(response, who, 403, "That project is outside your access.", "/settings/learning");
@@ -5231,7 +5257,7 @@ export function createDecisionServer(options) {
5231
5257
  ...store.listProjects().map(one => ({ path: one.path, name: one.name })),
5232
5258
  ...managedRepos().map(path => ({ path, name: projectName(path) })),
5233
5259
  ]) {
5234
- if (seen.has(one.path) || !visible(one.path))
5260
+ if (seen.has(one.path) || !visible(one.path) || goneRepos().has(one.path))
5235
5261
  continue;
5236
5262
  seen.add(one.path);
5237
5263
  rows.push(one);
@@ -5475,7 +5501,7 @@ export function createDecisionServer(options) {
5475
5501
  const recent = store.listProjects().filter(one => visible(one.path));
5476
5502
  const recentPaths = new Set(recent.map(one => one.path));
5477
5503
  const candidates = new Set();
5478
- for (const path of [...managedRepos(), ...store.knownRepos()]) {
5504
+ for (const path of consoleProjects()) {
5479
5505
  const canonical = canonicalProject(path) ?? path;
5480
5506
  if (!recentPaths.has(canonical) && visible(canonical))
5481
5507
  candidates.add(canonical);
@@ -6765,7 +6791,7 @@ export function createDecisionServer(options) {
6765
6791
  if (who.via !== 'cookie' || who.role !== 'approver')
6766
6792
  return refuse(response, who, 403, 'Sign in as an approver to manage skills.', '/settings/skills');
6767
6793
  const repo = body.get('repo') ?? '', back = `/settings/skills?repo=${encodeURIComponent(repo)}`;
6768
- if (!visible(repo) || ![...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()].includes(repo))
6794
+ if (!visible(repo) || !consoleProjects().includes(repo))
6769
6795
  return refuse(response, who, 403, 'That project is outside your access.', '/projects');
6770
6796
  if ([...new Set(body.keys())].some(k => body.getAll(k).length !== 1))
6771
6797
  return refuse(response, who, 400, 'Invalid skills form.', back);
@@ -6821,7 +6847,7 @@ export function createDecisionServer(options) {
6821
6847
  if (who.via !== "cookie" || who.role !== "approver")
6822
6848
  return refuse(response, who, 403, "Sign in as an approver to set kits up.", "/kits");
6823
6849
  const kit = kitOf(kitPost[1]);
6824
- const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
6850
+ const projects = consoleProjects();
6825
6851
  const repo = body.get("repo") ?? "";
6826
6852
  if (kit === null || !projects.includes(repo) || !store.accountCanAccess(who.name, repo))
6827
6853
  return redirect(response, `/kits?problem=${encodeURIComponent("Choose one of your projects.")}`);
@@ -6866,7 +6892,7 @@ export function createDecisionServer(options) {
6866
6892
  if (who.role !== "approver")
6867
6893
  return refuse(response, who, 403, "Sign in as an approver to look after teammates.", "/teammates");
6868
6894
  if (teammatePost[2] === "new") {
6869
- const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
6895
+ const projects = consoleProjects();
6870
6896
  const repo = body.get("repo") ?? "";
6871
6897
  if (!projects.includes(repo))
6872
6898
  return redirect(response, `/teammates?problem=${encodeURIComponent("Choose one of your projects.")}`);
@@ -6938,7 +6964,7 @@ export function createDecisionServer(options) {
6938
6964
  const galleryPost = /^\/flows\/new\/([a-z-]{1,40})$/.exec(url.pathname);
6939
6965
  if (galleryPost !== null) {
6940
6966
  const template = galleryTemplateOf(galleryPost[1]);
6941
- const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
6967
+ const projects = consoleProjects();
6942
6968
  if (template === null)
6943
6969
  return refuse(response, who, 404, "There's no template by that name.", "/flows/new");
6944
6970
  if (who.via !== "cookie" || who.role !== "approver" || store.isDemo())
@@ -6970,7 +6996,7 @@ export function createDecisionServer(options) {
6970
6996
  if (url.pathname === "/flows/new" || url.pathname === "/flows/example" || url.pathname === "/flows/import" || flowPost !== null || triggerPost !== null) {
6971
6997
  const now = clock();
6972
6998
  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);
6999
+ const projects = consoleProjects();
6974
7000
  if (who.via !== "cookie" || who.role !== "approver")
6975
7001
  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
7002
  if (url.pathname === "/flows/example") {
@@ -7244,7 +7270,7 @@ export function createDecisionServer(options) {
7244
7270
  return back("problem", "Choose what the budget is for.");
7245
7271
  const scope = target[1], key = target[2];
7246
7272
  const known = scope === "installation" ? key === "*"
7247
- : scope === "project" ? [...new Set([...managedRepos(), ...store.knownRepos()])].filter(visible).includes(key)
7273
+ : scope === "project" ? consoleProjects().includes(key)
7248
7274
  : scope === "person" ? store.accountFacts().some(one => one.name === key && one.revokedAt === null)
7249
7275
  : store.teammates([...new Set([...managedRepos(), ...store.knownRepos()])]).some(one => String(one.id) === key);
7250
7276
  if (!known)
@@ -7266,7 +7292,7 @@ export function createDecisionServer(options) {
7266
7292
  // Settings → Project → Builds at once: an approver for the project sets it; the ledger keeps before → after.
7267
7293
  if (url.pathname === "/settings/project/concurrency") {
7268
7294
  const repo = body.get("repo") ?? "";
7269
- const known = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
7295
+ const known = consoleProjects();
7270
7296
  if (!known.includes(repo))
7271
7297
  return refuse(response, who, 403, "That project is outside your access.", "/settings/project");
7272
7298
  if (who.via !== "cookie" || who.role !== "approver" || !store.accountCanAccess(who.name, repo))
@@ -7287,7 +7313,7 @@ export function createDecisionServer(options) {
7287
7313
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
7288
7314
  return refuse(response, who, 403, "An instance operator deletes projects.", "/settings/project");
7289
7315
  const repo = body.get("repo") ?? "";
7290
- const known = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
7316
+ const known = consoleProjects();
7291
7317
  if (!known.includes(repo))
7292
7318
  return refuse(response, who, 403, "That project is outside your access.", "/settings/project");
7293
7319
  const back = (key, words, to = repo) => redirect(response, `/settings/project?${to === "" ? "" : `repo=${encodeURIComponent(to)}&`}${key}=${encodeURIComponent(words)}`);
@@ -7343,7 +7369,7 @@ export function createDecisionServer(options) {
7343
7369
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
7344
7370
  return refuse(response, who, 403, "An instance operator sets approval rules.", "/settings/approval");
7345
7371
  const repo = body.get("repo") ?? "";
7346
- const known = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
7372
+ const known = consoleProjects();
7347
7373
  if (!known.includes(repo))
7348
7374
  return refuse(response, who, 403, "That project is outside your access.", "/settings/approval");
7349
7375
  const back = (key, words) => redirect(response, `/settings/approval?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
@@ -7744,7 +7770,7 @@ export function createDecisionServer(options) {
7744
7770
  if (who.via !== "cookie" || who.role !== "approver")
7745
7771
  return refuse(response, who, 403, "Sign in as an approver to connect tools.", "/settings/tools");
7746
7772
  const repo = body.get("repo") ?? "";
7747
- const reachable = (one) => visible(one) && [...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()].includes(one);
7773
+ const reachable = (one) => visible(one) && consoleProjects().includes(one);
7748
7774
  if (!reachable(repo))
7749
7775
  return refuse(response, who, 403, "That project is outside your access.", "/projects");
7750
7776
  // "Also connect to …": a service connected on the shown project, signed in again for another project.
@@ -7817,13 +7843,13 @@ export function createDecisionServer(options) {
7817
7843
  if (who.via !== "cookie" || who.role !== "approver")
7818
7844
  return refuse(response, who, 403, "Sign in as an approver to manage tools.", "/settings/tools");
7819
7845
  const repo = body.get("repo") ?? "";
7820
- if (!visible(repo) || ![...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()].includes(repo))
7846
+ if (!visible(repo) || !consoleProjects().includes(repo))
7821
7847
  return refuse(response, who, 403, "That project is outside your access.", "/projects");
7822
7848
  const back = (key, words, anchor = "") => redirect(response, `/settings/tools?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}${anchor}`);
7823
7849
  // As for Connect: a post from a page that showed another project is refused, never applied here.
7824
7850
  const shown = body.get("shown") ?? "";
7825
7851
  if (shown !== repo) {
7826
- const page = visible(shown) && [...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()].includes(shown) ? shown : repo;
7852
+ const page = visible(shown) && consoleProjects().includes(shown) ? shown : repo;
7827
7853
  return redirect(response, `/settings/tools?repo=${encodeURIComponent(page)}&problem=${encodeURIComponent("The project changed; try again.")}`);
7828
7854
  }
7829
7855
  const action = body.get("action") ?? "";
@@ -7986,7 +8012,7 @@ export function createDecisionServer(options) {
7986
8012
  if (who.via !== 'cookie' || who.role !== 'approver')
7987
8013
  return refuse(response, who, 403, 'Sign in as an approver to refresh project context.', '/settings/knowledge');
7988
8014
  const repo = body.get('repo') ?? '';
7989
- if (!visible(repo) || !store.accountCanAccess(who.name, repo) || ![...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()].includes(repo))
8015
+ if (!visible(repo) || !store.accountCanAccess(who.name, repo) || !consoleProjects().includes(repo))
7990
8016
  return refuse(response, who, 403, 'That project is outside your access.', '/settings/knowledge');
7991
8017
  const refreshed = await repositoryContext({ repo, query: '', refresh: true, cacheRoot: join(dirname(evidenceRoot), 'repository-context') });
7992
8018
  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 +8021,7 @@ export function createDecisionServer(options) {
7995
8021
  if (who.via !== 'cookie' || who.role !== 'approver')
7996
8022
  return refuse(response, who, 403, 'Sign in as an approver to decide memory proposals.', '/settings/knowledge');
7997
8023
  const repo = body.get('repo') ?? '', decision = body.get('decision') ?? '';
7998
- if (!visible(repo) || ![...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()].includes(repo))
8024
+ if (!visible(repo) || !consoleProjects().includes(repo))
7999
8025
  return refuse(response, who, 403, 'That project is outside your access.', '/settings/knowledge');
8000
8026
  if (!['accept', 'reject'].includes(decision) || !/^[0-9]{1,12}$/.test(body.get('proposal') ?? ''))
8001
8027
  return refuse(response, who, 400, 'Choose accept or reject for one proposal.', '/settings/knowledge');
@@ -8011,7 +8037,7 @@ export function createDecisionServer(options) {
8011
8037
  if (who.via !== 'cookie' || who.role !== 'approver')
8012
8038
  return refuse(response, who, 403, 'Sign in as an approver to change project decisions.', '/settings/knowledge');
8013
8039
  const repo = body.get('repo') ?? '', action = body.get('action') ?? '';
8014
- if (!visible(repo) || ![...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()].includes(repo))
8040
+ if (!visible(repo) || !consoleProjects().includes(repo))
8015
8041
  return refuse(response, who, 403, 'That project is outside your access.', '/settings/knowledge');
8016
8042
  if (['repo', 'action', 'claim', 'why', 'decision', 'reason'].some(k => body.getAll(k).length > 1) || !['record', 'retire'].includes(action))
8017
8043
  return refuse(response, who, 400, 'Choose record or retire.', '/settings/knowledge');
@@ -8063,7 +8089,7 @@ export function createDecisionServer(options) {
8063
8089
  if (who.via !== "cookie")
8064
8090
  return refuse(response, who, 403, "Sign in to change learning.", "/settings/learning");
8065
8091
  const repo = body.get("repo") ?? "", action = body.get("action") ?? "";
8066
- if (!visible(repo) || ![...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()].includes(repo))
8092
+ if (!visible(repo) || !consoleProjects().includes(repo))
8067
8093
  return refuse(response, who, 403, "That project is outside your access.", "/settings/learning");
8068
8094
  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
8095
  return refuse(response, who, 400, "Invalid learning form.", "/settings/learning");
@@ -8505,6 +8531,25 @@ export function createDecisionServer(options) {
8505
8531
  sessions.persist(who.session);
8506
8532
  return redirect(response, safeReturn(body.get("return")));
8507
8533
  }
8534
+ if (url.pathname === "/projects/remove") {
8535
+ // The reversible remove: off every list and the builder; tasks, results and settings stay. Deleting is
8536
+ // Settings → Project → Delete project. The same admission code as `repos remove` (project-admission.ts).
8537
+ if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
8538
+ return refuse(response, who, 403, "An instance operator removes projects.", "/settings/project");
8539
+ const repo = body.get("repo") ?? "";
8540
+ if (!consoleProjects().includes(repo))
8541
+ return refuse(response, who, 403, "That project is outside your access.", "/settings/project");
8542
+ const released = await releaseProject(store, { registryFile: options.registryPath ?? null, path: repo, actor: { label: who.name, origin: "console" }, now });
8543
+ if (!released.ok)
8544
+ return redirect(response, `/settings/project?repo=${encodeURIComponent(repo)}&problem=${encodeURIComponent(`${projectName(repo)} wasn't removed: ${released.message}`)}`);
8545
+ deletedRepos.add(repo);
8546
+ if (who.session.project === repo) {
8547
+ who.session.project = null;
8548
+ who.session.projectRevision += 1;
8549
+ sessions.persist(who.session);
8550
+ }
8551
+ return redirect(response, `/settings/project?said=${encodeURIComponent(`Removed ${projectName(repo)}. Its tasks and results stay saved.`)}`);
8552
+ }
8508
8553
  if (url.pathname === "/projects/open") {
8509
8554
  // Sessions only: a bearer caller names its project per request and has
8510
8555
  // no session to mutate — refusing here keeps that boundary legible.
@@ -8526,18 +8571,19 @@ export function createDecisionServer(options) {
8526
8571
  if (!(await isGitRepo(canonical))) {
8527
8572
  return void projectsScreen(response, who, "that path is not a git repository", 400, safeReturn(body.get("return")));
8528
8573
  }
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
- }
8574
+ // An account limited to listed projects never widens its own list.
8575
+ if (!visible(canonical)) {
8576
+ return void projectsScreen(response, who, "That project is outside your access.", 403, safeReturn(body.get("return")));
8538
8577
  }
8539
- store.upsertProject(canonical, projectName(canonical), now);
8540
- who.session.project = canonical;
8578
+ // Opening an allowed repository admits it the same way `repos add` does
8579
+ // (project-admission.ts). A co-located `up` notices the durable list and
8580
+ // connects its builder; there is no separate restart or binding step.
8581
+ const admitted = await admitProject(store, { registryFile: options.registryPath ?? null, path: canonical, actor: { label: who.name, origin: "console" }, now });
8582
+ if (!admitted.ok) {
8583
+ 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")));
8584
+ }
8585
+ deletedRepos.delete(admitted.repo);
8586
+ who.session.project = admitted.repo;
8541
8587
  who.session.projectRevision++;
8542
8588
  // The switcher (board pass) opens a project from any screen and
8543
8589
  // returns there — a same-site path only, never an off-site road.
@@ -9270,14 +9316,11 @@ export function createDecisionServer(options) {
9270
9316
  if (admitted === null) {
9271
9317
  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
9318
  }
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
- }
9319
+ const joined = await admitProject(store, { registryFile: options.registryPath ?? null, path: admitted, actor: { label: who.name, origin: "console" }, now });
9320
+ if (!joined.ok) {
9321
+ return projectsScreen(response, who, `${cloned.target} is cloned but not added — ${joined.message}`, 400);
9279
9322
  }
9280
- store.upsertProject(admitted, projectName(admitted), now);
9323
+ deletedRepos.delete(admitted);
9281
9324
  who.session.project = admitted;
9282
9325
  who.session.projectRevision += 1;
9283
9326
  return projectsScreen(response, who, options.upConsole === true
@@ -10853,8 +10896,8 @@ export function createDecisionServer(options) {
10853
10896
  if (document === current.document.trim())
10854
10897
  return redirect(response, taskHref(taskId));
10855
10898
  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);
10899
+ if (content.length > TEXT_LIMITS.planDocumentBytes) {
10900
+ return taskScreen(response, who, taskId, `plan not saved: it is over ${TEXT_LIMITS.planDocumentBytes} bytes`, 400);
10858
10901
  }
10859
10902
  try {
10860
10903
  const name = `plan-edit-${randomUUID().replace(/-/g, "")}.md`;
package/dist/store.d.ts CHANGED
@@ -6980,6 +6980,8 @@ export declare class Store {
6980
6980
  /** Remember a project was opened. Upsert keeps added_at; recency always moves.
6981
6981
  * A project added for the first time starts on Quick checks. */
6982
6982
  upsertProject(path: string, name: string, now: Date): void;
6983
+ /** A project removed from the lists (project-admission.ts): only its row goes; its tasks, results and settings stay. */
6984
+ forgetProject(path: string): void;
6983
6985
  /** Most recently opened first — the opener page's order. */
6984
6986
  listProjects(): {
6985
6987
  path: string;
package/dist/store.js CHANGED
@@ -20463,6 +20463,10 @@ export class Store {
20463
20463
  stampNewProjectLevel(this, path, now);
20464
20464
  });
20465
20465
  }
20466
+ /** A project removed from the lists (project-admission.ts): only its row goes; its tasks, results and settings stay. */
20467
+ forgetProject(path) {
20468
+ this.db.prepare("DELETE FROM project WHERE path = ?").run(path);
20469
+ }
20466
20470
  /** Most recently opened first — the opener page's order. */
20467
20471
  listProjects() {
20468
20472
  return this.db
package/dist/surface.js CHANGED
@@ -81,8 +81,14 @@ export const COMMAND_GUIDE = [
81
81
  operator('conversation read', 'explicitly record your read position at --message within --conversation'),
82
82
  operator('conversation follow', 'enable or pause automatic lead summaries with --conversation and --enabled true|false, within the existing paid chat authorization; passive chat --follow only reads updates'),
83
83
  operator('conversation stop', 'request stopping the exact --message in --conversation; retain uncertain work until its owner settles it'),
84
- operator("repos add", "connect a repository to the installation"),
85
- operator("repos remove", "disconnect a repository"),
84
+ { invocation: "repos add", synopsis: "add a project: a Git repository, in the console at once; the lead adds with its token", audience: "agent", agentMayInvoke: true, mutation: "identity-idempotent",
85
+ positionals: [{ name: "path", required: false, meaning: "the repository (default: the one you are in)" }],
86
+ flags: [jsonFlag, { name: "token", takesValue: true, meaning: "the lead token (or TOOLROLL_LEAD_TOKEN)" }],
87
+ notableReasons: ["not-git", "unavailable", "refused", "locked"] },
88
+ { invocation: "repos remove", synopsis: "remove a project from the console and the builder; its tasks and results stay saved", audience: "agent", agentMayInvoke: true, mutation: "identity-idempotent",
89
+ positionals: [{ name: "path", required: true, meaning: "the repository" }],
90
+ flags: [jsonFlag, { name: "token", takesValue: true, meaning: "the lead token (or TOOLROLL_LEAD_TOKEN)" }],
91
+ notableReasons: ["refused", "locked"] },
86
92
  operator("repos add-from-github", "preview, clone from GitHub, and connect - the console onboarding ceremony as a CLI verb"),
87
93
  { invocation: "contract", synopsis: "the machine contract: envelope version + capability tokens; --commands dumps this guide", audience: "agent", agentMayInvoke: true, mutation: "none",
88
94
  flags: [jsonFlag, { name: "commands", takesValue: false, meaning: "dump the declared command guide" }] },
@@ -20,7 +20,24 @@ export declare const TEXT_LIMITS: {
20
20
  readonly reportSummary: 2500;
21
21
  /** What one flow step passes on to the steps after it. */
22
22
  readonly stageOutput: 12000;
23
+ /** The planner's whole handoff file, in UTF-8 bytes. */
24
+ readonly planPayloadBytes: number;
25
+ /** The plan document (markdown), in UTF-8 bytes. */
26
+ readonly planDocumentBytes: number;
27
+ /** The plan document's Approach section. */
28
+ readonly planApproach: 2000;
29
+ /** One Milestones, Dependencies, Risks or Proof item in the plan document. */
30
+ readonly planItem: 600;
31
+ /** One path a plan expects to touch. */
32
+ readonly planTouch: 200;
33
+ /** A planner's amendment note: why the filed contract must change. */
34
+ readonly planAmendment: 1000;
35
+ /** An acceptance criterion's id, statement and advisory how, in UTF-8 bytes. */
36
+ readonly acceptanceIdBytes: 40;
37
+ readonly acceptanceStatementBytes: 1000;
38
+ readonly acceptanceHowBytes: 500;
23
39
  };
40
+ export type TextLimitKey = keyof typeof TEXT_LIMITS;
24
41
  /** One chat message on each platform. Longer text is split across messages, never cut. Teams: the text one Adaptive Card
25
42
  * holds, well inside its 28 KB message. */
26
43
  export declare const PLATFORM_LIMITS: {
@@ -20,6 +20,22 @@ export const TEXT_LIMITS = {
20
20
  reportSummary: 2_500,
21
21
  /** What one flow step passes on to the steps after it. */
22
22
  stageOutput: 12_000,
23
+ /** The planner's whole handoff file, in UTF-8 bytes. */
24
+ planPayloadBytes: 64 * 1024,
25
+ /** The plan document (markdown), in UTF-8 bytes. */
26
+ planDocumentBytes: 16 * 1024,
27
+ /** The plan document's Approach section. */
28
+ planApproach: 2_000,
29
+ /** One Milestones, Dependencies, Risks or Proof item in the plan document. */
30
+ planItem: 600,
31
+ /** One path a plan expects to touch. */
32
+ planTouch: 200,
33
+ /** A planner's amendment note: why the filed contract must change. */
34
+ planAmendment: 1_000,
35
+ /** An acceptance criterion's id, statement and advisory how, in UTF-8 bytes. */
36
+ acceptanceIdBytes: 40,
37
+ acceptanceStatementBytes: 1_000,
38
+ acceptanceHowBytes: 500,
23
39
  };
24
40
  /** One chat message on each platform. Longer text is split across messages, never cut. Teams: the text one Adaptive Card
25
41
  * holds, well inside its 28 KB message. */
@@ -10,6 +10,8 @@ export declare function deadlineWords(deadline: {
10
10
  at: string;
11
11
  label: string;
12
12
  }, now?: Date, zone?: string): string;
13
+ /** How long ago, in one short mark: "now", "4m", "2h", "3d". A future stamp reads "now"; an unreadable one, "". */
14
+ export declare function shortAge(iso: string, now?: Date): string;
13
15
  /** The exact minute in `zone` ("2026-09-30 16:39", with " UTC" when it is UTC): a time's title, one hover away. */
14
16
  export declare function fullWhen(iso: string, zone?: string): string;
15
17
  /** Every server-rendered stamp (`whenHtml`'s `<time data-when>`) inside `root`, reworded by the one formatter in the