@kubitnodes/stellaris 0.3.25 → 0.3.27

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 (47) hide show
  1. package/dist/cli/index.js +340 -131
  2. package/dist/server/index.js +818 -178
  3. package/package.json +1 -1
  4. package/web/dist/assets/{BackLink-UTiLF1J4.js → BackLink-qSxOMeEG.js} +1 -1
  5. package/web/dist/assets/{Button-Cp4cNRUX.js → Button-CxjWy598.js} +1 -1
  6. package/web/dist/assets/{ChannelForms-DD2B0tey.js → ChannelForms-BVidWMId.js} +1 -1
  7. package/web/dist/assets/{ChannelView-CY3oG9hZ.js → ChannelView-rqfk1Tyu.js} +1 -1
  8. package/web/dist/assets/{CitizenView-BNK5ro-6.js → CitizenView-xU99R8hS.js} +1 -1
  9. package/web/dist/assets/{CitizensView-WPQ6Jhl3.js → CitizensView-Bgw6PRSo.js} +1 -1
  10. package/web/dist/assets/{CliIcon-C7bwChMP.js → CliIcon-BB2NdLxN.js} +1 -1
  11. package/web/dist/assets/{CronsView-_pdgwX1I.js → CronsView-BrXrcfC6.js} +1 -1
  12. package/web/dist/assets/{FileTree-jyDUSu7g.js → FileTree-CHDmrOhY.js} +1 -1
  13. package/web/dist/assets/{FileView-DcQvlc8V.js → FileView-DUMfKYRW.js} +1 -1
  14. package/web/dist/assets/{KnowledgeView-DDl0HQPG.js → KnowledgeView-Qog6c92V.js} +1 -1
  15. package/web/dist/assets/{Markdown-DT3V-EVe.js → Markdown-C7RbUEZE.js} +1 -1
  16. package/web/dist/assets/{MessageItem-agtEugfG.js → MessageItem-DgWh5pJF.js} +1 -1
  17. package/web/dist/assets/{MetricsView-DiXyYeWP.js → MetricsView-DuOyISk1.js} +1 -1
  18. package/web/dist/assets/{NeedsYouView-CKmuYan_.js → NeedsYouView-CrmHXZAn.js} +1 -1
  19. package/web/dist/assets/{Overview-CKcTqsMM.js → Overview-xatORJK6.js} +1 -1
  20. package/web/dist/assets/{Pane-BaM0TdE6.js → Pane-BlIRwUtQ.js} +1 -1
  21. package/web/dist/assets/{Picker-C2ig-0dT.js → Picker-BSOB2JM_.js} +1 -1
  22. package/web/dist/assets/ProjectView-CVt28SoI.js +1 -0
  23. package/web/dist/assets/{ProposalView-B5yjTAcn.js → ProposalView-DJ0aJm7J.js} +1 -1
  24. package/web/dist/assets/{ProposalsView-p4KjwV9p.js → ProposalsView-C0r1ya8_.js} +1 -1
  25. package/web/dist/assets/{Quota-CfFGIql9.js → Quota-59QJZrPa.js} +1 -1
  26. package/web/dist/assets/{RunnersView-DdvwoOur.js → RunnersView-CcAAfw6y.js} +1 -1
  27. package/web/dist/assets/{SkillFileList-CgrCnuaC.js → SkillFileList-Cs_f3RPo.js} +1 -1
  28. package/web/dist/assets/{SkillView-Di5Z9h2L.js → SkillView-BvVx3LSL.js} +1 -1
  29. package/web/dist/assets/{SocietyView-BDiVxUC5.js → SocietyView-CJRrypjI.js} +1 -1
  30. package/web/dist/assets/{StickyList-BaV_KZ8x.js → StickyList-BbGq82Jj.js} +1 -1
  31. package/web/dist/assets/{SubjectThread-DD_ZCCnr.js → SubjectThread-DdkyhVqK.js} +1 -1
  32. package/web/dist/assets/{TaskView-CKcs-MOC.js → TaskView-CyEWpCvS.js} +1 -1
  33. package/web/dist/assets/{TasksView-oZMjncOU.js → TasksView-D10XP1uk.js} +1 -1
  34. package/web/dist/assets/{ThreadForms-CG5Y926b.js → ThreadForms-D94Jj0ES.js} +1 -1
  35. package/web/dist/assets/{ThreadItem-D-n2arFA.js → ThreadItem-CYmdCPOf.js} +1 -1
  36. package/web/dist/assets/{ThreadView-CT9vd0P4.js → ThreadView-D79G2zgF.js} +1 -1
  37. package/web/dist/assets/{citizen-BiHfph2A.js → citizen-CgA4vJjy.js} +1 -1
  38. package/web/dist/assets/{compose-DY1WL8zB.js → compose-3ksQaHF6.js} +1 -1
  39. package/web/dist/assets/format-DpQyO6r1.js +55 -0
  40. package/web/dist/assets/{governance-C6ez5MLh.js → governance-YialJVYK.js} +1 -1
  41. package/web/dist/assets/{index-CPXALUV6.js → index-DhuCp-VF.js} +3 -3
  42. package/web/dist/assets/{mentions-BEL5U6ra.js → mentions-Buc5vs-j.js} +1 -1
  43. package/web/dist/assets/{tasks-CAI8Eci7.js → tasks-DLTeaHys.js} +1 -1
  44. package/web/dist/assets/{useMatch-CINzyDWa.js → useMatch-DPEg8ohe.js} +1 -1
  45. package/web/dist/index.html +8 -8
  46. package/web/dist/assets/ProjectView-T2OHTh5j.js +0 -1
  47. package/web/dist/assets/format-CENiwkJC.js +0 -55
@@ -2,12 +2,12 @@
2
2
 
3
3
  // apps/server/dist/index.js
4
4
  import { access as access2 } from "node:fs/promises";
5
- import path10 from "node:path";
5
+ import path11 from "node:path";
6
6
  import { serve } from "@hono/node-server";
7
7
 
8
8
  // packages/board-core/dist/board.js
9
- import { chmod as chmod2, cp, mkdir as mkdir4, readdir as readdir4, readFile as readFile4, rename as rename3, rm as rm2, stat as stat3, writeFile as writeFile4 } from "node:fs/promises";
10
- import path7 from "node:path";
9
+ import { chmod as chmod2, cp, mkdir as mkdir5, readdir as readdir4, readFile as readFile4, rename as rename3, rm as rm3, stat as stat3, writeFile as writeFile4 } from "node:fs/promises";
10
+ import path8 from "node:path";
11
11
  import { decodeTime, monotonicFactory } from "ulid";
12
12
  import { z as z16 } from "zod";
13
13
 
@@ -1283,6 +1283,7 @@ var BOARD_EVENT_TYPES = [
1283
1283
  "project.configured",
1284
1284
  "project.archived",
1285
1285
  "project.placed",
1286
+ "project.moved",
1286
1287
  "agent.added",
1287
1288
  "agent.configured",
1288
1289
  "agent.placed",
@@ -1471,6 +1472,11 @@ var RunnerHelloSchema = z9.object({
1471
1472
  quotaReads: z9.boolean().default(false),
1472
1473
  /** Whether the runner reads an image a turn running there uploads; one from before never does. */
1473
1474
  imageReads: z9.boolean().default(false),
1475
+ /**
1476
+ * Whether the runner carries a project's repository out to another runner and in from one, and
1477
+ * removes its copy once the project has moved; one from before never does.
1478
+ */
1479
+ projectMoves: z9.boolean().default(false),
1474
1480
  capabilities: z9.array(z9.string()).default([]),
1475
1481
  /**
1476
1482
  * What the runner's account lacks of the git identity citizens' own commits carry, checked
@@ -1580,6 +1586,11 @@ var TurnJobSchema = z9.object({
1580
1586
  session: z9.string().min(1).nullable(),
1581
1587
  /** The running total the session reported after its last turn, which a resumed session starts from. */
1582
1588
  costSoFarUsd: z9.number().nonnegative(),
1589
+ /**
1590
+ * The session's running usage after its last turn, which a resumed session's own usage is
1591
+ * measured from. Absent when the board holds none for the session, and from a server from before.
1592
+ */
1593
+ usageSoFar: UsageSchema.optional(),
1583
1594
  /** The rendered charter, norms, memory core, and skills index, with `PATH_TOKENS` for places. */
1584
1595
  instructions: z9.string(),
1585
1596
  prompt: z9.string(),
@@ -1608,6 +1619,17 @@ var TurnOutcomeSchema = z9.object({
1608
1619
  costUsd: z9.number().nonnegative(),
1609
1620
  /** The session's running total after this turn, for CLIs that report one. */
1610
1621
  sessionCostUsd: z9.number().nonnegative().optional(),
1622
+ /**
1623
+ * The session's running usage after this turn, for CLIs that report one, else the job's
1624
+ * `usageSoFar`. A runner from before never sends it.
1625
+ */
1626
+ sessionUsage: UsageSchema.optional(),
1627
+ /**
1628
+ * The whole input of the turn's latest model call in the session's own thread, cached or not:
1629
+ * how large the session had grown. Sub-agents' calls are left out. A turn that made no call,
1630
+ * and a runner from before, never send it.
1631
+ */
1632
+ contextTokens: z9.number().int().nonnegative().optional(),
1611
1633
  /**
1612
1634
  * The session the CLI used, which differs from the job's for CLIs that assign their own ids. A
1613
1635
  * turn whose CLI never started, because it failed or was stopped first, reports the job's own,
@@ -1776,6 +1798,43 @@ var HeldWorkspaceSchema = z9.object({
1776
1798
  });
1777
1799
  var HeldWorkspacesSchema = z9.object({ workspaces: z9.array(HeldWorkspaceSchema) });
1778
1800
  var SweepResultSchema = z9.object({ kept: z9.array(HeldWorkspaceSchema) });
1801
+ var MOVE_PACK_LIMIT_BYTES = 256 * 1024 * 1024;
1802
+ var RepoRefsSchema = z9.record(z9.string().regex(/^refs\/(heads|tags)\/\S+$/), z9.string().regex(/^[0-9a-f]{40}([0-9a-f]{24})?$/));
1803
+ var ProjectExportSchema = z9.object({ move: UlidSchema, repo: ProjectRepoSchema });
1804
+ var HeldWorkSchema = z9.object({
1805
+ agent: NameSchema,
1806
+ conversation: WorkspaceConversationSchema.nullable(),
1807
+ uncommitted: z9.number().int().nonnegative(),
1808
+ onNoBranch: z9.number().int().nonnegative()
1809
+ });
1810
+ var ProjectExportedSchema = z9.discriminatedUnion("ok", [
1811
+ z9.object({
1812
+ ok: z9.literal(true),
1813
+ refs: RepoRefsSchema,
1814
+ bytes: z9.number().int().nonnegative()
1815
+ }),
1816
+ z9.object({
1817
+ ok: z9.literal(false),
1818
+ held: z9.array(HeldWorkSchema),
1819
+ bytes: z9.number().int().nonnegative()
1820
+ })
1821
+ ]);
1822
+ var ProjectImportSchema = z9.object({
1823
+ move: UlidSchema,
1824
+ repo: ProjectRepoSchema,
1825
+ pullRequests: z9.boolean()
1826
+ });
1827
+ var ProjectImportedSchema = z9.object({ refs: RepoRefsSchema });
1828
+ var ProjectReleaseSchema = z9.object({ slug: NameSchema });
1829
+ var ProjectReleasedSchema = z9.object({
1830
+ worktrees: z9.number().int().nonnegative(),
1831
+ clone: z9.boolean()
1832
+ });
1833
+ var ProjectMovedSchema = z9.object({
1834
+ project: ProjectSchema,
1835
+ carried: z9.number().int().nonnegative().nullable(),
1836
+ released: z9.object({ ok: z9.boolean(), detail: z9.string() }).nullable()
1837
+ });
1779
1838
  var TurnSteerSchema = z9.object({ id: z9.uuid(), text: z9.string().min(1) });
1780
1839
  var RunnerMessageSchema = z9.discriminatedUnion("type", [
1781
1840
  z9.object({ type: z9.literal("turn"), job: TurnJobSchema }),
@@ -1812,6 +1871,19 @@ var RunnerMessageSchema = z9.discriminatedUnion("type", [
1812
1871
  }),
1813
1872
  /** Answered with whether the turn was there to stop; it ends `stopped`. */
1814
1873
  z9.object({ type: z9.literal("stop"), request: z9.string().min(1), turnId: UlidSchema }),
1874
+ /**
1875
+ * Answered with a `ProjectExported`. Sent only to runners whose hello says `projectMoves`, as are
1876
+ * the import and the release.
1877
+ */
1878
+ z9.object({ type: z9.literal("export"), request: z9.string().min(1), export: ProjectExportSchema }),
1879
+ /** Answered with a `ProjectImported`. */
1880
+ z9.object({ type: z9.literal("import"), request: z9.string().min(1), import: ProjectImportSchema }),
1881
+ /** Answered with a `ProjectReleased`. */
1882
+ z9.object({
1883
+ type: z9.literal("release"),
1884
+ request: z9.string().min(1),
1885
+ release: ProjectReleaseSchema
1886
+ }),
1815
1887
  /**
1816
1888
  * A conversation ended: the runner removes every citizen's workspace for it but those a turn
1817
1889
  * still uses, and answers with a `SweepResult` naming those it kept for holding work on no branch.
@@ -1942,6 +2014,16 @@ var TurnRecordSchema = z10.object({
1942
2014
  * resumed session starts from it, so the next turn's own cost is measured from here.
1943
2015
  */
1944
2016
  sessionCostUsd: z10.number().nonnegative().optional(),
2017
+ /**
2018
+ * The running usage the CLI reported for the session after this turn, when the runner reports
2019
+ * one. A resumed session's next turn measures its own usage from here.
2020
+ */
2021
+ sessionUsage: UsageSchema.optional(),
2022
+ /**
2023
+ * The whole input of the turn's latest model call in the session's own thread, as the runner
2024
+ * reported it: how large the session had grown. Absent for a turn that made no call.
2025
+ */
2026
+ contextTokens: z10.number().int().nonnegative().optional(),
1945
2027
  toolCalls: z10.number().int().nonnegative().default(0),
1946
2028
  /** The model the CLI reported for this turn, else the model configured for the agent, else null. */
1947
2029
  model: z10.string().nullable().default(null),
@@ -1955,7 +2037,10 @@ var TurnRecordSchema = z10.object({
1955
2037
  transient: z10.boolean().optional(),
1956
2038
  /** How many turns in a row, this one included, could not resume the session for a `retry`. */
1957
2039
  resumeRetries: z10.number().int().positive().optional(),
1958
- /** Whether the server dropped the conversation's session after this turn, which the next starts anew. */
2040
+ /**
2041
+ * Whether the server dropped the conversation's session after this turn, which the next starts
2042
+ * anew: one its CLI could not resume, or a society role's home after a reflection completed.
2043
+ */
1959
2044
  sessionDropped: z10.boolean().optional(),
1960
2045
  /** The crons whose fire started the turn, which record how it ended. */
1961
2046
  crons: z10.array(UlidSchema).optional()
@@ -2171,11 +2256,17 @@ var TokenUseSchema = z12.object({
2171
2256
  // packages/shared/dist/config.js
2172
2257
  import { z as z13 } from "zod";
2173
2258
  var LogLevelSchema = z13.enum(["trace", "debug", "info", "warn", "error", "fatal"]);
2259
+ var AppleAppIdSchema = z13.string().regex(/^[A-Z0-9]{10}\.[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*$/, "not <team id>.<bundle id>");
2174
2260
  var GithubSignInSchema = z13.object({
2175
2261
  clientId: z13.string().min(1),
2176
2262
  clientSecret: z13.string().min(1),
2177
2263
  /** Logins, lowercased; GitHub logins are case-insensitive. */
2178
- users: z13.array(z13.string().min(1)).min(1)
2264
+ users: z13.array(z13.string().min(1)).min(1),
2265
+ /**
2266
+ * The iOS app that may sign in, which Apple's association file names so that only it can take the
2267
+ * board's `https` landing; null when no app signs in.
2268
+ */
2269
+ appleAppId: AppleAppIdSchema.nullable()
2179
2270
  });
2180
2271
  var ServerConfigSchema = z13.object({
2181
2272
  dataDir: z13.string().min(1),
@@ -2216,13 +2307,20 @@ function githubSignIn(env) {
2216
2307
  const clientId = env["STELLARIS_GITHUB_CLIENT_ID"] ?? "";
2217
2308
  const clientSecret = env["STELLARIS_GITHUB_CLIENT_SECRET"] ?? "";
2218
2309
  const users = list(env["STELLARIS_GITHUB_USERS"]).map((login) => login.toLowerCase());
2310
+ const appleAppId = env["STELLARIS_APPLE_APP_ID"]?.trim() || null;
2311
+ if (appleAppId !== null && !AppleAppIdSchema.safeParse(appleAppId).success) {
2312
+ throw new Error(`STELLARIS_APPLE_APP_ID is <team id>.<bundle id>, not ${JSON.stringify(appleAppId)}`);
2313
+ }
2219
2314
  if (clientId === "" && clientSecret === "" && users.length === 0) {
2315
+ if (appleAppId !== null) {
2316
+ throw new Error("STELLARIS_APPLE_APP_ID needs GitHub sign-in");
2317
+ }
2220
2318
  return null;
2221
2319
  }
2222
2320
  if (clientId === "" || clientSecret === "" || users.length === 0) {
2223
2321
  throw new Error("GitHub sign-in needs STELLARIS_GITHUB_CLIENT_ID, STELLARIS_GITHUB_CLIENT_SECRET, and STELLARIS_GITHUB_USERS together");
2224
2322
  }
2225
- return GithubSignInSchema.parse({ clientId, clientSecret, users });
2323
+ return GithubSignInSchema.parse({ clientId, clientSecret, users, appleAppId });
2226
2324
  }
2227
2325
  function loadServerConfig(env) {
2228
2326
  return ServerConfigSchema.parse({
@@ -2257,7 +2355,7 @@ var RunnerConfigSchema = z13.object({
2257
2355
  });
2258
2356
 
2259
2357
  // packages/shared/dist/version.js
2260
- var STELLARIS_VERSION = "0.3.25";
2358
+ var STELLARIS_VERSION = "0.3.27";
2261
2359
 
2262
2360
  // packages/shared/dist/mentions.js
2263
2361
  import { parse, postprocess, preprocess } from "micromark";
@@ -2952,6 +3050,41 @@ function filesOf(lines, status) {
2952
3050
  return [...files.values()];
2953
3051
  }
2954
3052
 
3053
+ // packages/board-core/dist/moves.js
3054
+ import { mkdir as mkdir4, rm as rm2 } from "node:fs/promises";
3055
+ import path5 from "node:path";
3056
+ import { execa as execa2 } from "execa";
3057
+ var MoveRepos = class {
3058
+ root;
3059
+ constructor(root) {
3060
+ this.root = root;
3061
+ }
3062
+ /** Makes the move's empty repository, which checks every object a push brings, and answers its path. */
3063
+ async open(dir) {
3064
+ await mkdir4(this.root, { recursive: true });
3065
+ await git2(["init", "--quiet", "--bare", dir], this.root);
3066
+ await git2(["config", "receive.fsckObjects", "true"], dir);
3067
+ return dir;
3068
+ }
3069
+ async drop(dir) {
3070
+ await rm2(path5.resolve(this.root, dir), { recursive: true, force: true });
3071
+ }
3072
+ /** Removes every move's repository, as the server starts: a move never survives a restart. */
3073
+ async clear() {
3074
+ await rm2(this.root, { recursive: true, force: true });
3075
+ }
3076
+ };
3077
+ async function git2(args, cwd) {
3078
+ const result = await execa2("git", args, {
3079
+ cwd,
3080
+ reject: false,
3081
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
3082
+ });
3083
+ if (result.exitCode !== 0) {
3084
+ throw new Error(`git ${args[0] ?? ""} failed: ${result.stderr.trim()}`);
3085
+ }
3086
+ }
3087
+
2955
3088
  // packages/board-core/dist/mentions.js
2956
3089
  import vm from "node:vm";
2957
3090
  var MENTION_PARSE_MS = 500;
@@ -3365,121 +3498,121 @@ var Mutex = class {
3365
3498
  };
3366
3499
 
3367
3500
  // packages/board-core/dist/paths.js
3368
- import path5 from "node:path";
3501
+ import path6 from "node:path";
3369
3502
  var BoardPaths = class {
3370
3503
  dataDir;
3371
3504
  constructor(dataDir) {
3372
- this.dataDir = path5.resolve(dataDir);
3505
+ this.dataDir = path6.resolve(dataDir);
3373
3506
  }
3374
3507
  // Board projection, also the storage of record; only the core library writes it.
3375
3508
  get board() {
3376
- return path5.join(this.dataDir, "board");
3509
+ return path6.join(this.dataDir, "board");
3377
3510
  }
3378
3511
  get society() {
3379
- return path5.join(this.board, "society");
3512
+ return path6.join(this.board, "society");
3380
3513
  }
3381
3514
  societyFile() {
3382
- return path5.join(this.society, "society.md");
3515
+ return path6.join(this.society, "society.md");
3383
3516
  }
3384
3517
  societyChannels() {
3385
- return path5.join(this.society, "channels");
3518
+ return path6.join(this.society, "channels");
3386
3519
  }
3387
3520
  societyChannel(name) {
3388
- return path5.join(this.societyChannels(), name);
3521
+ return path6.join(this.societyChannels(), name);
3389
3522
  }
3390
3523
  /** Threads whose channel is a society channel; project channels' threads live under the project. */
3391
3524
  societyThreads() {
3392
- return path5.join(this.society, "threads");
3525
+ return path6.join(this.society, "threads");
3393
3526
  }
3394
3527
  societyKnowledge() {
3395
- return path5.join(this.society, "knowledge");
3528
+ return path6.join(this.society, "knowledge");
3396
3529
  }
3397
3530
  societyKnowledgeFile(topic) {
3398
- return path5.join(this.societyKnowledge(), `${topic}.md`);
3531
+ return path6.join(this.societyKnowledge(), `${topic}.md`);
3399
3532
  }
3400
3533
  /** Skills promoted to the society, one directory per skill holding SKILL.md. */
3401
3534
  societySkills() {
3402
- return path5.join(this.society, "skills");
3535
+ return path6.join(this.society, "skills");
3403
3536
  }
3404
3537
  societySkill(name) {
3405
- return path5.join(this.societySkills(), name);
3538
+ return path6.join(this.societySkills(), name);
3406
3539
  }
3407
3540
  societySkillFile(name) {
3408
- return path5.join(this.societySkill(name), "SKILL.md");
3541
+ return path6.join(this.societySkill(name), "SKILL.md");
3409
3542
  }
3410
3543
  roles() {
3411
- return path5.join(this.society, "roles");
3544
+ return path6.join(this.society, "roles");
3412
3545
  }
3413
3546
  role(name) {
3414
- return path5.join(this.roles(), `${name}.md`);
3547
+ return path6.join(this.roles(), `${name}.md`);
3415
3548
  }
3416
3549
  proposals() {
3417
- return path5.join(this.society, "proposals");
3550
+ return path6.join(this.society, "proposals");
3418
3551
  }
3419
3552
  proposal(id) {
3420
- return path5.join(this.proposals(), `${id}.md`);
3553
+ return path6.join(this.proposals(), `${id}.md`);
3421
3554
  }
3422
3555
  /** A skill proposal's folder, as the board took it from its proposer's home. */
3423
3556
  proposalFiles(id) {
3424
- return path5.join(this.proposals(), id);
3557
+ return path6.join(this.proposals(), id);
3425
3558
  }
3426
3559
  decisions() {
3427
- return path5.join(this.society, "decisions");
3560
+ return path6.join(this.society, "decisions");
3428
3561
  }
3429
3562
  decision(id) {
3430
- return path5.join(this.decisions(), `${id}.md`);
3563
+ return path6.join(this.decisions(), `${id}.md`);
3431
3564
  }
3432
3565
  runners() {
3433
- return path5.join(this.society, "runners");
3566
+ return path6.join(this.society, "runners");
3434
3567
  }
3435
3568
  runner(name) {
3436
- return path5.join(this.runners(), `${name}.md`);
3569
+ return path6.join(this.runners(), `${name}.md`);
3437
3570
  }
3438
3571
  members() {
3439
- return path5.join(this.society, "members");
3572
+ return path6.join(this.society, "members");
3440
3573
  }
3441
3574
  /** Crons, one file each with its note as the body; ended ones stay. */
3442
3575
  crons() {
3443
- return path5.join(this.society, "crons");
3576
+ return path6.join(this.society, "crons");
3444
3577
  }
3445
3578
  cron(id) {
3446
- return path5.join(this.crons(), `${id}.md`);
3579
+ return path6.join(this.crons(), `${id}.md`);
3447
3580
  }
3448
3581
  member(name) {
3449
- return path5.join(this.members(), `${name}.md`);
3582
+ return path6.join(this.members(), `${name}.md`);
3450
3583
  }
3451
3584
  projects() {
3452
- return path5.join(this.board, "projects");
3585
+ return path6.join(this.board, "projects");
3453
3586
  }
3454
3587
  project(slug) {
3455
- return path5.join(this.projects(), slug);
3588
+ return path6.join(this.projects(), slug);
3456
3589
  }
3457
3590
  projectFile(slug) {
3458
- return path5.join(this.project(slug), "project.md");
3591
+ return path6.join(this.project(slug), "project.md");
3459
3592
  }
3460
3593
  projectChannels(slug) {
3461
- return path5.join(this.project(slug), "channels");
3594
+ return path6.join(this.project(slug), "channels");
3462
3595
  }
3463
3596
  projectChannel(slug, name) {
3464
- return path5.join(this.projectChannels(slug), name);
3597
+ return path6.join(this.projectChannels(slug), name);
3465
3598
  }
3466
3599
  threads(slug) {
3467
- return path5.join(this.project(slug), "threads");
3600
+ return path6.join(this.project(slug), "threads");
3468
3601
  }
3469
3602
  tasks(slug) {
3470
- return path5.join(this.project(slug), "tasks");
3603
+ return path6.join(this.project(slug), "tasks");
3471
3604
  }
3472
3605
  task(slug, id) {
3473
- return path5.join(this.tasks(slug), `${id}.md`);
3606
+ return path6.join(this.tasks(slug), `${id}.md`);
3474
3607
  }
3475
3608
  projectKnowledge(slug) {
3476
- return path5.join(this.project(slug), "knowledge");
3609
+ return path6.join(this.project(slug), "knowledge");
3477
3610
  }
3478
3611
  projectKnowledgeFile(slug, topic) {
3479
- return path5.join(this.projectKnowledge(slug), `${topic}.md`);
3612
+ return path6.join(this.projectKnowledge(slug), `${topic}.md`);
3480
3613
  }
3481
3614
  dashboard(slug) {
3482
- return path5.join(this.project(slug), "dashboard.md");
3615
+ return path6.join(this.project(slug), "dashboard.md");
3483
3616
  }
3484
3617
  channelDir(ref) {
3485
3618
  const parsed = parseChannelRef(ref);
@@ -3492,57 +3625,57 @@ var BoardPaths = class {
3492
3625
  }
3493
3626
  /** A thread's record, `<id>.md`, beside the directory of its messages, `<id>/`. */
3494
3627
  threadFile(threads, id) {
3495
- return path5.join(threads, `${id}.md`);
3628
+ return path6.join(threads, `${id}.md`);
3496
3629
  }
3497
3630
  threadMessages(threads, id) {
3498
- return path5.join(threads, id);
3631
+ return path6.join(threads, id);
3499
3632
  }
3500
3633
  messageFile(dir, id, author) {
3501
- return path5.join(dir, `${id}-${author}.md`);
3634
+ return path6.join(dir, `${id}-${author}.md`);
3502
3635
  }
3503
3636
  // Agent homes: authored by the agent, source of truth on the board server.
3504
3637
  agents() {
3505
- return path5.join(this.dataDir, "agents");
3638
+ return path6.join(this.dataDir, "agents");
3506
3639
  }
3507
3640
  agent(name) {
3508
- return path5.join(this.agents(), name);
3641
+ return path6.join(this.agents(), name);
3509
3642
  }
3510
3643
  agentFile(name) {
3511
- return path5.join(this.agent(name), "agent.json");
3644
+ return path6.join(this.agent(name), "agent.json");
3512
3645
  }
3513
3646
  agentRole(name) {
3514
- return path5.join(this.agent(name), "role.md");
3647
+ return path6.join(this.agent(name), "role.md");
3515
3648
  }
3516
3649
  agentProfile(name) {
3517
- return path5.join(this.agent(name), "profile.md");
3650
+ return path6.join(this.agent(name), "profile.md");
3518
3651
  }
3519
3652
  agentMemory(name) {
3520
- return path5.join(this.agent(name), "memory");
3653
+ return path6.join(this.agent(name), "memory");
3521
3654
  }
3522
3655
  agentMemoryCore(name) {
3523
- return path5.join(this.agentMemory(name), "core.md");
3656
+ return path6.join(this.agentMemory(name), "core.md");
3524
3657
  }
3525
3658
  /** Transcripts of the agent's finished turns, one JSON line per step. */
3526
3659
  agentTurns(name) {
3527
- return path5.join(this.agent(name), "turns");
3660
+ return path6.join(this.agent(name), "turns");
3528
3661
  }
3529
3662
  agentTranscript(name, turnId) {
3530
- return path5.join(this.agentTurns(name), `${turnId}.jsonl`);
3663
+ return path6.join(this.agentTurns(name), `${turnId}.jsonl`);
3531
3664
  }
3532
3665
  agentSkills(name) {
3533
- return path5.join(this.agent(name), "skills");
3666
+ return path6.join(this.agent(name), "skills");
3534
3667
  }
3535
3668
  agentSkill(name, skill) {
3536
- return path5.join(this.agentSkills(name), skill);
3669
+ return path6.join(this.agentSkills(name), skill);
3537
3670
  }
3538
3671
  agentSkillFile(name, skill) {
3539
- return path5.join(this.agentSkill(name, skill), "SKILL.md");
3672
+ return path6.join(this.agentSkill(name, skill), "SKILL.md");
3540
3673
  }
3541
3674
  agentProjects(name) {
3542
- return path5.join(this.agent(name), "projects");
3675
+ return path6.join(this.agent(name), "projects");
3543
3676
  }
3544
3677
  agentProject(name, slug) {
3545
- return path5.join(this.agentProjects(name), slug);
3678
+ return path6.join(this.agentProjects(name), slug);
3546
3679
  }
3547
3680
  /**
3548
3681
  * A thread's, a channel's, or, in the society scope, a project's conversation's session and last
@@ -3550,46 +3683,56 @@ var BoardPaths = class {
3550
3683
  */
3551
3684
  agentConversation(name, slug, part) {
3552
3685
  if (part.startsWith("#")) {
3553
- return path5.join(this.agentProject(name, slug), "channels", part.slice(1));
3686
+ return path6.join(this.agentProject(name, slug), "channels", part.slice(1));
3554
3687
  }
3555
- return part.startsWith("~") ? path5.join(this.agentProject(name, slug), "places", part.slice(1)) : path5.join(this.agentProject(name, slug), "threads", part);
3688
+ return part.startsWith("~") ? path6.join(this.agentProject(name, slug), "places", part.slice(1)) : path6.join(this.agentProject(name, slug), "threads", part);
3556
3689
  }
3557
3690
  agentCursors(name) {
3558
- return path5.join(this.agent(name), "cursors.json");
3691
+ return path6.join(this.agent(name), "cursors.json");
3559
3692
  }
3560
3693
  events() {
3561
- return path5.join(this.dataDir, "events");
3694
+ return path6.join(this.dataDir, "events");
3562
3695
  }
3563
3696
  eventLog() {
3564
- return path5.join(this.events(), "log.jsonl");
3697
+ return path6.join(this.events(), "log.jsonl");
3565
3698
  }
3566
3699
  /** Images citizens uploaded, by id: outside the projection, which every runner mirrors. */
3567
3700
  images() {
3568
- return path5.join(this.dataDir, "images");
3701
+ return path6.join(this.dataDir, "images");
3569
3702
  }
3570
3703
  image(id) {
3571
- return path5.join(this.images(), id);
3704
+ return path6.join(this.images(), id);
3705
+ }
3706
+ /**
3707
+ * The repositories projects travel through between runners, one per move while it runs:
3708
+ * outside the projection, which every runner mirrors, and served only to the move's runners.
3709
+ */
3710
+ moves() {
3711
+ return path6.join(this.dataDir, "moves");
3712
+ }
3713
+ move(id) {
3714
+ return path6.join(this.moves(), `${id}.git`);
3572
3715
  }
3573
3716
  state() {
3574
- return path5.join(this.dataDir, "state");
3717
+ return path6.join(this.dataDir, "state");
3575
3718
  }
3576
3719
  pausedFile() {
3577
- return path5.join(this.state(), "paused.json");
3720
+ return path6.join(this.state(), "paused.json");
3578
3721
  }
3579
3722
  /** Removed knowledge topics, set aside outside the projection, by scope. */
3580
3723
  removedKnowledge(scope) {
3581
- return path5.join(this.state(), "removed-knowledge", scope);
3724
+ return path6.join(this.state(), "removed-knowledge", scope);
3582
3725
  }
3583
3726
  /** Skills removed from the society, set aside outside the projection, one directory per removal. */
3584
3727
  removedSkills() {
3585
- return path5.join(this.state(), "removed-skills");
3728
+ return path6.join(this.state(), "removed-skills");
3586
3729
  }
3587
3730
  };
3588
3731
 
3589
3732
  // packages/board-core/dist/skill-folder.js
3590
3733
  import { constants } from "node:fs";
3591
3734
  import { lstat, open as open2, readdir as readdir3, readlink, realpath } from "node:fs/promises";
3592
- import path6 from "node:path";
3735
+ import path7 from "node:path";
3593
3736
  var SKILL_TEXT_LIMIT_CHARS = 1e5;
3594
3737
  var SKILL_TEXT_LIMIT_BYTES = SKILL_TEXT_LIMIT_CHARS * 4;
3595
3738
  function skillFileText(bytes) {
@@ -3600,8 +3743,8 @@ function skillFileText(bytes) {
3600
3743
  };
3601
3744
  }
3602
3745
  function inside(root, file) {
3603
- const relative = path6.relative(root, file);
3604
- return relative !== ".." && !relative.startsWith(`..${path6.sep}`) && !path6.isAbsolute(relative);
3746
+ const relative = path7.relative(root, file);
3747
+ return relative !== ".." && !relative.startsWith(`..${path7.sep}`) && !path7.isAbsolute(relative);
3605
3748
  }
3606
3749
  async function handleInside(root, handle) {
3607
3750
  if (process.platform !== "linux") {
@@ -3611,7 +3754,7 @@ async function handleInside(root, handle) {
3611
3754
  return file !== null && inside(root, file);
3612
3755
  }
3613
3756
  async function walk(root, prefix, found) {
3614
- const entries = await readdir3(path6.join(root, ...prefix === "" ? [] : prefix.split("/")), {
3757
+ const entries = await readdir3(path7.join(root, ...prefix === "" ? [] : prefix.split("/")), {
3615
3758
  withFileTypes: true
3616
3759
  }).catch(() => []);
3617
3760
  for (const entry of entries.toSorted((a, b) => a.name < b.name ? -1 : 1)) {
@@ -3632,7 +3775,7 @@ async function walk(root, prefix, found) {
3632
3775
  async function readEntry(root, relative) {
3633
3776
  let real;
3634
3777
  try {
3635
- real = await realpath(path6.join(root, ...relative.split("/")));
3778
+ real = await realpath(path7.join(root, ...relative.split("/")));
3636
3779
  } catch {
3637
3780
  return null;
3638
3781
  }
@@ -3948,9 +4091,11 @@ var Board = class _Board {
3948
4091
  signIns = /* @__PURE__ */ new Map();
3949
4092
  files = new FileTree();
3950
4093
  homes;
4094
+ moves;
3951
4095
  constructor(dataDir, options) {
3952
4096
  this.paths = new BoardPaths(dataDir);
3953
- this.homes = new HomeRepos(path7.join(this.paths.state(), "home-hooks"));
4097
+ this.homes = new HomeRepos(path8.join(this.paths.state(), "home-hooks"));
4098
+ this.moves = new MoveRepos(this.paths.moves());
3954
4099
  this.leaseMs = options.leaseMs ?? DEFAULT_LEASE_MS;
3955
4100
  this.now = options.now ?? (() => /* @__PURE__ */ new Date());
3956
4101
  this.events = new EventLog(this.paths.eventLog(), () => this.newId(), this.now);
@@ -3976,6 +4121,7 @@ var Board = class _Board {
3976
4121
  await board2.loadRunnerTokens();
3977
4122
  await board2.loadSignIns();
3978
4123
  await board2.homes.installHooks();
4124
+ await board2.moves.clear();
3979
4125
  return board2;
3980
4126
  }
3981
4127
  /**
@@ -3985,7 +4131,7 @@ var Board = class _Board {
3985
4131
  */
3986
4132
  async applyAlignments() {
3987
4133
  const alignments = [];
3988
- const file = path7.join(this.paths.state(), "alignments.json");
4134
+ const file = path8.join(this.paths.state(), "alignments.json");
3989
4135
  const { applied } = await exists(file) ? await readJson(file, AlignmentsSchema) : { applied: [] };
3990
4136
  const pending = alignments.filter(({ name }) => !applied.includes(name));
3991
4137
  if (pending.length === 0) {
@@ -4193,7 +4339,7 @@ ${charter.purpose}
4193
4339
  });
4194
4340
  }
4195
4341
  signInsFile() {
4196
- return path7.join(this.paths.state(), "sign-ins.json");
4342
+ return path8.join(this.paths.state(), "sign-ins.json");
4197
4343
  }
4198
4344
  async readSignIns() {
4199
4345
  const file = this.signInsFile();
@@ -4290,7 +4436,7 @@ ${charter.purpose}
4290
4436
  const files = await listFiles(this.paths.tasks(project));
4291
4437
  const tasks = [];
4292
4438
  for (const file of files) {
4293
- const doc = await readMarkdown(path7.join(this.paths.tasks(project), file), TaskFrontmatterSchema);
4439
+ const doc = await readMarkdown(path8.join(this.paths.tasks(project), file), TaskFrontmatterSchema);
4294
4440
  tasks.push({ ...doc.data, body: doc.body });
4295
4441
  }
4296
4442
  return tasks;
@@ -4309,7 +4455,7 @@ ${charter.purpose}
4309
4455
  const files = await listFiles(this.paths.proposals());
4310
4456
  const proposals = [];
4311
4457
  for (const file of files) {
4312
- const doc = await readMarkdown(path7.join(this.paths.proposals(), file), ProposalFrontmatterSchema);
4458
+ const doc = await readMarkdown(path8.join(this.paths.proposals(), file), ProposalFrontmatterSchema);
4313
4459
  proposals.push({ ...doc.data, body: doc.body });
4314
4460
  }
4315
4461
  return proposals;
@@ -4336,7 +4482,7 @@ ${charter.purpose}
4336
4482
  async listMembers() {
4337
4483
  const members = [];
4338
4484
  for (const file of await listFiles(this.paths.members())) {
4339
- const doc = await readMarkdown(path7.join(this.paths.members(), file), MemberSchema);
4485
+ const doc = await readMarkdown(path8.join(this.paths.members(), file), MemberSchema);
4340
4486
  members.push({ ...doc.data, profile: doc.body });
4341
4487
  }
4342
4488
  return members;
@@ -4355,7 +4501,7 @@ ${charter.purpose}
4355
4501
  const dir = project === null ? this.paths.societyKnowledge() : this.paths.projectKnowledge(project);
4356
4502
  const topics = [];
4357
4503
  for (const file of await listFiles(dir)) {
4358
- topics.push(await this.knowledgeAt(path7.join(dir, file), project, file.replace(/\.md$/, "")));
4504
+ topics.push(await this.knowledgeAt(path8.join(dir, file), project, file.replace(/\.md$/, "")));
4359
4505
  }
4360
4506
  return topics;
4361
4507
  }
@@ -4385,7 +4531,7 @@ ${charter.purpose}
4385
4531
  const aside = this.paths.removedKnowledge(project ?? SOCIETY_SCOPE);
4386
4532
  let highest = 0;
4387
4533
  for (const file of (await listFiles(aside)).filter((name) => name.startsWith(`${topic}.`))) {
4388
- const copy = await this.knowledgeAt(path7.join(aside, file), project, topic);
4534
+ const copy = await this.knowledgeAt(path8.join(aside, file), project, topic);
4389
4535
  highest = Math.max(highest, copy.revision);
4390
4536
  }
4391
4537
  return highest;
@@ -4401,7 +4547,7 @@ ${charter.purpose}
4401
4547
  async listSkillsIn(dir, scope) {
4402
4548
  const skills = [];
4403
4549
  for (const entry of await listDirs(dir)) {
4404
- const file = path7.join(dir, entry, "SKILL.md");
4550
+ const file = path8.join(dir, entry, "SKILL.md");
4405
4551
  if (!await exists(file)) {
4406
4552
  continue;
4407
4553
  }
@@ -4491,7 +4637,7 @@ ${charter.purpose}
4491
4637
  if (!wanted.delete(file.slice(0, 26))) {
4492
4638
  continue;
4493
4639
  }
4494
- const { data } = await readMarkdown(path7.join(dir, file), MessageFrontmatterSchema);
4640
+ const { data } = await readMarkdown(path8.join(dir, file), MessageFrontmatterSchema);
4495
4641
  found.push(MessageRefSchema.parse(data));
4496
4642
  }
4497
4643
  if (wanted.size === 0) {
@@ -4523,7 +4669,7 @@ ${charter.purpose}
4523
4669
  if (!file.endsWith(".md")) {
4524
4670
  continue;
4525
4671
  }
4526
- const doc = await readMarkdown(path7.join(threads, file), ThreadFrontmatterSchema);
4672
+ const doc = await readMarkdown(path8.join(threads, file), ThreadFrontmatterSchema);
4527
4673
  if (doc.data.state !== "open") {
4528
4674
  continue;
4529
4675
  }
@@ -4550,7 +4696,7 @@ ${charter.purpose}
4550
4696
  const all = [];
4551
4697
  for (const threads of await this.threadScopes()) {
4552
4698
  for (const file of await listFiles(threads)) {
4553
- const doc = await readMarkdown(path7.join(threads, file), ThreadFrontmatterSchema);
4699
+ const doc = await readMarkdown(path8.join(threads, file), ThreadFrontmatterSchema);
4554
4700
  const messages = await listFiles(this.paths.threadMessages(threads, doc.data.id));
4555
4701
  const last = messages.at(-1);
4556
4702
  all.push({
@@ -5145,7 +5291,7 @@ ${task.body}`;
5145
5291
  }
5146
5292
  }
5147
5293
  for (const file of await listFiles(this.paths.projectKnowledge(project))) {
5148
- const content = await readFile4(path7.join(this.paths.projectKnowledge(project), file), "utf8");
5294
+ const content = await readFile4(path8.join(this.paths.projectKnowledge(project), file), "utf8");
5149
5295
  if (content.toLowerCase().includes(needle)) {
5150
5296
  hits.push({
5151
5297
  kind: "knowledge",
@@ -5166,7 +5312,7 @@ ${task.body}`;
5166
5312
  shared.push({
5167
5313
  kind: "knowledge",
5168
5314
  ref: `society/knowledge/${file}`,
5169
- file: path7.join(this.paths.societyKnowledge(), file)
5315
+ file: path8.join(this.paths.societyKnowledge(), file)
5170
5316
  });
5171
5317
  }
5172
5318
  for (const skill of await this.listSocietySkills()) {
@@ -5179,7 +5325,7 @@ ${task.body}`;
5179
5325
  shared.push({
5180
5326
  kind: "memory",
5181
5327
  ref: `memory/${file}`,
5182
- file: path7.join(this.paths.agentMemory(actor.name), file)
5328
+ file: path8.join(this.paths.agentMemory(actor.name), file)
5183
5329
  });
5184
5330
  }
5185
5331
  for (const entry of shared) {
@@ -5268,8 +5414,8 @@ ${current.body}`);
5268
5414
  removedAt: this.now().toISOString()
5269
5415
  });
5270
5416
  const aside = this.paths.removedKnowledge(args.project ?? SOCIETY_SCOPE);
5271
- await mkdir4(aside, { recursive: true });
5272
- await rename3(file, path7.join(aside, `${args.topic}.${removed.removedAt.replaceAll(":", "-")}.md`));
5417
+ await mkdir5(aside, { recursive: true });
5418
+ await rename3(file, path8.join(aside, `${args.topic}.${removed.removedAt.replaceAll(":", "-")}.md`));
5273
5419
  await this.events.append("knowledge.removed", actor.name, {
5274
5420
  topic: args.topic,
5275
5421
  project: args.project
@@ -5316,8 +5462,8 @@ ${current.body}`);
5316
5462
  removedAt: this.now().toISOString()
5317
5463
  });
5318
5464
  const aside = this.paths.removedSkills();
5319
- await mkdir4(aside, { recursive: true });
5320
- await rename3(this.paths.societySkill(args.name), path7.join(aside, `${args.name}.${removed.removedAt.replaceAll(":", "-")}`));
5465
+ await mkdir5(aside, { recursive: true });
5466
+ await rename3(this.paths.societySkill(args.name), path8.join(aside, `${args.name}.${removed.removedAt.replaceAll(":", "-")}`));
5321
5467
  await this.events.append("skill.removed", actor.name, {
5322
5468
  name: args.name,
5323
5469
  reason: args.reason
@@ -5394,7 +5540,7 @@ ${current.body}`);
5394
5540
  type,
5395
5541
  size: bytes.byteLength
5396
5542
  });
5397
- const name = path7.basename(args.path).replaceAll(/[[\]\\\r\n]/g, "");
5543
+ const name = path8.basename(args.path).replaceAll(/[[\]\\\r\n]/g, "");
5398
5544
  return {
5399
5545
  id,
5400
5546
  type,
@@ -5420,7 +5566,7 @@ ${current.body}`);
5420
5566
  async listCrons() {
5421
5567
  const crons = [];
5422
5568
  for (const file of await listFiles(this.paths.crons())) {
5423
- crons.push(await this.readCronFile(path7.join(this.paths.crons(), file)));
5569
+ crons.push(await this.readCronFile(path8.join(this.paths.crons(), file)));
5424
5570
  }
5425
5571
  return crons;
5426
5572
  }
@@ -5767,7 +5913,7 @@ ${current.body}`);
5767
5913
  async openThreadIds(dir, keep) {
5768
5914
  const ids = /* @__PURE__ */ new Set();
5769
5915
  for (const file of await listFiles(dir)) {
5770
- const doc = await readMarkdown(path7.join(dir, file), ThreadFrontmatterSchema);
5916
+ const doc = await readMarkdown(path8.join(dir, file), ThreadFrontmatterSchema);
5771
5917
  if (doc.data.state === "open" && keep({ ...doc.data, body: doc.body })) {
5772
5918
  ids.add(doc.data.id);
5773
5919
  }
@@ -5775,12 +5921,12 @@ ${current.body}`);
5775
5921
  return ids;
5776
5922
  }
5777
5923
  /**
5778
- * Refuses to archive a project while a turn runs in it, in a citizen's conversation of its
5779
- * channels in the society scope, or in a thread its archive closes.
5924
+ * Refuses to archive or move a project while a turn runs in it, in a citizen's conversation of
5925
+ * its channels in the society scope, or in one of its open threads.
5780
5926
  */
5781
- async assertProjectQuiet(actor, slug) {
5927
+ async assertProjectQuiet(actor, slug, what = `project ${slug} cannot be archived`) {
5782
5928
  const threads = await this.openThreadIds(this.paths.threads(slug), () => true);
5783
- this.assertNoTurnCut(actor, `project ${slug} cannot be archived`, (turn) => turn.scope === slug || turn.place === slug || turn.thread !== void 0 && threads.has(turn.thread));
5929
+ this.assertNoTurnCut(actor, what, (turn) => turn.scope === slug || turn.place === slug || turn.thread !== void 0 && threads.has(turn.thread));
5784
5930
  }
5785
5931
  async closeThread(actor, input) {
5786
5932
  const args = VerbInputs.close_thread.parse(input);
@@ -6747,7 +6893,7 @@ ${rationale}`}`);
6747
6893
  return part === void 0 ? this.paths.agentProject(agent, scope) : this.paths.agentConversation(agent, scope, part);
6748
6894
  }
6749
6895
  async readSessions(agent, project, part) {
6750
- const file = path7.join(this.sessionDir(agent, project, part), "sessions.json");
6896
+ const file = path8.join(this.sessionDir(agent, project, part), "sessions.json");
6751
6897
  return await exists(file) ? readJson(file, SessionsFileSchema) : {};
6752
6898
  }
6753
6899
  /**
@@ -6758,7 +6904,7 @@ ${rationale}`}`);
6758
6904
  await this.mutex.run(async () => {
6759
6905
  const dir = this.sessionDir(agent, project, part);
6760
6906
  await ensureDir(dir);
6761
- const file = path7.join(dir, "sessions.json");
6907
+ const file = path8.join(dir, "sessions.json");
6762
6908
  const current = await exists(file) ? await readJson(file, SessionsFileSchema) : {};
6763
6909
  const kept = current.runner === void 0 || current.runner === runner ? current : {};
6764
6910
  await writeJson(file, { ...kept, [cli]: sessionId, runner });
@@ -6770,7 +6916,7 @@ ${rationale}`}`);
6770
6916
  */
6771
6917
  async dropSession(agent, project, cli, sessionId, part) {
6772
6918
  return this.mutex.run(async () => {
6773
- const file = path7.join(this.sessionDir(agent, project, part), "sessions.json");
6919
+ const file = path8.join(this.sessionDir(agent, project, part), "sessions.json");
6774
6920
  if (!await exists(file)) {
6775
6921
  return false;
6776
6922
  }
@@ -6785,7 +6931,7 @@ ${rationale}`}`);
6785
6931
  }
6786
6932
  /** The last turn of one conversation: the scope's home, a channel's, a project's, or a thread's. */
6787
6933
  async readLastTurn(agent, project, part) {
6788
- const file = path7.join(this.sessionDir(agent, project, part), "last-turn.json");
6934
+ const file = path8.join(this.sessionDir(agent, project, part), "last-turn.json");
6789
6935
  return await exists(file) ? readJson(file, TurnRecordSchema) : null;
6790
6936
  }
6791
6937
  /** The latest turn a citizen took in a scope, in any of its conversations there. */
@@ -6793,9 +6939,9 @@ ${rationale}`}`);
6793
6939
  let latest = await this.readLastTurn(agent, project);
6794
6940
  const place = this.paths.agentProject(agent, project);
6795
6941
  const parts = [
6796
- ...await listDirs(path7.join(place, "threads")),
6797
- ...(await listDirs(path7.join(place, "channels"))).map((name) => `#${name}`),
6798
- ...(await listDirs(path7.join(place, "places"))).map((name) => `~${name}`)
6942
+ ...await listDirs(path8.join(place, "threads")),
6943
+ ...(await listDirs(path8.join(place, "channels"))).map((name) => `#${name}`),
6944
+ ...(await listDirs(path8.join(place, "places"))).map((name) => `~${name}`)
6799
6945
  ];
6800
6946
  for (const part of parts) {
6801
6947
  const turn = await this.readLastTurn(agent, project, part);
@@ -6861,6 +7007,7 @@ ${rationale}`}`);
6861
7007
  exitReason: parsed.exitReason,
6862
7008
  costUsd: parsed.costUsd,
6863
7009
  ...parsed.usage === null ? {} : { usage: parsed.usage },
7010
+ ...parsed.contextTokens === void 0 ? {} : { contextTokens: parsed.contextTokens },
6864
7011
  toolCalls: parsed.toolCalls,
6865
7012
  model: parsed.model,
6866
7013
  summary: parsed.status?.summary ?? null,
@@ -7061,11 +7208,11 @@ ${rationale}`}`);
7061
7208
  }
7062
7209
  /** Small named state files under data/state, for cursors the scheduler must keep across restarts. */
7063
7210
  async readState(name, schema, fallback) {
7064
- const file = path7.join(this.paths.state(), `${name}.json`);
7211
+ const file = path8.join(this.paths.state(), `${name}.json`);
7065
7212
  return await exists(file) ? readJson(file, schema) : fallback;
7066
7213
  }
7067
7214
  async writeState(name, value) {
7068
- await this.mutex.run(() => writeJson(path7.join(this.paths.state(), `${name}.json`), value));
7215
+ await this.mutex.run(() => writeJson(path8.join(this.paths.state(), `${name}.json`), value));
7069
7216
  }
7070
7217
  /** The project's dashboard, empty at revision 0 when it has none. */
7071
7218
  async readDashboard(slug) {
@@ -7096,7 +7243,7 @@ ${rationale}`}`);
7096
7243
  async listRunners() {
7097
7244
  const runners2 = [];
7098
7245
  for (const file of await listFiles(this.paths.runners())) {
7099
- runners2.push((await readMarkdown(path7.join(this.paths.runners(), file), RunnerSchema)).data);
7246
+ runners2.push((await readMarkdown(path8.join(this.paths.runners(), file), RunnerSchema)).data);
7100
7247
  }
7101
7248
  return runners2;
7102
7249
  }
@@ -7148,7 +7295,7 @@ ${rationale}`}`);
7148
7295
  return this.runnerTokens.get(hashToken(token)) ?? null;
7149
7296
  }
7150
7297
  runnerTokensFile() {
7151
- return path7.join(this.paths.state(), "runners.json");
7298
+ return path8.join(this.paths.state(), "runners.json");
7152
7299
  }
7153
7300
  async readRunnerTokens() {
7154
7301
  const file = this.runnerTokensFile();
@@ -7162,7 +7309,8 @@ ${rationale}`}`);
7162
7309
  }
7163
7310
  /**
7164
7311
  * Places a project on the runner that will hold its repository, unless it already lives on one,
7165
- * and returns where it lives. Placement is once: every later turn of the project goes there.
7312
+ * and returns where it lives. Placement is once: every later turn of the project goes there,
7313
+ * until the user moves the project (`recordProjectMove`).
7166
7314
  */
7167
7315
  async placeProject(slug, runner) {
7168
7316
  return this.mutex.run(async () => {
@@ -7204,12 +7352,12 @@ ${rationale}`}`);
7204
7352
  }
7205
7353
  const relative = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
7206
7354
  if (entry.isDirectory()) {
7207
- await walk2(path7.join(dir, entry.name), relative);
7355
+ await walk2(path8.join(dir, entry.name), relative);
7208
7356
  continue;
7209
7357
  }
7210
7358
  const file = entry.isFile() ? conflictCopyOf(relative) : null;
7211
7359
  if (file !== null) {
7212
- const since = await this.homes.changedAt(home, relative) ?? (await stat3(path7.join(home, relative)).catch(() => null))?.mtime.toISOString();
7360
+ const since = await this.homes.changedAt(home, relative) ?? (await stat3(path8.join(home, relative)).catch(() => null))?.mtime.toISOString();
7213
7361
  if (since !== void 0) {
7214
7362
  found.push({ path: relative, file, since });
7215
7363
  }
@@ -7283,6 +7431,74 @@ ${rationale}`}`);
7283
7431
  return next;
7284
7432
  });
7285
7433
  }
7434
+ /**
7435
+ * The board's half of moving a project to another runner, which only the user does, checked
7436
+ * before anything travels and while the hub holds the project's turns: the project is active and
7437
+ * lives on a runner other than `to`, which the board knows; no task there is landing, since its
7438
+ * landing runs on the old runner; and no turn runs in the project. Tasks in play move with their
7439
+ * branches, so the answer lists them for the capabilities the new runner must offer.
7440
+ */
7441
+ async prepareProjectMove(actor, slug, to) {
7442
+ this.assertUser(actor, "only the user may move a project to another runner");
7443
+ const runner = NameSchema.parse(to);
7444
+ return this.mutex.run(async () => {
7445
+ const project = await this.readActiveProject(slug);
7446
+ const from = project.runner;
7447
+ if (from === void 0) {
7448
+ throw new BoardError("INVALID_STATE", `project ${slug} lives on no runner yet; its first turn places it`);
7449
+ }
7450
+ if (from === runner) {
7451
+ throw new BoardError("INVALID_STATE", `project ${slug} already lives on ${runner}`);
7452
+ }
7453
+ await this.readRunner(runner);
7454
+ const inPlay2 = (await this.listTasks(slug)).filter((task) => task.status === "open" || task.status === "claimed");
7455
+ const landing = inPlay2.filter((task) => task.completing);
7456
+ if (landing.length > 0) {
7457
+ throw new BoardError("INVALID_STATE", `project ${slug} cannot be moved while ${landing.length} task(s) land on ${from} (${landing.map((task) => task.id).join(", ")}); move it once they have landed`);
7458
+ }
7459
+ await this.assertProjectQuiet(actor, slug, `project ${slug} cannot be moved`);
7460
+ return { project: { ...project, runner: from }, inPlay: inPlay2 };
7461
+ });
7462
+ }
7463
+ /**
7464
+ * Records that a project moved, once its new runner holds every branch its old one had, or with
7465
+ * nothing carried when the user moved it while the old runner was away: every later turn of
7466
+ * the project goes to `to`. Refused when the project was archived or moved meanwhile.
7467
+ */
7468
+ async recordProjectMove(actor, move) {
7469
+ this.assertUser(actor, "only the user may move a project to another runner");
7470
+ return this.mutex.run(async () => {
7471
+ const current = await this.readActiveProject(move.slug);
7472
+ if (current.runner !== move.from) {
7473
+ throw new BoardError("INVALID_STATE", `project ${move.slug} no longer lives on ${move.from}, so this move no longer applies`);
7474
+ }
7475
+ await this.readRunner(move.to);
7476
+ const project = await this.updateProject(move.slug, (each) => ({ ...each, runner: move.to }));
7477
+ await this.events.append("project.moved", actor.name, {
7478
+ slug: move.slug,
7479
+ from: move.from,
7480
+ to: move.to,
7481
+ carried: move.carried
7482
+ });
7483
+ return project;
7484
+ });
7485
+ }
7486
+ /**
7487
+ * Makes the repository a move carries a project through, for the server's git route to serve,
7488
+ * and answers the move's id, which names it; `dropMoveRepo` removes it when the move ends.
7489
+ */
7490
+ async openMoveRepo() {
7491
+ const id = this.newId();
7492
+ await this.moves.open(this.paths.move(id));
7493
+ return id;
7494
+ }
7495
+ async dropMoveRepo(id) {
7496
+ await this.moves.drop(this.paths.move(UlidSchema.parse(id)));
7497
+ }
7498
+ /** Where the moves' repositories live, which the server's git route serves from. */
7499
+ get moveRepos() {
7500
+ return this.paths.moves();
7501
+ }
7286
7502
  /** The projection a runner mirrors for its agents to read, by relative path, with hashes. */
7287
7503
  async boardManifest() {
7288
7504
  return this.files.manifest(this.paths.board, () => true);
@@ -7293,7 +7509,7 @@ ${rationale}`}`);
7293
7509
  async writeTurnRecord(record) {
7294
7510
  const dir = this.sessionDir(record.agent, record.project, conversationPart(record));
7295
7511
  await ensureDir(dir);
7296
- await writeJson(path7.join(dir, "last-turn.json"), record);
7512
+ await writeJson(path8.join(dir, "last-turn.json"), record);
7297
7513
  }
7298
7514
  // ---------------------------------------------------------------------------------------------
7299
7515
  // Internals
@@ -7412,8 +7628,8 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
7412
7628
  async ensureAgentProject(name, slug) {
7413
7629
  const dir = this.paths.agentProject(name, slug);
7414
7630
  await ensureDir(dir);
7415
- if (!await exists(path7.join(dir, "sessions.json"))) {
7416
- await writeJson(path7.join(dir, "sessions.json"), {});
7631
+ if (!await exists(path8.join(dir, "sessions.json"))) {
7632
+ await writeJson(path8.join(dir, "sessions.json"), {});
7417
7633
  }
7418
7634
  }
7419
7635
  /**
@@ -7655,11 +7871,11 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
7655
7871
  const file = this.paths.societySkillFile(skill.name);
7656
7872
  const replaced = await exists(file);
7657
7873
  const taken = proposal.skillFiles;
7658
- const staged = path7.join(this.paths.proposalFiles(proposal.id), "SKILL.md");
7874
+ const staged = path8.join(this.paths.proposalFiles(proposal.id), "SKILL.md");
7659
7875
  if (taken === void 0 || !await exists(staged)) {
7660
7876
  throw new BoardError("INVALID_STATE", `proposal ${proposal.id} holds no skill files`);
7661
7877
  }
7662
- await rm2(dir, { recursive: true, force: true });
7878
+ await rm3(dir, { recursive: true, force: true });
7663
7879
  await cp(this.paths.proposalFiles(proposal.id), dir, { recursive: true });
7664
7880
  const { body } = await readLooseMarkdown(file);
7665
7881
  await writeMarkdown(file, {
@@ -7712,10 +7928,10 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
7712
7928
  return `the frontmatter of ${folder}/SKILL.md in ${proposal.proposedBy}'s home does not parse (${manual.error})`;
7713
7929
  }
7714
7930
  const dir = this.paths.proposalFiles(proposal.id);
7715
- await rm2(dir, { recursive: true, force: true });
7931
+ await rm3(dir, { recursive: true, force: true });
7716
7932
  for (const entry of taken.files) {
7717
- const target = path7.join(dir, ...entry.path.split("/"));
7718
- await ensureDir(path7.dirname(target));
7933
+ const target = path8.join(dir, ...entry.path.split("/"));
7934
+ await ensureDir(path8.dirname(target));
7719
7935
  await writeFile4(target, contents.get(entry.path) ?? Buffer.alloc(0));
7720
7936
  await chmod2(target, entry.executable ? 493 : 420);
7721
7937
  }
@@ -7806,7 +8022,7 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
7806
8022
  if (!isSafeRelativePath(entry.path)) {
7807
8023
  continue;
7808
8024
  }
7809
- const bytes = await readFile4(path7.join(dir, ...entry.path.split("/"))).catch(() => null);
8025
+ const bytes = await readFile4(path8.join(dir, ...entry.path.split("/"))).catch(() => null);
7810
8026
  files.push({
7811
8027
  ...entry,
7812
8028
  ...bytes === null ? { text: null, truncated: false } : skillFileText(bytes)
@@ -7955,7 +8171,7 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
7955
8171
  this.assertNoTurnCut(actor, `${ref} cannot be archived`, (turn) => turn.channel === channel && turn.scope === scope || turn.thread !== void 0 && closing.has(turn.thread));
7956
8172
  const threadsClosed = [];
7957
8173
  for (const file of await listFiles(threads)) {
7958
- const doc = await readMarkdown(path7.join(threads, file), ThreadFrontmatterSchema);
8174
+ const doc = await readMarkdown(path8.join(threads, file), ThreadFrontmatterSchema);
7959
8175
  if (doc.data.channel !== ref || doc.data.state !== "open") {
7960
8176
  continue;
7961
8177
  }
@@ -8087,7 +8303,7 @@ ${charter.purpose}
8087
8303
  const threads = this.paths.threads(slug);
8088
8304
  const threadsClosed = [];
8089
8305
  for (const file of await listFiles(threads)) {
8090
- const doc = await readMarkdown(path7.join(threads, file), ThreadFrontmatterSchema);
8306
+ const doc = await readMarkdown(path8.join(threads, file), ThreadFrontmatterSchema);
8091
8307
  if (doc.data.state !== "open") {
8092
8308
  continue;
8093
8309
  }
@@ -8162,10 +8378,10 @@ ${charter.purpose}
8162
8378
  ...project,
8163
8379
  members: project.members.filter((member) => member !== name)
8164
8380
  }));
8165
- const sessions = path7.join(this.paths.agentProject(name, slug), "sessions.json");
8381
+ const sessions = path8.join(this.paths.agentProject(name, slug), "sessions.json");
8166
8382
  if (await exists(sessions)) {
8167
8383
  const archived = `sessions.archived.${ts.replace(/[:.]/g, "-")}.json`;
8168
- await rename3(sessions, path7.join(this.paths.agentProject(name, slug), archived));
8384
+ await rename3(sessions, path8.join(this.paths.agentProject(name, slug), archived));
8169
8385
  }
8170
8386
  }
8171
8387
  this.tokenIndex.delete(current.tokenHash);
@@ -8506,7 +8722,7 @@ ${charter.purpose}
8506
8722
  if (since !== null && id <= since || upto !== void 0 && id > upto) {
8507
8723
  continue;
8508
8724
  }
8509
- const doc = await readMarkdown(path7.join(dir, file), MessageFrontmatterSchema);
8725
+ const doc = await readMarkdown(path8.join(dir, file), MessageFrontmatterSchema);
8510
8726
  messages.push({ ...doc.data, body: doc.body });
8511
8727
  }
8512
8728
  return messages;
@@ -8634,6 +8850,7 @@ var DEFAULT_TIMINGS = Object.freeze({
8634
8850
  homeConflictMs: 24 * 36e5,
8635
8851
  scaleCooldownMs: 36e5,
8636
8852
  reflectionMs: 24 * 36e5,
8853
+ sessionCapTokens: 3e5,
8637
8854
  transientRetryMs: [6e4, 3 * 6e4, 10 * 6e4]
8638
8855
  });
8639
8856
  var SchedulerTimingsSchema = z17.object({
@@ -8652,6 +8869,7 @@ var SchedulerTimingsSchema = z17.object({
8652
8869
  homeConflictMs: z17.number().positive(),
8653
8870
  scaleCooldownMs: z17.number().positive(),
8654
8871
  reflectionMs: z17.number().positive(),
8872
+ sessionCapTokens: z17.number().int().positive(),
8655
8873
  transientRetryMs: z17.array(z17.number().positive())
8656
8874
  }).partial();
8657
8875
  function parseTimings(input) {
@@ -8694,7 +8912,12 @@ var StateSchema = z17.object({
8694
8912
  costSinceReport: z17.number().nonnegative().default(0),
8695
8913
  turnsSinceReport: z17.number().int().nonnegative().default(0),
8696
8914
  /** When each member last reflected, or was first seen; the cadence counts from here. */
8697
- lastReflection: z17.record(z17.string(), z17.string()).default({})
8915
+ lastReflection: z17.record(z17.string(), z17.string()).default({}),
8916
+ /**
8917
+ * The home session each society member last asked for an early reflection for, once per
8918
+ * session, and whether that reflection still waits to be queued.
8919
+ */
8920
+ earlyReflection: z17.record(z17.string(), z17.object({ session: z17.string(), due: z17.boolean() })).default({})
8698
8921
  });
8699
8922
  function messageOf(trigger) {
8700
8923
  return trigger.kind === "mention" || trigger.kind === "user_post" ? trigger.messageId : void 0;
@@ -9038,6 +9261,7 @@ var Scheduler = class {
9038
9261
  const cost = payload["costUsd"];
9039
9262
  this.state.costSinceReport += typeof cost === "number" ? cost : 0;
9040
9263
  this.state.turnsSinceReport += 1;
9264
+ await this.checkSessionSize(event);
9041
9265
  return;
9042
9266
  }
9043
9267
  case "wake.requested": {
@@ -9766,9 +9990,44 @@ var Scheduler = class {
9766
9990
  item.dispatch = rest.length === 0 ? dispatch : { ...dispatch, crons: rest };
9767
9991
  }
9768
9992
  }
9993
+ /**
9994
+ * A society role's home session that a turn left at `sessionCapTokens` or more, by its latest
9995
+ * model call, asks for a reflection ahead of the cadence, which `checkReflections` queues once
9996
+ * none of the member's turns is queued or running; the reflection's end starts the home afresh
9997
+ * (`TurnHost`), so its history goes only once a reflection saved what to keep. A session asks
9998
+ * once, so a reflection that fails leaves the next to the cadence, and none asks while a
9999
+ * reflection is queued or running.
10000
+ */
10001
+ async checkSessionSize(event) {
10002
+ const { payload } = event;
10003
+ const tokens = payload["contextTokens"];
10004
+ const session = stringOf(payload["session"]);
10005
+ if (typeof tokens !== "number" || tokens < this.timings.sessionCapTokens || session === null || payload["project"] !== SOCIETY_SCOPE || payload["thread"] !== void 0 || payload["channel"] !== void 0 || payload["place"] !== void 0 || payload["trigger"] === "reflection" || payload["sessionDropped"] === true) {
10006
+ return;
10007
+ }
10008
+ const agent = await this.tryReadAgent(event.actor);
10009
+ if (agent === null || agent.status !== "active") {
10010
+ return;
10011
+ }
10012
+ const charter = await this.board.readRole(agent.role).catch(() => null);
10013
+ if (charter === null || !charter.societyScope || !charter.reflects) {
10014
+ return;
10015
+ }
10016
+ if (this.state.earlyReflection[agent.name]?.session === session) {
10017
+ return;
10018
+ }
10019
+ const ofAgent = (key) => parseSessionKey(key)?.agent === agent.name;
10020
+ const reflecting = [...this.pending].some(([key, item]) => ofAgent(key) && item.dispatch.trigger.kind === "reflection") || [...this.exclusive].some(([key, kind]) => ofAgent(key) && kind === "reflection");
10021
+ if (reflecting) {
10022
+ return;
10023
+ }
10024
+ this.state.earlyReflection[agent.name] = { session, due: true };
10025
+ this.log.info({ agent: agent.name, contextTokens: tokens, cap: this.timings.sessionCapTokens }, "home session past the cap; a reflection is due");
10026
+ }
9769
10027
  /**
9770
10028
  * Every `reflectionMs`, a member whose charter reflects takes a reflection turn in the scope of
9771
- * its latest working turn, unless it has not worked since its last reflection.
10029
+ * its latest working turn, unless it has not worked since its last reflection; a society member
10030
+ * whose home session passed the cap takes one in its home ahead of the cadence, which restarts it.
9772
10031
  */
9773
10032
  async checkReflections(now) {
9774
10033
  for (const agent of await this.board.listAgents()) {
@@ -9779,6 +10038,15 @@ var Scheduler = class {
9779
10038
  if (!charter.reflects) {
9780
10039
  continue;
9781
10040
  }
10041
+ const early = this.state.earlyReflection[agent.name];
10042
+ if (early?.due === true) {
10043
+ if (!this.busy(agent.name)) {
10044
+ this.state.lastReflection[agent.name] = iso(now);
10045
+ this.state.earlyReflection[agent.name] = { ...early, due: false };
10046
+ this.enqueue(agent.name, SOCIETY_SCOPE, { kind: "reflection", reason: "home session past the cap" }, now);
10047
+ }
10048
+ continue;
10049
+ }
9782
10050
  const last = this.state.lastReflection[agent.name];
9783
10051
  if (last === void 0) {
9784
10052
  this.state.lastReflection[agent.name] = iso(now);
@@ -9792,8 +10060,7 @@ var Scheduler = class {
9792
10060
  this.state.lastReflection[agent.name] = iso(now);
9793
10061
  continue;
9794
10062
  }
9795
- const busy = [...this.pending.keys(), ...this.running.keys()].some((key) => parseSessionKey(key)?.agent === agent.name);
9796
- if (busy) {
10063
+ if (this.busy(agent.name)) {
9797
10064
  continue;
9798
10065
  }
9799
10066
  this.state.lastReflection[agent.name] = iso(now);
@@ -9803,6 +10070,13 @@ var Scheduler = class {
9803
10070
  this.enqueue(agent.name, latest.scope, { kind: "reflection", reason: "scheduled reflection" }, now);
9804
10071
  }
9805
10072
  }
10073
+ /**
10074
+ * Whether a member has a turn queued or running anywhere, so a reflection waits for the next tick
10075
+ * rather than merge into a working turn or run beside turns writing the memory it consolidates.
10076
+ */
10077
+ busy(agent) {
10078
+ return [...this.pending.keys(), ...this.running.keys()].some((key) => parseSessionKey(key)?.agent === agent);
10079
+ }
9806
10080
  /** The member's most recent turn that was not itself a reflection, with the scope it ran in. */
9807
10081
  async latestWorkingTurn(agent) {
9808
10082
  let latest = null;
@@ -10249,6 +10523,10 @@ var Scheduler = class {
10249
10523
  this.exclusive.set(key, "onboarding");
10250
10524
  } else if (dispatch.trigger.kind === "reflection") {
10251
10525
  this.exclusive.set(key, "reflection");
10526
+ const early = this.state.earlyReflection[dispatch.agent];
10527
+ if (early?.due === true) {
10528
+ this.state.earlyReflection[dispatch.agent] = { ...early, due: false };
10529
+ }
10252
10530
  }
10253
10531
  }
10254
10532
  }
@@ -10382,7 +10660,7 @@ var TURN_CONTRACT = [
10382
10660
  "- Work only inside your worktree. In a task's conversation it is a worktree of its own on the task's branch `task/<id>`: commit your work there. The branch is checked out in one worktree at a time and kept for the turns of whoever holds the task's stage, so a turn of anyone else there, or one that starts while another turn has the branch, works detached at its tip, which Your workspace then says; its commits go on the branch by a fast-forward when a turn in the task ends with the branch free, and the task's thread is told of commits that no longer build on its tip. After each turn the runner commits what you left on the branch and lets it go for the next holder. Your home worktree is on your own branch, `agent/<name>`, for work tied to no task; nothing lands from it by itself. In a proposal's, a topic's, or a channel's conversation it is a worktree of its own, detached at the project's default branch, which goes when the thread closes or the channel is archived, so work that should last goes on a task's branch. Before each turn the runner fetches the project's remote and brings what holds no work of yours up to the default branch: your own branch when it has no commits of its own, a task's branch nobody has worked on, and a detached worktree with nothing uncommitted; what it leaves where it is, and why, is under Your workspace in the prompt. It never merges or commits for you.",
10383
10661
  "- Posts that would wake this conversation may arrive while you work, delivered into the turn under a heading that says so and formatted like the digest. They are part of this turn: act on them now, after the step in hand, or not at all, as with the digest. The user may also stop a turn; the next turn is told.",
10384
10662
  "- Silence is allowed. If the digest needs no reply, post nothing.",
10385
- "- To come back to something later, set a cron with create_cron: a note on what to do then, and a five-field cron expression or one time with at. It wakes you in this conversation unless you name another, at most every fifteen minutes, and every fire is a paid turn, so end a cron with remove_cron once it has done its job. Never wait by holding a stage, sleeping, or leaving a process running: a turn's board token ends with the turn, so nothing it leaves behind can act on the board. The society's crons are under society/crons/ in the board projection.",
10663
+ "- To come back to something later, set a cron with create_cron: a note on what to do then, and a five-field cron expression or one time with at. It wakes you in this conversation unless you name another, at most every fifteen minutes, and every fire is a paid turn, so end a cron with remove_cron once it has done its job. Never wait by sleeping or leaving a process running: a turn's board token ends with the turn, so nothing it leaves behind can act on the board. While you check your own stage's work on a schedule, such as a running job, CI, or a deploy, keep holding the stage: the cron sets when you check, and your turns in the task's thread renew its lease. Release a stage only to hand it on, or once its task's blocked_by names the tasks it waits on: the stage then wakes nobody until the last of them ends. Any other wait on someone else, such as the user or an outside party, is a stage of its own, not a held stage. The society's crons are under society/crons/ in the board projection.",
10386
10664
  "- An @mention wakes the citizen named, and every wake costs a turn. Address someone with @ only when you need them to act; when you merely refer to citizens, write their names plainly. A name in a code span or a code block is no mention, so code keeps its @ as written. @user wakes nobody, since the user takes no turns; it is what puts a question before the user, so use it only when you need the user to answer or decide.",
10387
10665
  "- A thread is a conversation off a channel that reaches only its participants and whoever is mentioned in it. Every task has one, opened with it under the task's id on the channel of its project it was filed in, general unless it was filed in another: talk about a task there, with post_message and its thread_id, and the notes you give advance_task and update_task are posted there as you. Its participants are the task's creator, its stage holders and assignees, whoever may take the stage that waits, and anyone who posted; it closes when the task ends. Every proposal has one too, in governance, opened with its pitch and closed by its decision. Open other threads with open_thread on a channel with a title, for any other topic; close_thread posts your summary to its channel.",
10388
10666
  "- To show an image in a post, such as a screenshot or a chart, save it anywhere you can read it, such as a folder from `mktemp -d`, call upload_image with its path, and put the markdown it returns in the post, `![what it shows](image:<id>)`, with what the image shows as its alt text: the board keeps the image, so it shows in any conversation after the file is gone. It takes a PNG, JPEG, GIF, or WebP of at most 8 MB from your worktree, your home, or the system temp folder. An image committed on a task's branch, linked by its path in the task's worktree, still shows in place, and so does one at an https address. Never put an image or any other file into a message as base64 or a data: URL: the board does not show it, and every reader of the conversation gets it in full in their prompt.",
@@ -10399,7 +10677,7 @@ var PLANNING = [
10399
10677
  "- Name stages by what gets done. Plan the next few steps rather than everything, and reshape with plan_task as the work teaches you.",
10400
10678
  "- Assign a stage to a role when anyone in it could do it, to a citizen only when it must be them, and to nobody when anyone in the project could. A citizen named on a stage must be a member of the task's project; add them first. A stage wakes everyone it is open to when it comes up, so the narrower the assignee, the fewer turns it costs.",
10401
10679
  "- Reshape rather than force: another round is an inserted stage, and a wait on something outside the society is a stage named for what it waits on. When a check finds work unfinished, the holder of the current stage sends the task back to an earlier stage with update_task, and so may the user, the steward, and the concierge; anyone else asks the concierge in the task's thread.",
10402
- "- A task that cannot start until other tasks finish names them with update_task blocked_by. While any of them is in play, its stage wakes nobody, neither when it comes up nor on a heartbeat; once the last has ended, done or abandoned, the stage wakes as if it had just come up, or wakes its holder. Nobody needs to clear the block or mention anyone when they end; change blocked_by only when the plan changes.",
10680
+ "- A task that cannot start until other tasks finish names them with update_task blocked_by, and whoever holds its stage then releases it, since the heartbeat still wakes a holder. While any of them is in play, a stage nobody holds wakes nobody, neither when it comes up nor on a heartbeat; once the last has ended, done or abandoned, the stage wakes as if it had just come up, or wakes its holder. Nobody needs to clear the block or mention anyone when they end; change blocked_by only when the plan changes.",
10403
10681
  "- A gated stage is an independent check: nobody who held an earlier stage of the task may hold it, and only the user, the steward, and the concierge may add, remove, move, reassign, or ungate it. Ask them in the task's thread when a gate should change.",
10404
10682
  "- Commit your work on the task's branch before you advance, and say what you did in the advance note; it is the handover the next holder reads in the task's thread."
10405
10683
  ].join("\n");
@@ -10467,9 +10745,10 @@ var REFLECTION_STEPS = [
10467
10745
  "- Consolidate memory/core.md: keep it to lessons that still hold, about sixty lines at most, and move detail worth keeping to memory/<topic>.md in your home, which the board's search covers for you alone.",
10468
10746
  '- Extract a skill: a procedure you have followed twice is a candidate, and one that makes a citizen better at a kind of work on any project goes to skills/<name>/SKILL.md, frontmatter with `name` and a one-line `description` and then the steps, with any script or reference it uses in the same folder. Propose it with kind "skill" when more than one citizen would use it and nothing in it is tied to one place; one only you use stays yours.',
10469
10747
  "- Refresh profile.md: one paragraph on what you do well and what to send your way; the roster the front desk routes with is built from it.",
10470
- "- Share: how a codebase behaves goes in its AGENTS.md or README through a task like any change to the code, so propose it in that task's thread or the project channel; a durable fact about this scope as a place (runners, access, flow, where results live) goes through write_knowledge; a norm the whole society should follow goes to the steward as a plain post in general.",
10471
- "- Report memoryUpdated: true in the status object when core.md or a skill changed."
10748
+ "- Share: how a codebase behaves goes in its AGENTS.md or README through a task like any change to the code, so propose it in that task's thread or the project channel; a durable fact about this scope as a place (runners, access, flow, where results live) goes through write_knowledge; a norm the whole society should follow goes to the steward as a plain post in general."
10472
10749
  ];
10750
+ var SAVE_WHAT_IS_OPEN = "- This conversation's session ends with this turn, and the next turn here starts a new one that knows only your home's files and the board: write anything still open into memory/core.md, or the archive it points to, before you end.";
10751
+ var REFLECTION_REPORT = "- Report memoryUpdated: true in the status object when core.md or a skill changed.";
10473
10752
  var MAX_BODY_CHARS = 1500;
10474
10753
  var MAX_PROFILE_CHARS = 200;
10475
10754
  function clip(text) {
@@ -10654,7 +10933,7 @@ function buildTurnPrompt(input) {
10654
10933
  lines.push("", WORKSPACE_TOKEN);
10655
10934
  const conflicts = input.conflicts ?? [];
10656
10935
  if (dispatch.trigger.kind === "reflection") {
10657
- lines.push("", "## Reflection", "", REFLECTION_INTRO, ...conflicts.length > 0 ? [RECONCILE] : [], ...REFLECTION_STEPS);
10936
+ lines.push("", "## Reflection", "", REFLECTION_INTRO, ...conflicts.length > 0 ? [RECONCILE] : [], ...REFLECTION_STEPS, ...input.afreshAfterReflection === true ? [SAVE_WHAT_IS_OPEN] : [], REFLECTION_REPORT);
10658
10937
  }
10659
10938
  if (input.onboarding !== null) {
10660
10939
  lines.push("", "## First turn", "", renderOnboardingPreamble(input.onboarding));
@@ -10668,6 +10947,9 @@ function buildTurnPrompt(input) {
10668
10947
  "Its CLI could not resume this conversation's session, so the session was dropped and this turn starts a new one: nothing of your earlier turns here carries over but what is on the board and in your home."
10669
10948
  ] : [], "The worktree may hold uncommitted changes. Run git status before doing anything else.");
10670
10949
  }
10950
+ if (last?.trigger.kind === "reflection" && last.exitReason === "completed" && last.sessionDropped === true) {
10951
+ lines.push("", "## Your session started afresh", "", "Your last turn here was a reflection, and this conversation's session ended with it, so this turn starts a new one: what you kept is in memory/core.md and the archive it points to, and what your earlier turns did is on the board, in your posts and the threads and tasks you worked in, which search and the board's files reach.");
10952
+ }
10671
10953
  const society = input.societyView;
10672
10954
  if (society !== void 0 && society !== null) {
10673
10955
  lines.push("", "## The society", "", `Each ask is a conversation of its own, and others may be in a turn at the same time. Before proposing a citizen, a role, or a project, look at the proposals still waiting under ${PATH_TOKENS.board}/society/proposals/: another ask may have proposed it already.`, "", "### Projects", "");
@@ -10684,7 +10966,7 @@ function buildTurnPrompt(input) {
10684
10966
  }
10685
10967
  const machines = input.runners;
10686
10968
  if (machines !== void 0 && machines !== null) {
10687
- lines.push("", "## Runners", "", "The machines turns run on. A project lives on the runner that takes its first turn, one that offers every capability the project and that turn's task require, and never moves. A task's required_capabilities must name capabilities exactly as a runner below offers them; a task needing one its project's runner lacks waits. A runner's quota is what each CLI's login there last reported of its plan, in percent of each window; a CLI limited until a time fails its turns there until then.", "");
10969
+ lines.push("", "## Runners", "", "The machines turns run on. A project lives on the runner that takes its first turn, one that offers every capability the project and that turn's task require, and moves only when the user moves it. A task's required_capabilities must name capabilities exactly as a runner below offers them; a task needing one its project's runner lacks waits. A runner's quota is what each CLI's login there last reported of its plan, in percent of each window; a CLI limited until a time fails its turns there until then.", "");
10688
10970
  if (machines.runners.length === 0) {
10689
10971
  lines.push("None registered yet.");
10690
10972
  }
@@ -10907,7 +11189,10 @@ var TurnHost = class {
10907
11189
  const newSession = recorded === void 0;
10908
11190
  const lastTurn = await this.board.readLastTurn(agent.name, dispatch.project, part);
10909
11191
  const costSoFarUsd = !newSession && lastTurn?.session === recorded ? lastTurn.sessionCostUsd ?? lastTurn.costUsd : 0;
11192
+ const usageSoFar = !newSession && lastTurn?.session === recorded ? lastTurn.sessionUsage : void 0;
10910
11193
  const resumeRetries = !newSession && lastTurn?.session === recorded && lastTurn.resumeFailed === "retry" ? lastTurn.resumeRetries ?? 1 : 0;
11194
+ const afreshAfterReflection = charter.societyScope && agent.role !== USER_ROLE && societyScope && part === void 0;
11195
+ const afterReflection = lastTurn?.trigger.kind === "reflection" && lastTurn.sessionDropped === true;
10911
11196
  const digest = await this.board.readDigest({
10912
11197
  ...actor,
10913
11198
  scope: dispatch.project,
@@ -10919,7 +11204,7 @@ var TurnHost = class {
10919
11204
  const messages = threadSoFar ?? digest.messages;
10920
11205
  const cursorAfter = threadSoFar?.at(-1)?.id ?? digest.cursor;
10921
11206
  const held = task === null ? [] : (await this.board.heldClaims(agent.name)).filter((each) => each.id === task.id);
10922
- const onboarding = dispatch.onboarding || newSession && part === void 0 ? {
11207
+ const onboarding = dispatch.onboarding || newSession && part === void 0 && !afterReflection ? {
10923
11208
  agentName: agent.name,
10924
11209
  roleSummary: charter.purpose,
10925
11210
  project: dispatch.project,
@@ -10998,6 +11283,7 @@ var TurnHost = class {
10998
11283
  project,
10999
11284
  lastTurn,
11000
11285
  onboarding,
11286
+ afreshAfterReflection,
11001
11287
  societyView,
11002
11288
  runners: runners2,
11003
11289
  knowledge,
@@ -11010,7 +11296,8 @@ var TurnHost = class {
11010
11296
  const record = await this.board.beginTurn({
11011
11297
  ...base,
11012
11298
  session: recorded ?? null,
11013
- sessionCostUsd: costSoFarUsd
11299
+ sessionCostUsd: costSoFarUsd,
11300
+ ...usageSoFar === void 0 ? {} : { sessionUsage: usageSoFar }
11014
11301
  });
11015
11302
  if (record.id === void 0) {
11016
11303
  throw new Error("the board gave the turn no id");
@@ -11028,6 +11315,7 @@ var TurnHost = class {
11028
11315
  ...place === void 0 ? {} : { place },
11029
11316
  session: recorded ?? null,
11030
11317
  costSoFarUsd,
11318
+ ...usageSoFar === void 0 ? {} : { usageSoFar },
11031
11319
  instructions,
11032
11320
  prompt,
11033
11321
  mcp: { url: this.mcpUrl, token },
@@ -11057,6 +11345,7 @@ var TurnHost = class {
11057
11345
  transcript: [],
11058
11346
  coldToken: residentKey === null ? token : null,
11059
11347
  residentKey: residentKey === null ? null : `${runner.name}|${residentKey}`,
11348
+ afreshAfterReflection,
11060
11349
  keepLeases,
11061
11350
  done,
11062
11351
  fail
@@ -11220,8 +11509,10 @@ var TurnHost = class {
11220
11509
  const part = conversationPart(job);
11221
11510
  const resumeRetries = outcome.resumeFailed === "retry" ? turn.resumeRetries + 1 : void 0;
11222
11511
  const dropSession = job.session !== null && (outcome.resumeFailed === "lost" || (resumeRetries ?? 0) >= RESUME_RETRIES);
11512
+ const reflected = turn.afreshAfterReflection && turn.record.trigger.kind === "reflection" && outcome.exitReason === "completed";
11513
+ const { sessionUsage: _started, ...begun } = turn.record;
11223
11514
  const finished = {
11224
- ...turn.record,
11515
+ ...begun,
11225
11516
  session: outcome.session,
11226
11517
  endedAt: this.now().toISOString(),
11227
11518
  exitReason: outcome.exitReason,
@@ -11230,6 +11521,8 @@ var TurnHost = class {
11230
11521
  usage: outcome.usage,
11231
11522
  costUsd: outcome.costUsd,
11232
11523
  sessionCostUsd: outcome.sessionCostUsd ?? turn.costSoFarUsd,
11524
+ ...outcome.sessionUsage === void 0 ? {} : { sessionUsage: outcome.sessionUsage },
11525
+ ...outcome.contextTokens === void 0 ? {} : { contextTokens: outcome.contextTokens },
11233
11526
  toolCalls: turn.transcript.filter((entry) => entry.event.type === "tool_call").length,
11234
11527
  model: outcome.model ?? turn.configuredModel,
11235
11528
  ...outcome.work === null ? {} : { work: outcome.work },
@@ -11237,7 +11530,7 @@ var TurnHost = class {
11237
11530
  ...outcome.resumeFailed === void 0 ? {} : { resumeFailed: outcome.resumeFailed },
11238
11531
  ...outcome.transient === true ? { transient: true } : {},
11239
11532
  ...resumeRetries === void 0 ? {} : { resumeRetries },
11240
- ...dropSession ? { sessionDropped: true } : {}
11533
+ ...dropSession || reflected ? { sessionDropped: true } : {}
11241
11534
  };
11242
11535
  const ended = await this.conversationEnded(job);
11243
11536
  const closing = turn.record.trigger.kind === "closing";
@@ -11255,6 +11548,11 @@ var TurnHost = class {
11255
11548
  resumeFailed: outcome.resumeFailed,
11256
11549
  resumeRetries
11257
11550
  }, "dropped a session its CLI could not resume");
11551
+ } else if (reflected) {
11552
+ if (job.session !== null) {
11553
+ await this.board.dropSession(job.agent, job.scope, job.cli, job.session, part);
11554
+ }
11555
+ this.log.info({ agent: job.agent, project: job.scope, session: outcome.session }, "dropped a home session after its reflection");
11258
11556
  } else if (outcome.session !== null && outcome.session !== job.session) {
11259
11557
  await this.board.writeSession(job.agent, job.scope, job.cli, outcome.session, turn.runner, part);
11260
11558
  }
@@ -11550,6 +11848,8 @@ var RunnerProtocolError = class extends Error {
11550
11848
  };
11551
11849
  var RunnerAwayError = class extends Error {
11552
11850
  };
11851
+ var MoveSourceAwayError = class extends Error {
11852
+ };
11553
11853
  var SILENT2 = { info() {
11554
11854
  }, warn() {
11555
11855
  }, error() {
@@ -11582,6 +11882,12 @@ var RunnerHub = class {
11582
11882
  /** Disconnects being recorded, which `close` waits for. */
11583
11883
  detaching = /* @__PURE__ */ new Set();
11584
11884
  runnerListeners = /* @__PURE__ */ new Set();
11885
+ /** The project being moved, by slug: none of its turns is placed and nothing lands there until it ends. */
11886
+ moving = /* @__PURE__ */ new Map();
11887
+ /** Turns of each project placed on a runner and not yet opened, which the board cannot see yet. */
11888
+ starting = /* @__PURE__ */ new Map();
11889
+ /** Moves started since the hub did, so a placement that a move overtook gives its turn back. */
11890
+ moveStarts = 0;
11585
11891
  constructor(options) {
11586
11892
  this.board = options.board;
11587
11893
  this.host = options.host;
@@ -11853,6 +12159,7 @@ var RunnerHub = class {
11853
12159
  return accepted ? "sent" : "refused";
11854
12160
  }
11855
12161
  async assign(dispatch) {
12162
+ const starts = this.moveStarts;
11856
12163
  const agent = await this.board.readAgent(dispatch.agent).catch(() => null);
11857
12164
  if (agent === null) {
11858
12165
  return { refused: `no agent ${dispatch.agent}` };
@@ -11869,6 +12176,9 @@ var RunnerHub = class {
11869
12176
  if (dispatch.project === SOCIETY_SCOPE) {
11870
12177
  chosen = await this.pinnedSeat(agent.name, agent.homeRunner, cli, free);
11871
12178
  } else {
12179
+ if (this.moving.has(dispatch.project)) {
12180
+ return null;
12181
+ }
11872
12182
  const project = await this.board.readProject(dispatch.project);
11873
12183
  let home = project.runner;
11874
12184
  if (home === void 0) {
@@ -11895,7 +12205,13 @@ var RunnerHub = class {
11895
12205
  if (chosen === void 0) {
11896
12206
  return null;
11897
12207
  }
12208
+ if (dispatch.project !== SOCIETY_SCOPE && (this.moving.has(dispatch.project) || this.moveStarts !== starts)) {
12209
+ return null;
12210
+ }
11898
12211
  chosen.inUse += 1;
12212
+ if (dispatch.project !== SOCIETY_SCOPE) {
12213
+ this.starting.set(dispatch.project, (this.starting.get(dispatch.project) ?? 0) + 1);
12214
+ }
11899
12215
  return { runner: chosen.name };
11900
12216
  }
11901
12217
  async runTurn(dispatch, assignment) {
@@ -11904,11 +12220,23 @@ var RunnerHub = class {
11904
12220
  }
11905
12221
  const seat = this.seats.get(assignment.runner);
11906
12222
  let turnId = null;
12223
+ let placed = dispatch.project !== SOCIETY_SCOPE;
12224
+ const opening = () => {
12225
+ if (placed) {
12226
+ placed = false;
12227
+ const left = (this.starting.get(dispatch.project) ?? 1) - 1;
12228
+ if (left > 0) {
12229
+ this.starting.set(dispatch.project, left);
12230
+ } else {
12231
+ this.starting.delete(dispatch.project);
12232
+ }
12233
+ }
12234
+ };
11907
12235
  try {
11908
12236
  if (seat === void 0) {
11909
12237
  return await this.host.refuseTurn(dispatch, assignment.runner, "the runner is gone");
11910
12238
  }
11911
- const opened = await this.host.openTurn(dispatch, this.seatView(seat));
12239
+ const opened = await this.host.openTurn(dispatch, this.seatView(seat)).finally(opening);
11912
12240
  if ("failed" in opened) {
11913
12241
  return opened.failed;
11914
12242
  }
@@ -11920,6 +12248,7 @@ var RunnerHub = class {
11920
12248
  turnId = opened.job.turnId;
11921
12249
  return await opened.ended;
11922
12250
  } finally {
12251
+ opening();
11923
12252
  if (seat !== void 0) {
11924
12253
  seat.inUse = Math.max(0, seat.inUse - 1);
11925
12254
  if (turnId !== null) {
@@ -11952,6 +12281,9 @@ var RunnerHub = class {
11952
12281
  });
11953
12282
  return "done";
11954
12283
  }
12284
+ if (this.moving.has(project)) {
12285
+ return "deferred";
12286
+ }
11955
12287
  let outcome = { ok: true, detail: "nothing to land, since the task left no branch" };
11956
12288
  if (record.runner !== void 0) {
11957
12289
  const seat = this.seats.get(record.runner);
@@ -12004,6 +12336,9 @@ var RunnerHub = class {
12004
12336
  if (task.status !== "abandoned" || task.onDone !== "ghpr" || pull === void 0 || pull.closed !== void 0) {
12005
12337
  return "done";
12006
12338
  }
12339
+ if (this.moving.has(project)) {
12340
+ return "deferred";
12341
+ }
12007
12342
  const record = await this.board.readProject(project);
12008
12343
  let outcome = {
12009
12344
  ok: false,
@@ -12036,16 +12371,171 @@ var RunnerHub = class {
12036
12371
  });
12037
12372
  return "done";
12038
12373
  }
12374
+ /**
12375
+ * Moves a project to another runner, which only the user does, carrying every branch and tag of
12376
+ * its repository: the project's turns, landings, and pull-request closes wait while it runs.
12377
+ * The old runner pushes to a repository on the server once no worktree of the project holds work
12378
+ * no branch does, the new runner clones it, and the board records the new runner only once that
12379
+ * runner holds every branch and tag at the commit the old one had; until then the project works
12380
+ * where it was. The server's repository goes as the move ends, and the old runner then removes
12381
+ * its copy. With the old runner away nothing can travel, so the move goes ahead only when the
12382
+ * user says `withoutBranches`, and the branches stay there.
12383
+ */
12384
+ async moveProject(actor, slug, to, options = {}) {
12385
+ const under = [...this.moving.keys()];
12386
+ if (under.length > 0) {
12387
+ throw new BoardError("INVALID_STATE", `a move of ${under.join(", ")} is under way; try again once it ends`);
12388
+ }
12389
+ const move = { id: null, from: null, to, phase: "checking" };
12390
+ this.moving.set(slug, move);
12391
+ this.moveStarts += 1;
12392
+ let source;
12393
+ let moved;
12394
+ try {
12395
+ if ((this.starting.get(slug) ?? 0) > 0) {
12396
+ throw new BoardError("INVALID_STATE", `a turn of ${slug} is starting; try again once it has started and ended`);
12397
+ }
12398
+ const { project, inPlay: inPlay2 } = await this.board.prepareProjectMove(actor, slug, to);
12399
+ const from = project.runner;
12400
+ move.from = from;
12401
+ const target = this.seats.get(to);
12402
+ if (target === void 0 || target.send === null) {
12403
+ throw new BoardError("INVALID_STATE", `runner ${to} is not connected`);
12404
+ }
12405
+ await this.assertCanHost(target, project, inPlay2);
12406
+ source = this.seats.get(from);
12407
+ if (source === void 0 || source.send === null) {
12408
+ if (options.withoutBranches !== true) {
12409
+ throw new MoveSourceAwayError(sourceAwayMessage(project, from, to, inPlay2));
12410
+ }
12411
+ move.phase = "recording";
12412
+ const recorded2 = await this.board.recordProjectMove(actor, {
12413
+ slug,
12414
+ from,
12415
+ to,
12416
+ carried: null
12417
+ });
12418
+ this.log.info({ project: slug, from, to }, "project moved without its branches");
12419
+ return { project: recorded2, carried: null, released: null };
12420
+ }
12421
+ if (options.withoutBranches === true) {
12422
+ throw new BoardError("INVALID_STATE", `runner ${from}, where ${slug} lives, is connected, so the move carries its branches`);
12423
+ }
12424
+ if (!source.hello.projectMoves) {
12425
+ throw new BoardError("INVALID_STATE", `runner ${from}, where ${slug} lives, cannot carry a project yet; upgrade it`);
12426
+ }
12427
+ const repo = projectRepo(project);
12428
+ move.id = await this.board.openMoveRepo();
12429
+ const id = move.id;
12430
+ move.phase = "export";
12431
+ const exported = ProjectExportedSchema.parse(await this.request(source, (request) => ({
12432
+ type: "export",
12433
+ request,
12434
+ export: { move: id, repo }
12435
+ })).catch((error) => {
12436
+ throw new BoardError("INVALID_STATE", `${from} could not send ${slug}: ${describe(error)}`);
12437
+ }));
12438
+ if (!exported.ok) {
12439
+ throw new BoardError("INVALID_STATE", exported.held.length === 0 ? `${slug}'s repository on ${from} is ${mebibytes(exported.bytes)}, more than a move carries (${mebibytes(MOVE_PACK_LIMIT_BYTES)})` : `${slug} cannot be moved while work no branch holds sits in its worktrees on ${from}: ${exported.held.map(heldLine).join("; ")}. Have each citizen commit it or clear it, then move again`);
12440
+ }
12441
+ move.phase = "import";
12442
+ const imported = ProjectImportedSchema.parse(await this.request(target, (request) => ({
12443
+ type: "import",
12444
+ request,
12445
+ import: { move: id, repo, pullRequests: project.onDone === "ghpr" }
12446
+ })).catch((error) => {
12447
+ throw new BoardError("INVALID_STATE", `${to} could not take ${slug}: ${describe(error)}`);
12448
+ }));
12449
+ move.phase = "recording";
12450
+ const differ = differentRefs(exported.refs, imported.refs);
12451
+ if (differ.length > 0) {
12452
+ await this.releaseOn(target, slug);
12453
+ throw new BoardError("INVALID_STATE", `${slug} arrived on ${to} unlike it left ${from} (${differ.slice(0, 5).join(", ")}${differ.length > 5 ? ", \u2026" : ""}); it stays on ${from}`);
12454
+ }
12455
+ const carried = Object.keys(exported.refs).length;
12456
+ const recorded = await this.board.recordProjectMove(actor, { slug, from, to, carried }).catch(async (error) => {
12457
+ await this.releaseOn(target, slug);
12458
+ throw error;
12459
+ });
12460
+ this.log.info({ project: slug, from, to, carried }, "project moved");
12461
+ moved = { project: recorded, carried };
12462
+ } finally {
12463
+ this.moving.delete(slug);
12464
+ if (move.id !== null) {
12465
+ const id = move.id;
12466
+ await this.board.dropMoveRepo(id).catch((error) => {
12467
+ this.log.warn({ move: id, error: String(error) }, "could not remove a move's repository");
12468
+ });
12469
+ }
12470
+ }
12471
+ return { ...moved, released: await this.releaseOn(source, slug) };
12472
+ }
12473
+ /**
12474
+ * Whether a runner may reach a move's repository on the server: the old runner to push while it
12475
+ * exports, the new one to fetch while it imports. `unknown` for a move that is not under way.
12476
+ */
12477
+ moveAccess(runner, id, push) {
12478
+ const move = [...this.moving.values()].find((each) => each.id !== null && each.id === id);
12479
+ if (move === void 0) {
12480
+ return "unknown";
12481
+ }
12482
+ const allowed = push ? move.phase === "export" && runner === move.from : move.phase === "import" && runner === move.to;
12483
+ return allowed ? "allowed" : "refused";
12484
+ }
12485
+ /** Refuses a runner that cannot take a project's turns: its members' CLIs, the capabilities, a git identity. */
12486
+ async assertCanHost(seat, project, inPlay2) {
12487
+ if (!seat.hello.projectMoves) {
12488
+ throw new BoardError("INVALID_STATE", `runner ${seat.name} cannot take a project yet; upgrade it`);
12489
+ }
12490
+ const lacking = [];
12491
+ for (const name of project.members) {
12492
+ const agent = await this.board.readAgent(name).catch(() => null);
12493
+ if (agent !== null && agent.status === "active" && agent.cli !== null && !seat.hello.clis.includes(agent.cli)) {
12494
+ lacking.push(`${agent.cli} for ${agent.name}`);
12495
+ }
12496
+ }
12497
+ const needs = /* @__PURE__ */ new Set([
12498
+ ...project.requiredCapabilities,
12499
+ ...inPlay2.flatMap((task) => task.requiredCapabilities)
12500
+ ]);
12501
+ const missing = [...needs].filter((each) => !seat.hello.capabilities.includes(each));
12502
+ if (missing.length > 0) {
12503
+ lacking.push(`the ${missing.length === 1 ? "capability" : "capabilities"} ${missing.join(", ")}`);
12504
+ }
12505
+ const identity = seat.hello.gitIdentityMissing ?? [];
12506
+ if (identity.length > 0) {
12507
+ lacking.push(`a git ${identity.join(" and ")} for citizens' commits`);
12508
+ }
12509
+ if (lacking.length > 0) {
12510
+ throw new BoardError("INVALID_STATE", `runner ${seat.name} cannot take ${project.slug}: it lacks ${lacking.join("; ")}`);
12511
+ }
12512
+ }
12513
+ /** Asks a runner to remove its copy of a project that lives elsewhere now; how that went, never a throw. */
12514
+ async releaseOn(seat, slug) {
12515
+ if (seat === void 0) {
12516
+ return null;
12517
+ }
12518
+ try {
12519
+ const released = ProjectReleasedSchema.parse(await this.request(seat, (request) => ({ type: "release", request, release: { slug } }), SWEEP_TIMEOUT_MS));
12520
+ return {
12521
+ ok: true,
12522
+ detail: `${seat.name} removed ${released.worktrees} worktree(s)${released.clone ? " and its clone" : ""}`
12523
+ };
12524
+ } catch (error) {
12525
+ this.log.warn({ runner: seat.name, project: slug, error: String(error) }, "could not release a project");
12526
+ return { ok: false, detail: `${seat.name} kept its copy of ${slug}: ${describe(error)}` };
12527
+ }
12528
+ }
12039
12529
  /**
12040
12530
  * One path on a branch of a project's repository, read on the runner the project lives on.
12041
12531
  * NOT_FOUND when the project has no runner yet or the branch or the path does not exist, and
12042
12532
  * `RunnerAwayError` while its runner is away.
12043
12533
  */
12044
- async readBranch(project, branch, path11) {
12534
+ async readBranch(project, branch, path12) {
12045
12535
  const { seat, repo } = await this.projectSeat(project, "branchReads", "read files");
12046
- const file = BranchFileSchema.nullable().parse(await this.request(seat, (request) => ({ type: "file", request, read: { repo, branch, path: path11 } }), FILE_TIMEOUT_MS));
12536
+ const file = BranchFileSchema.nullable().parse(await this.request(seat, (request) => ({ type: "file", request, read: { repo, branch, path: path12 } }), FILE_TIMEOUT_MS));
12047
12537
  if (file === null) {
12048
- throw new BoardError("NOT_FOUND", `${branch} has no ${path11 === "" ? "commit" : path11}`);
12538
+ throw new BoardError("NOT_FOUND", `${branch} has no ${path12 === "" ? "commit" : path12}`);
12049
12539
  }
12050
12540
  return file;
12051
12541
  }
@@ -12065,11 +12555,11 @@ var RunnerHub = class {
12065
12555
  * What a branch of a project's repository did to one file since it left the default branch, read
12066
12556
  * on the runner the project lives on, with the same refusals as `branchChanges`.
12067
12557
  */
12068
- async branchDiff(project, branch, path11) {
12558
+ async branchDiff(project, branch, path12) {
12069
12559
  const { seat, repo } = await this.projectSeat(project, "branchChanges", "list changes");
12070
- const diff = BranchFileDiffSchema.nullable().parse(await this.request(seat, (request) => ({ type: "diff", request, read: { repo, branch, path: path11 } }), FILE_TIMEOUT_MS));
12560
+ const diff = BranchFileDiffSchema.nullable().parse(await this.request(seat, (request) => ({ type: "diff", request, read: { repo, branch, path: path12 } }), FILE_TIMEOUT_MS));
12071
12561
  if (diff === null) {
12072
- throw new BoardError("NOT_FOUND", `${branch} did not change ${path11}`);
12562
+ throw new BoardError("NOT_FOUND", `${branch} did not change ${path12}`);
12073
12563
  }
12074
12564
  return diff;
12075
12565
  }
@@ -12306,6 +12796,32 @@ var RunnerHub = class {
12306
12796
  });
12307
12797
  }
12308
12798
  };
12799
+ function describe(error) {
12800
+ return error instanceof Error ? error.message : String(error);
12801
+ }
12802
+ function mebibytes(bytes) {
12803
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
12804
+ }
12805
+ function heldLine(held) {
12806
+ const where = held.conversation === null ? "its own worktree" : held.conversation.kind === "thread" ? `thread ${held.conversation.id}` : held.conversation.kind === "channel" ? `channel ${held.conversation.name}` : `the channels of ${held.conversation.project}`;
12807
+ const parts = [
12808
+ held.uncommitted > 0 ? `${held.uncommitted} uncommitted file(s)` : "",
12809
+ held.onNoBranch > 0 ? `${held.onNoBranch} commit(s) on no branch` : ""
12810
+ ].filter((part) => part !== "");
12811
+ return `${held.agent}, ${where}: ${parts.join(" and ")}`;
12812
+ }
12813
+ function differentRefs(left, right) {
12814
+ const names = /* @__PURE__ */ new Set([...Object.keys(left), ...Object.keys(right)]);
12815
+ return [...names].filter((name) => left[name] !== right[name]).toSorted();
12816
+ }
12817
+ function sourceAwayMessage(project, from, to, inPlay2) {
12818
+ const stays = [
12819
+ "its local branches and worktrees",
12820
+ project.repo === null ? `its whole history, since ${project.slug} has no remote (${to} would start an empty repository)` : "",
12821
+ inPlay2.length === 0 ? "" : `the work of ${inPlay2.length} task(s) in play (${inPlay2.map((task) => task.id).join(", ")})`
12822
+ ].filter((part) => part !== "");
12823
+ return `runner ${from}, where ${project.slug} lives, is not connected, so nothing can travel. Staying on ${from}: ${stays.join("; ")}. Move it without them to go ahead`;
12824
+ }
12309
12825
 
12310
12826
  // apps/server/dist/index.js
12311
12827
  import pino from "pino";
@@ -12381,6 +12897,7 @@ import { z as z19, ZodError } from "zod";
12381
12897
  // apps/server/dist/body-limit.js
12382
12898
  import { bodyLimit } from "hono/body-limit";
12383
12899
  var ENROLL_BODY_LIMIT_BYTES = 16 * 1024;
12900
+ var APP_TOKEN_BODY_LIMIT_BYTES = 16 * 1024;
12384
12901
  var API_BODY_LIMIT_BYTES = 1024 * 1024;
12385
12902
  var RUNNER_BODY_LIMIT_BYTES = 8 * 1024 * 1024;
12386
12903
  var RUNNER_ANSWER_LIMIT_BYTES = 16 * 1024 * 1024;
@@ -12398,13 +12915,19 @@ function limitBody(maxSize) {
12398
12915
  }
12399
12916
 
12400
12917
  // apps/server/dist/github-auth.js
12401
- import { randomBytes as randomBytes2 } from "node:crypto";
12918
+ import { createHash as createHash3, randomBytes as randomBytes2, timingSafeEqual } from "node:crypto";
12402
12919
  import { Hono } from "hono";
12403
12920
  import { z as z18 } from "zod";
12404
12921
  var SIGN_IN_TTL_MS = 30 * 24 * 36e5;
12405
12922
  var STATE_TTL_MS = 10 * 6e4;
12406
12923
  var MAX_STATES = 1e3;
12924
+ var CODE_TTL_MS = 2 * 6e4;
12407
12925
  var TokenAnswerSchema = z18.object({ access_token: z18.string().min(1) });
12926
+ var CodeChallengeSchema = z18.string().regex(/^[A-Za-z0-9_-]{43}$/);
12927
+ var CodeExchangeSchema = z18.object({
12928
+ code: z18.string().min(1).max(64),
12929
+ code_verifier: z18.string().regex(/^[A-Za-z0-9._~-]{43,128}$/)
12930
+ });
12408
12931
  var GithubUserSchema = z18.object({
12409
12932
  login: z18.string().min(1),
12410
12933
  id: z18.number().int(),
@@ -12418,30 +12941,67 @@ function githubAvatar(url) {
12418
12941
  return void 0;
12419
12942
  }
12420
12943
  }
12944
+ var BOARD_ORIGIN = "https://board.invalid";
12421
12945
  function safeNext(next) {
12422
- return next !== void 0 && /^\/(?![/\\])/.test(next) ? next : "/";
12946
+ if (next === void 0 || !next.startsWith("/")) {
12947
+ return "/";
12948
+ }
12949
+ let url;
12950
+ try {
12951
+ url = new URL(next, BOARD_ORIGIN);
12952
+ } catch {
12953
+ return "/";
12954
+ }
12955
+ const path12 = `${url.pathname}${url.search}`;
12956
+ return url.origin === BOARD_ORIGIN && /^\/(?![/\\])/.test(path12) ? path12 : "/";
12423
12957
  }
12958
+ var APP_LANDING = "/auth/app/signin";
12424
12959
  function land(c, next, fragment) {
12960
+ c.header("cache-control", "no-store");
12425
12961
  return c.redirect(`${next}#${fragment}`, 302);
12426
12962
  }
12963
+ function prune(map, now) {
12964
+ for (const [key, entry] of map) {
12965
+ if (entry.expiresAt <= now || map.size >= MAX_STATES) {
12966
+ map.delete(key);
12967
+ }
12968
+ }
12969
+ }
12970
+ function provesChallenge(verifier, challenge) {
12971
+ const answer = Buffer.from(createHash3("sha256").update(verifier).digest("base64url"));
12972
+ const expected = Buffer.from(challenge);
12973
+ return answer.length === expected.length && timingSafeEqual(answer, expected);
12974
+ }
12427
12975
  function githubAuthRoutes(options) {
12428
12976
  const { board: board2, github } = options;
12429
12977
  const fetchImpl = options.fetch ?? fetch;
12430
12978
  const now = options.now ?? Date.now;
12431
12979
  const states = /* @__PURE__ */ new Map();
12980
+ const codes = /* @__PURE__ */ new Map();
12432
12981
  const routes = new Hono();
12433
- routes.get("/config", (c) => c.json({ github: github !== null }));
12982
+ routes.get("/config", (c) => c.json({ github: github !== null, appLanding: (github?.appleAppId ?? null) !== null }));
12983
+ routes.get("/app/signin", (c) => c.text("not found", 404, { "cache-control": "no-store" }));
12434
12984
  if (github === null) {
12435
12985
  return routes;
12436
12986
  }
12437
- routes.get("/github", options.limit ?? ((_c, next) => next()), (c) => {
12438
- for (const [state2, pending] of states) {
12439
- if (pending.expiresAt <= now() || states.size >= MAX_STATES) {
12440
- states.delete(state2);
12987
+ const limit2 = options.limit ?? ((_c, next) => next());
12988
+ routes.get("/github", limit2, (c) => {
12989
+ const app2 = c.req.query("landing") === "app";
12990
+ let challenge = null;
12991
+ if (app2) {
12992
+ if (github.appleAppId === null) {
12993
+ return c.text("Signing in to the app is off on this board", 403);
12994
+ }
12995
+ const parsed = CodeChallengeSchema.safeParse(c.req.query("code_challenge"));
12996
+ if (!parsed.success || c.req.query("code_challenge_method") !== "S256") {
12997
+ return c.text("Signing in to the app needs a code_challenge with method S256", 400);
12441
12998
  }
12999
+ challenge = parsed.data;
12442
13000
  }
13001
+ prune(states, now());
12443
13002
  const state = randomBytes2(24).toString("base64url");
12444
- states.set(state, { next: safeNext(c.req.query("next")), expiresAt: now() + STATE_TTL_MS });
13003
+ const next = app2 ? APP_LANDING : safeNext(c.req.query("next"));
13004
+ states.set(state, { next, challenge, expiresAt: now() + STATE_TTL_MS });
12445
13005
  const authorize = new URL("https://github.com/login/oauth/authorize");
12446
13006
  authorize.searchParams.set("client_id", github.clientId);
12447
13007
  authorize.searchParams.set("state", state);
@@ -12494,9 +13054,43 @@ function githubAuthRoutes(options) {
12494
13054
  options.log?.warn({ login }, "GitHub sign-in refused a login that is not on the list");
12495
13055
  return land(c, pending.next, "signin-error=not-allowed");
12496
13056
  }
13057
+ if (pending.challenge !== null) {
13058
+ prune(codes, now());
13059
+ const appCode = randomBytes2(24).toString("base64url");
13060
+ codes.set(appCode, {
13061
+ challenge: pending.challenge,
13062
+ login,
13063
+ avatar,
13064
+ expiresAt: now() + CODE_TTL_MS
13065
+ });
13066
+ return land(c, pending.next, `code=${appCode}`);
13067
+ }
12497
13068
  const token = await board2.signIn(login, SIGN_IN_TTL_MS, avatar);
12498
13069
  return land(c, pending.next, `session=${encodeURIComponent(token)}`);
12499
13070
  });
13071
+ if (github.appleAppId !== null) {
13072
+ routes.post("/app/token", limit2, limitBody(APP_TOKEN_BODY_LIMIT_BYTES), async (c) => {
13073
+ const body = CodeExchangeSchema.safeParse(await c.req.json().catch(() => null));
13074
+ if (!body.success) {
13075
+ return c.json({ error: "invalid_request" }, 400);
13076
+ }
13077
+ const pending = codes.get(body.data.code);
13078
+ codes.delete(body.data.code);
13079
+ if (pending === void 0 || pending.expiresAt <= now() || !provesChallenge(body.data.code_verifier, pending.challenge)) {
13080
+ return c.json({ error: "invalid_grant" }, 400);
13081
+ }
13082
+ const token = await board2.signIn(pending.login, SIGN_IN_TTL_MS, pending.avatar);
13083
+ return c.json({ session: token }, 200, { "cache-control": "no-store" });
13084
+ });
13085
+ }
13086
+ return routes;
13087
+ }
13088
+ function appleAppSiteAssociation(github) {
13089
+ const routes = new Hono();
13090
+ routes.get("/apple-app-site-association", (c) => {
13091
+ const appId = github?.appleAppId ?? null;
13092
+ return appId === null ? c.text("not found", 404) : c.json({ webcredentials: { apps: [appId] } });
13093
+ });
12500
13094
  return routes;
12501
13095
  }
12502
13096
 
@@ -12604,14 +13198,14 @@ function rateLimit(options) {
12604
13198
  }
12605
13199
 
12606
13200
  // apps/server/dist/runner-routes.js
12607
- import path8 from "node:path";
13201
+ import path9 from "node:path";
12608
13202
  import { Hono as Hono2 } from "hono";
12609
13203
  import { streamSSE } from "hono/streaming";
12610
13204
 
12611
13205
  // apps/server/dist/home-git.js
12612
- import { execa as execa2 } from "execa";
13206
+ import { execa as execa3 } from "execa";
12613
13207
  var SMART_PATHS = /* @__PURE__ */ new Set(["/info/refs", "/git-upload-pack", "/git-receive-pack"]);
12614
- var MAX_BYTES = 256 * 1024 * 1024;
13208
+ var MAX_BYTES = MOVE_PACK_LIMIT_BYTES;
12615
13209
  function parseCgi(output) {
12616
13210
  const crlf = output.indexOf("\r\n\r\n");
12617
13211
  const lf = output.indexOf("\n\n");
@@ -12642,9 +13236,9 @@ var HomeGit = class {
12642
13236
  if (input.rest !== "/git-receive-pack") {
12643
13237
  return serveHomeGit(input);
12644
13238
  }
12645
- const previous = this.pushes.get(input.agent) ?? Promise.resolve();
13239
+ const previous = this.pushes.get(input.repository) ?? Promise.resolve();
12646
13240
  const run = previous.then(() => serveHomeGit(input), () => serveHomeGit(input));
12647
- this.pushes.set(input.agent, run.catch(() => void 0));
13241
+ this.pushes.set(input.repository, run.catch(() => void 0));
12648
13242
  return run;
12649
13243
  }
12650
13244
  };
@@ -12655,7 +13249,7 @@ async function serveHomeGit(input) {
12655
13249
  const { request } = input;
12656
13250
  const body = Buffer.from(await request.arrayBuffer());
12657
13251
  if (body.length > MAX_BYTES) {
12658
- return new Response("pack too large for a home", { status: 413 });
13252
+ return new Response("pack too large for the board to take", { status: 413 });
12659
13253
  }
12660
13254
  const url = new URL(request.url);
12661
13255
  const env = {
@@ -12664,7 +13258,7 @@ async function serveHomeGit(input) {
12664
13258
  GIT_PROJECT_ROOT: input.root,
12665
13259
  GIT_HTTP_EXPORT_ALL: "1",
12666
13260
  REQUEST_METHOD: request.method,
12667
- PATH_INFO: `/${input.agent}${input.rest}`,
13261
+ PATH_INFO: `/${input.repository}${input.rest}`,
12668
13262
  QUERY_STRING: url.search.replace(/^\?/, ""),
12669
13263
  CONTENT_TYPE: request.headers.get("content-type") ?? "",
12670
13264
  CONTENT_LENGTH: String(body.length),
@@ -12679,11 +13273,14 @@ async function serveHomeGit(input) {
12679
13273
  if (protocol !== null) {
12680
13274
  env["HTTP_GIT_PROTOCOL"] = protocol;
12681
13275
  }
12682
- const result = await execa2("git", ["http-backend"], {
13276
+ const result = await execa3("git", ["http-backend"], {
12683
13277
  env,
12684
13278
  extendEnv: false,
12685
13279
  input: body,
12686
13280
  encoding: "buffer",
13281
+ // The blank line that ends the headers ends the whole answer when the body is empty, as for
13282
+ // the 4-byte probe git sends before a pack too large for one post; stripped, it reads as none.
13283
+ stripFinalNewline: false,
12687
13284
  reject: false,
12688
13285
  maxBuffer: MAX_BYTES
12689
13286
  });
@@ -12799,20 +13396,47 @@ function runnerRoutes(board2, hub, enrollments, enrollLimit) {
12799
13396
  const marker = `/homes/${agent}/git`;
12800
13397
  const at = c.req.path.indexOf(marker);
12801
13398
  return homeGit.serve({
12802
- root: path8.dirname(repository),
12803
- agent,
13399
+ root: path9.dirname(repository),
13400
+ repository: agent,
12804
13401
  rest: at === -1 ? "" : c.req.path.slice(at + marker.length),
12805
13402
  request: c.req.raw,
12806
13403
  runner: c.get("runner")
12807
13404
  });
12808
13405
  });
12809
13406
  routes.route("/homes/:agent", home);
13407
+ routes.all("/moves/:id/git/*", async (c) => {
13408
+ const id = UlidSchema.safeParse(c.req.param("id"));
13409
+ const marker = `/moves/${c.req.param("id")}/git`;
13410
+ const at = c.req.path.indexOf(marker);
13411
+ const rest = at === -1 ? "" : c.req.path.slice(at + marker.length);
13412
+ const service = rest === "/info/refs" ? c.req.query("service") ?? "" : rest.slice(1);
13413
+ if (service !== "git-upload-pack" && service !== "git-receive-pack") {
13414
+ return c.json({ error: "NOT_FOUND", message: "not a smart HTTP request" }, 404);
13415
+ }
13416
+ const access3 = id.success ? hub.moveAccess(c.get("runner"), id.data, service === "git-receive-pack") : "unknown";
13417
+ if (access3 === "unknown") {
13418
+ return c.json({ error: "NOT_FOUND", message: "no such move under way" }, 404);
13419
+ }
13420
+ if (access3 === "refused" || !id.success) {
13421
+ return c.json({
13422
+ error: "FORBIDDEN",
13423
+ message: `this runner may not ${service === "git-receive-pack" ? "push to" : "fetch from"} this move now`
13424
+ }, 403);
13425
+ }
13426
+ return homeGit.serve({
13427
+ root: board2.moveRepos,
13428
+ repository: `${id.data}.git`,
13429
+ rest,
13430
+ request: c.req.raw,
13431
+ runner: c.get("runner")
13432
+ });
13433
+ });
12810
13434
  return routes;
12811
13435
  }
12812
13436
 
12813
13437
  // apps/server/dist/static.js
12814
13438
  import { readFile as readFile5, stat as stat4 } from "node:fs/promises";
12815
- import path9 from "node:path";
13439
+ import path10 from "node:path";
12816
13440
  var CONTENT_TYPES = {
12817
13441
  ".html": "text/html; charset=utf-8",
12818
13442
  ".js": "text/javascript; charset=utf-8",
@@ -12840,11 +13464,11 @@ function within2(root, pathname) {
12840
13464
  } catch {
12841
13465
  return null;
12842
13466
  }
12843
- const candidate = path9.resolve(root, `.${decoded}`);
12844
- return candidate === root || candidate.startsWith(root + path9.sep) ? candidate : null;
13467
+ const candidate = path10.resolve(root, `.${decoded}`);
13468
+ return candidate === root || candidate.startsWith(root + path10.sep) ? candidate : null;
12845
13469
  }
12846
13470
  function spaHandler(staticDir) {
12847
- const root = path9.resolve(staticDir);
13471
+ const root = path10.resolve(staticDir);
12848
13472
  return async (c) => {
12849
13473
  const { pathname } = new URL(c.req.url);
12850
13474
  if (pathname.startsWith("/api/")) {
@@ -12855,14 +13479,14 @@ function spaHandler(staticDir) {
12855
13479
  if (file !== null && await isFile(file)) {
12856
13480
  return c.body(await readFile5(file), 200, {
12857
13481
  ...headers,
12858
- "content-type": CONTENT_TYPES[path9.extname(file)] ?? "application/octet-stream",
13482
+ "content-type": CONTENT_TYPES[path10.extname(file)] ?? "application/octet-stream",
12859
13483
  "cache-control": pathname.startsWith("/assets/") ? "public, max-age=31536000, immutable" : "no-cache"
12860
13484
  });
12861
13485
  }
12862
13486
  if (pathname.startsWith("/assets/")) {
12863
13487
  return c.text("not found", 404, headers);
12864
13488
  }
12865
- const index = path9.join(root, "index.html");
13489
+ const index = path10.join(root, "index.html");
12866
13490
  if (!await isFile(index)) {
12867
13491
  return c.text("The interface is not built: run pnpm build:web.", 404);
12868
13492
  }
@@ -12889,6 +13513,10 @@ var RetireBodySchema = z19.object({ reason: z19.string().min(1) });
12889
13513
  var RunnerBodySchema = z19.object({ name: NameSchema });
12890
13514
  var EnrollmentApprovalSchema = z19.object({ name: NameSchema });
12891
13515
  var HomeRunnerBodySchema = z19.object({ runner: NameSchema.nullable() });
13516
+ var ProjectRunnerBodySchema = z19.object({
13517
+ runner: NameSchema,
13518
+ withoutBranches: z19.boolean().optional()
13519
+ });
12892
13520
  var ModelBodySchema = z19.object({ model: ModelNameSchema.nullable() });
12893
13521
  var EffortBodySchema = z19.object({ effort: EffortSchema.nullable() });
12894
13522
  var HistoryLimitSchema = z19.coerce.number().int().min(1).max(500);
@@ -12922,6 +13550,9 @@ function createApp(deps) {
12922
13550
  if (error instanceof RunnerAwayError) {
12923
13551
  return c.json({ error: "RUNNER_AWAY", message: error.message }, 503);
12924
13552
  }
13553
+ if (error instanceof MoveSourceAwayError) {
13554
+ return c.json({ error: "SOURCE_AWAY", message: error.message }, 409);
13555
+ }
12925
13556
  return c.json({ error: "INTERNAL", message: error.message }, 500);
12926
13557
  });
12927
13558
  const authenticate = async (c, next) => {
@@ -13096,6 +13727,13 @@ function createApp(deps) {
13096
13727
  return branchFile(c, project.slug, project.defaultBranch, file);
13097
13728
  };
13098
13729
  api.get("/projects/:slug/files", (c) => projectFile(c, ""));
13730
+ api.put("/projects/:slug/runner", async (c) => {
13731
+ const body = ProjectRunnerBodySchema.parse(await c.req.json());
13732
+ if (deps.runners === void 0) {
13733
+ throw new BoardError("NOT_FOUND", "this board has no runners to move a project between");
13734
+ }
13735
+ return c.json(await deps.runners.moveProject(c.get("actor"), NameSchema.parse(c.req.param("slug")), body.runner, { withoutBranches: body.withoutBranches }));
13736
+ });
13099
13737
  api.get("/projects/:slug/files/:path{.+}", (c) => projectFile(c, c.req.param("path")));
13100
13738
  api.get("/tasks/:id", async (c) => c.json((await board2.findTask(c.req.param("id"))).task));
13101
13739
  api.get("/tasks/:id/thread", async (c) => c.json(await board2.listThread(c.req.param("id"))));
@@ -13273,6 +13911,7 @@ function createApp(deps) {
13273
13911
  ...deps.signIn,
13274
13912
  limit: limited(SIGN_IN_LIMIT, "sign-ins")
13275
13913
  }));
13914
+ app2.route("/.well-known", appleAppSiteAssociation(deps.signIn?.github ?? null));
13276
13915
  if (deps.runners !== void 0) {
13277
13916
  app2.route("/runner", runnerRoutes(board2, deps.runners, deps.enrollments, limited(ENROLL_LIMIT, "enrollment requests")));
13278
13917
  }
@@ -13572,8 +14211,8 @@ var view = {
13572
14211
  return scheduler.activeSignals;
13573
14212
  }
13574
14213
  };
13575
- var webDir = process.env["STELLARIS_WEB_DIR"] ?? path10.resolve(import.meta.dirname, "../../web/dist");
13576
- var webBuilt = await access2(path10.join(webDir, "index.html")).then(() => true, () => false);
14214
+ var webDir = process.env["STELLARIS_WEB_DIR"] ?? path11.resolve(import.meta.dirname, "../../web/dist");
14215
+ var webBuilt = await access2(path11.join(webDir, "index.html")).then(() => true, () => false);
13577
14216
  var app = createApp({
13578
14217
  board,
13579
14218
  version: VERSION,
@@ -13597,6 +14236,7 @@ var server = serve({ fetch: app.fetch, port: config.port, hostname: config.host
13597
14236
  webDir,
13598
14237
  webBuilt,
13599
14238
  signIn: config.github === null ? "token" : `github (${config.github.users.join(", ")})`,
14239
+ appleAppId: config.github?.appleAppId ?? null,
13600
14240
  trustedProxies: config.trustedProxies
13601
14241
  }, "board server listening");
13602
14242
  });