@kubitnodes/stellaris 0.3.23 → 0.3.25

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 (77) hide show
  1. package/dist/cli/index.js +543 -682
  2. package/dist/server/index.js +866 -735
  3. package/package.json +4 -4
  4. package/web/dist/assets/BackLink-UTiLF1J4.js +1 -0
  5. package/web/dist/assets/{Button-f4jgBZY1.js → Button-Cp4cNRUX.js} +1 -1
  6. package/web/dist/assets/ChannelForms-DD2B0tey.js +1 -0
  7. package/web/dist/assets/ChannelView-CY3oG9hZ.js +2 -0
  8. package/web/dist/assets/CitizenView-BNK5ro-6.js +2 -0
  9. package/web/dist/assets/CitizensView-WPQ6Jhl3.js +1 -0
  10. package/web/dist/assets/{CliIcon-CLHaA65A.js → CliIcon-C7bwChMP.js} +1 -1
  11. package/web/dist/assets/CronsView-_pdgwX1I.js +1 -0
  12. package/web/dist/assets/FileTree-jyDUSu7g.js +1 -0
  13. package/web/dist/assets/FileView-DcQvlc8V.js +4 -0
  14. package/web/dist/assets/KnowledgeView-DDl0HQPG.js +1 -0
  15. package/web/dist/assets/Markdown-DT3V-EVe.js +30 -0
  16. package/web/dist/assets/{MessageItem-Bk9eBp1U.js → MessageItem-agtEugfG.js} +1 -1
  17. package/web/dist/assets/MetricsView-DiXyYeWP.js +1 -0
  18. package/web/dist/assets/NeedsYouView-CKmuYan_.js +1 -0
  19. package/web/dist/assets/Overview-CKcTqsMM.js +1 -0
  20. package/web/dist/assets/{Pane-CJYRVkwh.js → Pane-BaM0TdE6.js} +1 -1
  21. package/web/dist/assets/{Picker-DoxCN6C7.js → Picker-C2ig-0dT.js} +1 -1
  22. package/web/dist/assets/ProjectView-T2OHTh5j.js +1 -0
  23. package/web/dist/assets/ProposalView-B5yjTAcn.js +1 -0
  24. package/web/dist/assets/ProposalsView-p4KjwV9p.js +1 -0
  25. package/web/dist/assets/Quota-CfFGIql9.js +1 -0
  26. package/web/dist/assets/RunnersView-DdvwoOur.js +1 -0
  27. package/web/dist/assets/SkillFileList-CgrCnuaC.js +2 -0
  28. package/web/dist/assets/SkillView-Di5Z9h2L.js +1 -0
  29. package/web/dist/assets/SocietyView-BDiVxUC5.js +1 -0
  30. package/web/dist/assets/StickyList-BaV_KZ8x.js +1 -0
  31. package/web/dist/assets/SubjectThread-DD_ZCCnr.js +1 -0
  32. package/web/dist/assets/TaskView-CKcs-MOC.js +3 -0
  33. package/web/dist/assets/TasksView-oZMjncOU.js +1 -0
  34. package/web/dist/assets/{ThreadForms-C846TvtS.js → ThreadForms-CG5Y926b.js} +1 -1
  35. package/web/dist/assets/ThreadItem-D-n2arFA.js +1 -0
  36. package/web/dist/assets/ThreadView-CT9vd0P4.js +1 -0
  37. package/web/dist/assets/citizen-BiHfph2A.js +1 -0
  38. package/web/dist/assets/{compose-BmqQmcZH.js → compose-DY1WL8zB.js} +1 -1
  39. package/web/dist/assets/format-CENiwkJC.js +55 -0
  40. package/web/dist/assets/governance-C6ez5MLh.js +1 -0
  41. package/web/dist/assets/index-CPXALUV6.js +23 -0
  42. package/web/dist/assets/index-DIa8Dlav.css +2 -0
  43. package/web/dist/assets/mentions-BEL5U6ra.js +3 -0
  44. package/web/dist/assets/{tasks-R3YgpYTe.js → tasks-CAI8Eci7.js} +1 -1
  45. package/web/dist/assets/useMatch-CINzyDWa.js +1 -0
  46. package/web/dist/index.html +9 -9
  47. package/web/dist/assets/BackLink-DGYs80bb.js +0 -1
  48. package/web/dist/assets/ChannelForms-q9zdj4p2.js +0 -1
  49. package/web/dist/assets/ChannelView-CGs_aWL3.js +0 -2
  50. package/web/dist/assets/CitizenView-gcIo_sKt.js +0 -2
  51. package/web/dist/assets/CitizensView-DZ7S8vnJ.js +0 -1
  52. package/web/dist/assets/CronsView-1wns6Uwv.js +0 -1
  53. package/web/dist/assets/KnowledgeView-Cxfi2-Pt.js +0 -1
  54. package/web/dist/assets/Markdown-BhtIFcWc.js +0 -30
  55. package/web/dist/assets/MetricsView-CTEH6rJn.js +0 -1
  56. package/web/dist/assets/NeedsYouView-2VNqH2Sb.js +0 -1
  57. package/web/dist/assets/Overview-CS9HCSmm.js +0 -1
  58. package/web/dist/assets/ProjectView-CJ3r4UdN.js +0 -1
  59. package/web/dist/assets/ProposalView-DLn9dNPp.js +0 -2
  60. package/web/dist/assets/ProposalsView-B-cOxTsQ.js +0 -1
  61. package/web/dist/assets/Quota-Bo7UdWTV.js +0 -1
  62. package/web/dist/assets/RunnersView-DlQruhwp.js +0 -1
  63. package/web/dist/assets/SocietyView-Bl4ErnV8.js +0 -1
  64. package/web/dist/assets/StickyList-MVP34ytV.js +0 -1
  65. package/web/dist/assets/SubjectThread-BFFnHxIF.js +0 -1
  66. package/web/dist/assets/TaskFileView-GZ0eQFUb.js +0 -4
  67. package/web/dist/assets/TaskView-DppFelLW.js +0 -3
  68. package/web/dist/assets/TasksView-DDNXzeG4.js +0 -1
  69. package/web/dist/assets/ThreadItem-C8cou6wD.js +0 -1
  70. package/web/dist/assets/ThreadView-DvSGDYoD.js +0 -1
  71. package/web/dist/assets/citizen-B2Dlbcpe.js +0 -1
  72. package/web/dist/assets/format-DT52SbbR.js +0 -55
  73. package/web/dist/assets/governance-Cr5OlQnw.js +0 -1
  74. package/web/dist/assets/index-BTVy_xFF.js +0 -23
  75. package/web/dist/assets/index-DzV6CCnG.css +0 -2
  76. package/web/dist/assets/mentions-D5hNgtg6.js +0 -3
  77. package/web/dist/assets/useMatch-pEF6RJ6Q.js +0 -1
@@ -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 path9 from "node:path";
5
+ import path10 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 readdir3, readFile as readFile4, rename as rename3, rm as rm2, stat as stat3, writeFile as writeFile4 } from "node:fs/promises";
10
- import path6 from "node:path";
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";
11
11
  import { decodeTime, monotonicFactory } from "ulid";
12
12
  import { z as z16 } from "zod";
13
13
 
@@ -235,6 +235,7 @@ var CronFrontmatterSchema = z3.object({
235
235
  scope: NameSchema,
236
236
  thread: UlidSchema.optional(),
237
237
  channel: NameSchema.optional(),
238
+ place: NameSchema.optional(),
238
239
  schedule: CronScheduleSchema,
239
240
  createdBy: NameSchema,
240
241
  createdAt: IsoDateTimeSchema,
@@ -384,15 +385,16 @@ function quotaLevel(quota, now) {
384
385
  var WINDOW_NAMES = {
385
386
  five_hour: "5h",
386
387
  seven_day: "7d",
387
- seven_day_opus: "7d Opus",
388
- seven_day_sonnet: "7d Sonnet",
388
+ seven_day_opus: "7d opus",
389
+ seven_day_sonnet: "7d sonnet",
389
390
  seven_day_oauth_apps: "7d apps",
390
391
  seven_day_overage_included: "7d extra",
391
392
  overage: "extra usage"
392
393
  };
393
394
  function windowName(window) {
394
395
  if (window.label !== void 0) {
395
- return window.minutes === 10080 ? `7d ${window.label}` : window.label;
396
+ const label = window.label.toLowerCase();
397
+ return window.minutes === 10080 ? `7d ${label}` : label;
396
398
  }
397
399
  const named = WINDOW_NAMES[window.key];
398
400
  if (named !== void 0) {
@@ -766,7 +768,6 @@ var ReallocationProposalSchema = z5.object({
766
768
  changes: RoleChangesSchema,
767
769
  description: z5.string().min(1)
768
770
  });
769
- var LegacyReallocationSchema = z5.object({ description: z5.string().min(1) });
770
771
  function applyRoleChanges(role, changes) {
771
772
  return {
772
773
  ...role,
@@ -811,8 +812,7 @@ var ArchiveProposalSchema = z5.object({
811
812
  });
812
813
  var SkillProposalSchema = z5.object({
813
814
  name: NameSchema,
814
- summary: z5.string().min(1).max(200),
815
- body: z5.string().min(1).optional()
815
+ summary: z5.string().min(1).max(200)
816
816
  });
817
817
  var ProposalCharterSchemas = {
818
818
  role: RoleCharterSchema,
@@ -889,14 +889,10 @@ function describeCharter(kind, charter) {
889
889
  }
890
890
  case "reallocation": {
891
891
  const parsed = ReallocationProposalSchema.safeParse(charter);
892
- if (parsed.success) {
893
- const { role, changes, description } = parsed.data;
894
- return `reallocation of role ${role} (${describeRoleChanges(changes)}): ${description}`;
895
- }
896
- const legacy = LegacyReallocationSchema.safeParse(charter);
897
- if (!legacy.success)
892
+ if (!parsed.success)
898
893
  break;
899
- return `reallocation: ${legacy.data.description}`;
894
+ const { role, changes, description } = parsed.data;
895
+ return `reallocation of role ${role} (${describeRoleChanges(changes)}): ${description}`;
900
896
  }
901
897
  case "skill": {
902
898
  const parsed = SkillProposalSchema.safeParse(charter);
@@ -991,7 +987,8 @@ var DashboardSchema = z5.object({
991
987
  var ProposalOriginSchema = z5.object({
992
988
  scope: NameSchema,
993
989
  thread: UlidSchema.optional(),
994
- channel: NameSchema.optional()
990
+ channel: NameSchema.optional(),
991
+ place: NameSchema.optional()
995
992
  });
996
993
  var SKILL_FILES_LIMIT = 100;
997
994
  var SKILL_BYTES_LIMIT = 10 * 1024 * 1024;
@@ -1106,12 +1103,10 @@ var VerbInputs = {
1106
1103
  channel: ChannelRefSchema.optional(),
1107
1104
  limit: z7.number().int().positive().max(100).default(20)
1108
1105
  }),
1109
- /** On a task or a proposal, whose id the thread takes, or on a channel with a title. */
1106
+ /** On a channel with a title; a task's and a proposal's open with them. */
1110
1107
  open_thread: z7.object({
1111
- task_id: UlidSchema.optional(),
1112
- proposal_id: UlidSchema.optional(),
1113
- channel: ChannelRefSchema.optional(),
1114
- title: z7.string().min(1).max(200).optional()
1108
+ channel: ChannelRefSchema,
1109
+ title: z7.string().min(1).max(200)
1115
1110
  }),
1116
1111
  close_thread: z7.object({ thread_id: UlidSchema, summary: z7.string().min(1) }),
1117
1112
  create_task: z7.object({
@@ -1385,6 +1380,7 @@ var TurnExitReasonSchema = z8.enum([
1385
1380
  "blocked",
1386
1381
  "stopped"
1387
1382
  ]);
1383
+ var ResumeFailureSchema = z8.enum(["lost", "retry"]);
1388
1384
  var AgentEventSchema = z8.discriminatedUnion("type", [
1389
1385
  z8.object({
1390
1386
  type: z8.literal("turn_started"),
@@ -1436,6 +1432,8 @@ var LiveTurnEventSchema = z8.object({
1436
1432
  thread: UlidSchema.optional(),
1437
1433
  /** The channel, never general, whose conversation the turn is in. */
1438
1434
  channel: NameSchema.optional(),
1435
+ /** The project whose channels' conversation the turn is in, for a turn in the society scope. */
1436
+ place: NameSchema.optional(),
1439
1437
  event: AgentEventSchema
1440
1438
  });
1441
1439
 
@@ -1538,7 +1536,8 @@ var TurnWorkspaceSchema = z9.discriminatedUnion("kind", [
1538
1536
  z9.object({
1539
1537
  kind: z9.literal("home"),
1540
1538
  thread: UlidSchema.optional(),
1541
- channel: NameSchema.optional()
1539
+ channel: NameSchema.optional(),
1540
+ place: NameSchema.optional()
1542
1541
  }),
1543
1542
  z9.object({
1544
1543
  kind: z9.literal("project"),
@@ -1547,7 +1546,16 @@ var TurnWorkspaceSchema = z9.discriminatedUnion("kind", [
1547
1546
  thread: UlidSchema.optional(),
1548
1547
  channel: NameSchema.optional()
1549
1548
  }),
1550
- z9.object({ kind: z9.literal("task"), repo: ProjectRepoSchema, taskId: UlidSchema })
1549
+ z9.object({
1550
+ kind: z9.literal("task"),
1551
+ repo: ProjectRepoSchema,
1552
+ taskId: UlidSchema,
1553
+ /**
1554
+ * The citizen holding the task's current stage, when someone does: a turn of anyone else
1555
+ * works detached at the branch's tip, so the holder's turns find the branch free.
1556
+ */
1557
+ holder: NameSchema.optional()
1558
+ })
1551
1559
  ]);
1552
1560
  var TurnJobSchema = z9.object({
1553
1561
  turnId: UlidSchema,
@@ -1563,6 +1571,8 @@ var TurnJobSchema = z9.object({
1563
1571
  thread: UlidSchema.optional(),
1564
1572
  /** The channel, never general, whose conversation the turn is in. */
1565
1573
  channel: NameSchema.optional(),
1574
+ /** The project whose channels' conversation the turn is in, for a turn in the society scope. */
1575
+ place: NameSchema.optional(),
1566
1576
  /**
1567
1577
  * The session to resume, or null to start one: the CLI's adapter picks a fresh session's id, and
1568
1578
  * the outcome reports it.
@@ -1583,6 +1593,12 @@ var TurnJobSchema = z9.object({
1583
1593
  workspace: TurnWorkspaceSchema
1584
1594
  });
1585
1595
  var TurnWorkSchema = z9.object({ branch: z9.string().min(1), head: z9.string().min(1) });
1596
+ var StuckWorkSchema = z9.object({
1597
+ branch: z9.string().min(1),
1598
+ agent: NameSchema,
1599
+ commits: z9.array(z9.string()),
1600
+ total: z9.number().int().positive()
1601
+ });
1586
1602
  var TurnEventsSchema = z9.object({ entries: z9.array(TranscriptEntrySchema) });
1587
1603
  var TurnOutcomeSchema = z9.object({
1588
1604
  exitReason: TurnExitReasonSchema,
@@ -1592,8 +1608,24 @@ var TurnOutcomeSchema = z9.object({
1592
1608
  costUsd: z9.number().nonnegative(),
1593
1609
  /** The session's running total after this turn, for CLIs that report one. */
1594
1610
  sessionCostUsd: z9.number().nonnegative().optional(),
1595
- /** The session the CLI used, which differs from the job's for CLIs that assign their own ids. */
1596
- session: z9.string().min(1),
1611
+ /**
1612
+ * The session the CLI used, which differs from the job's for CLIs that assign their own ids. A
1613
+ * turn whose CLI never started, because it failed or was stopped first, reports the job's own,
1614
+ * null in a conversation's first turn, so a session never started is not recorded.
1615
+ */
1616
+ session: z9.string().min(1).nullable(),
1617
+ /**
1618
+ * Set when the CLI could not resume the job's session, which failed the turn before any model
1619
+ * call; the server then drops a `lost` session, and one that failed `retry` too often in a row. A
1620
+ * runner from before never sends it.
1621
+ */
1622
+ resumeFailed: ResumeFailureSchema.optional(),
1623
+ /**
1624
+ * Set when the model service turned the turn away for now, as when it was overloaded or failed
1625
+ * on its side, which the scheduler answers by sending the same wake again within minutes. A
1626
+ * runner from before never sends it.
1627
+ */
1628
+ transient: z9.boolean().optional(),
1597
1629
  /** The model the CLI reported, when it reported one. */
1598
1630
  model: z9.string().nullable(),
1599
1631
  work: TurnWorkSchema.nullable(),
@@ -1602,6 +1634,11 @@ var TurnOutcomeSchema = z9.object({
1602
1634
  * branch after the hand-back: uncommitted changes, or commits no branch contains.
1603
1635
  */
1604
1636
  leftovers: z9.boolean().default(false),
1637
+ /**
1638
+ * Commits the hand-back found its task's worktrees holding that no fast-forward of the task's
1639
+ * branch can take, each told once; a runner from before never sends it.
1640
+ */
1641
+ stuck: z9.array(StuckWorkSchema).optional(),
1605
1642
  /**
1606
1643
  * The CLI's plan quota as the turn's stream last reported it, when it reported any; a runner from
1607
1644
  * before never sends it.
@@ -1720,13 +1757,17 @@ var TurnImageSchema = z9.discriminatedUnion("kind", [
1720
1757
  ]);
1721
1758
  var WorkspaceConversationSchema = z9.discriminatedUnion("kind", [
1722
1759
  z9.object({ kind: z9.literal("thread"), id: UlidSchema }),
1723
- z9.object({ kind: z9.literal("channel"), scope: NameSchema, name: NameSchema })
1760
+ z9.object({ kind: z9.literal("channel"), scope: NameSchema, name: NameSchema }),
1761
+ z9.object({ kind: z9.literal("place"), project: NameSchema })
1724
1762
  ]);
1725
1763
  function workspaceConversationOf(turn) {
1726
1764
  if (turn.thread !== void 0) {
1727
1765
  return { kind: "thread", id: turn.thread };
1728
1766
  }
1729
- return turn.channel === void 0 ? null : { kind: "channel", scope: turn.scope, name: turn.channel };
1767
+ if (turn.channel !== void 0) {
1768
+ return { kind: "channel", scope: turn.scope, name: turn.channel };
1769
+ }
1770
+ return turn.place === void 0 ? null : { kind: "place", project: turn.place };
1730
1771
  }
1731
1772
  var HeldWorkspaceSchema = z9.object({
1732
1773
  agent: NameSchema,
@@ -1832,6 +1873,7 @@ var TurnDispatchSchema = z10.object({
1832
1873
  project: NameSchema,
1833
1874
  thread: TurnThreadSchema.optional(),
1834
1875
  channel: NameSchema.optional(),
1876
+ place: NameSchema.optional(),
1835
1877
  trigger: TriggerSchema,
1836
1878
  priority: z10.number().int().min(0).max(2),
1837
1879
  onboarding: z10.boolean().default(false),
@@ -1839,7 +1881,13 @@ var TurnDispatchSchema = z10.object({
1839
1881
  crons: z10.array(UlidSchema).optional()
1840
1882
  });
1841
1883
  function conversationPart(conversation) {
1842
- return conversation.thread ?? (conversation.channel === void 0 ? void 0 : `#${conversation.channel}`);
1884
+ if (conversation.thread !== void 0) {
1885
+ return conversation.thread;
1886
+ }
1887
+ if (conversation.channel !== void 0) {
1888
+ return `#${conversation.channel}`;
1889
+ }
1890
+ return conversation.place === void 0 ? void 0 : `~${conversation.place}`;
1843
1891
  }
1844
1892
  function sessionKey(agent, scope, part) {
1845
1893
  return part === void 0 ? `${agent}/${scope}` : `${agent}/${scope}/${part}`;
@@ -1852,7 +1900,10 @@ function parseSessionKey(key) {
1852
1900
  if (part === void 0) {
1853
1901
  return { agent, scope };
1854
1902
  }
1855
- return part.startsWith("#") ? { agent, scope, channel: part.slice(1) } : { agent, scope, thread: part };
1903
+ if (part.startsWith("#")) {
1904
+ return { agent, scope, channel: part.slice(1) };
1905
+ }
1906
+ return part.startsWith("~") ? { agent, scope, place: part.slice(1) } : { agent, scope, thread: part };
1856
1907
  }
1857
1908
  function channelConversation(scope, channel) {
1858
1909
  const at = channel.indexOf("/");
@@ -1860,6 +1911,10 @@ function channelConversation(scope, channel) {
1860
1911
  const name = at < 0 ? channel : channel.slice(at + 1);
1861
1912
  return place === scope && name !== "general" ? name : void 0;
1862
1913
  }
1914
+ function placeConversation(scope, channel) {
1915
+ const at = channel.indexOf("/");
1916
+ return scope === SOCIETY_SCOPE && at >= 0 ? channel.slice(0, at) : void 0;
1917
+ }
1863
1918
  var TurnRecordSchema = z10.object({
1864
1919
  /** Assigned by the board when the turn begins; the turn's transcript is filed under it. */
1865
1920
  id: UlidSchema.optional(),
@@ -1869,6 +1924,8 @@ var TurnRecordSchema = z10.object({
1869
1924
  thread: UlidSchema.optional(),
1870
1925
  /** The channel, never general, whose conversation the turn was in. */
1871
1926
  channel: NameSchema.optional(),
1927
+ /** The project whose channels' conversation the turn was in, for a turn in the society scope. */
1928
+ place: NameSchema.optional(),
1872
1929
  runner: NameSchema,
1873
1930
  cli: CliKindSchema.nullable(),
1874
1931
  session: z10.string().nullable(),
@@ -1892,6 +1949,14 @@ var TurnRecordSchema = z10.object({
1892
1949
  work: TurnWorkSchema.optional(),
1893
1950
  /** Who stopped the turn, for one that ended `stopped`. */
1894
1951
  stoppedBy: NameSchema.optional(),
1952
+ /** Why the CLI could not resume the turn's session, for a turn that failed so. */
1953
+ resumeFailed: ResumeFailureSchema.optional(),
1954
+ /** Whether the model service turned the turn away for now: overloaded, or failing on its side. */
1955
+ transient: z10.boolean().optional(),
1956
+ /** How many turns in a row, this one included, could not resume the session for a `retry`. */
1957
+ resumeRetries: z10.number().int().positive().optional(),
1958
+ /** Whether the server dropped the conversation's session after this turn, which the next starts anew. */
1959
+ sessionDropped: z10.boolean().optional(),
1895
1960
  /** The crons whose fire started the turn, which record how it ended. */
1896
1961
  crons: z10.array(UlidSchema).optional()
1897
1962
  });
@@ -1901,6 +1966,7 @@ var RunningTurnSchema = z10.object({
1901
1966
  scope: NameSchema,
1902
1967
  thread: UlidSchema.optional(),
1903
1968
  channel: NameSchema.optional(),
1969
+ place: NameSchema.optional(),
1904
1970
  cli: CliKindSchema,
1905
1971
  askedIn: ChannelRefSchema.optional(),
1906
1972
  steerable: z10.boolean(),
@@ -1931,6 +1997,8 @@ var TurnHistoryEntrySchema = z10.object({
1931
1997
  thread: UlidSchema.optional(),
1932
1998
  /** The channel, never general, whose conversation the turn was in. */
1933
1999
  channel: NameSchema.optional(),
2000
+ /** The project whose channels' conversation the turn was in, for a turn in the society scope. */
2001
+ place: NameSchema.optional(),
1934
2002
  trigger: z10.string(),
1935
2003
  exitReason: z10.string().nullable(),
1936
2004
  costUsd: z10.number(),
@@ -1959,7 +2027,8 @@ var OPS_SIGNAL_KINDS = [
1959
2027
  "runner",
1960
2028
  "home_conflict",
1961
2029
  "stuck_workspace",
1962
- "cron_failing"
2030
+ "cron_failing",
2031
+ "turn_refused"
1963
2032
  ];
1964
2033
  var OpsSignalKindSchema = z11.enum(OPS_SIGNAL_KINDS);
1965
2034
  var WAKING_SIGNAL_KINDS = [
@@ -1983,8 +2052,18 @@ var OpsSignalSchema = z11.object({
1983
2052
  project: NameSchema.optional(),
1984
2053
  agent: NameSchema.optional(),
1985
2054
  role: NameSchema.optional(),
1986
- taskId: UlidSchema.optional()
2055
+ taskId: UlidSchema.optional(),
2056
+ /**
2057
+ * Whether this log of the condition wakes the roles charted for `ops_event`, as its publisher
2058
+ * decided: the operations pass wakes them when a condition is new, and again only at the steps
2059
+ * it names, not at every repeat. Absent, the kind decides.
2060
+ */
2061
+ wakes: z11.boolean().optional()
1987
2062
  });
2063
+ var WAKING = new Set(WAKING_SIGNAL_KINDS);
2064
+ function signalWakes(signal) {
2065
+ return signal.wakes ?? WAKING.has(signal.kind);
2066
+ }
1988
2067
 
1989
2068
  // packages/shared/dist/metrics.js
1990
2069
  import { z as z12 } from "zod";
@@ -2178,7 +2257,7 @@ var RunnerConfigSchema = z13.object({
2178
2257
  });
2179
2258
 
2180
2259
  // packages/shared/dist/version.js
2181
- var STELLARIS_VERSION = "0.3.23";
2260
+ var STELLARIS_VERSION = "0.3.25";
2182
2261
 
2183
2262
  // packages/shared/dist/mentions.js
2184
2263
  import { parse, postprocess, preprocess } from "micromark";
@@ -2562,6 +2641,7 @@ var HOME_GITIGNORE = [
2562
2641
  "/projects/*/last-turn.json",
2563
2642
  "/projects/*/threads/",
2564
2643
  "/projects/*/channels/",
2644
+ "/projects/*/places/",
2565
2645
  `/${HOME_SCRATCH}/`,
2566
2646
  "*.tmp",
2567
2647
  "node_modules/",
@@ -2579,7 +2659,7 @@ var HOME_GITIGNORE = [
2579
2659
  var IGNORE_FILE = `${HOME_GITIGNORE.join("\n")}
2580
2660
  `;
2581
2661
  var PRE_RECEIVE = `#!/bin/sh
2582
- forbidden='^(agent\\.json|cursors\\.json|role\\.md|turns/|\\.claude/|\\.codex/|projects/[^/]+/(sessions[^/]*\\.json|last-turn\\.json|threads/|channels/))'
2662
+ forbidden='^(agent\\.json|cursors\\.json|role\\.md|turns/|\\.claude/|\\.codex/|projects/[^/]+/(sessions[^/]*\\.json|last-turn\\.json|threads/|channels/|places/))'
2583
2663
  while read old new ref; do
2584
2664
  if [ "$ref" != "refs/heads/main" ]; then
2585
2665
  echo "a home takes pushes to main only" >&2
@@ -2905,6 +2985,7 @@ var TurnPayload = z14.object({
2905
2985
  project: z14.string(),
2906
2986
  thread: z14.string().optional(),
2907
2987
  channel: z14.string().optional(),
2988
+ place: z14.string().optional(),
2908
2989
  trigger: z14.string()
2909
2990
  });
2910
2991
  var TaskPayload = z14.object({
@@ -2950,11 +3031,16 @@ function answers(mention, turn) {
2950
3031
  if (!mention.scopes.includes(turn.project)) {
2951
3032
  return false;
2952
3033
  }
2953
- const own = mention.thread === null ? channelConversation(turn.project, mention.channel) : void 0;
3034
+ const topLevel = mention.thread === null;
3035
+ const own = topLevel ? channelConversation(turn.project, mention.channel) : void 0;
3036
+ const place = topLevel ? placeConversation(turn.project, mention.channel) : void 0;
2954
3037
  if (turn.channel !== null) {
2955
- return mention.thread === null && own === turn.channel;
3038
+ return topLevel && own === turn.channel;
3039
+ }
3040
+ if (turn.place !== null) {
3041
+ return topLevel && place === turn.place;
2956
3042
  }
2957
- return mention.thread === null && own === void 0 || turn.trigger === "mention";
3043
+ return topLevel && own === void 0 && place === void 0 || turn.trigger === "mention";
2958
3044
  }
2959
3045
  function computeMetrics(input) {
2960
3046
  const { events, now, window } = input;
@@ -3020,6 +3106,7 @@ function computeMetrics(input) {
3020
3106
  at,
3021
3107
  thread: turn.data.thread ?? null,
3022
3108
  channel: turn.data.channel ?? null,
3109
+ place: turn.data.place ?? null,
3023
3110
  project: turn.data.project,
3024
3111
  trigger: turn.data.trigger
3025
3112
  };
@@ -3219,6 +3306,7 @@ var TurnHistoryPayloadSchema = z15.object({
3219
3306
  project: NameSchema,
3220
3307
  thread: UlidSchema.optional(),
3221
3308
  channel: NameSchema.optional(),
3309
+ place: NameSchema.optional(),
3222
3310
  trigger: z15.string().default("manual"),
3223
3311
  exitReason: z15.string().nullable().default(null),
3224
3312
  costUsd: z15.number().default(0),
@@ -3457,11 +3545,14 @@ var BoardPaths = class {
3457
3545
  return path5.join(this.agentProjects(name), slug);
3458
3546
  }
3459
3547
  /**
3460
- * A thread's or a channel's conversation's session and last turn, beside the scope's home
3461
- * conversation; `part` is a thread's id or `#channel`.
3548
+ * A thread's, a channel's, or, in the society scope, a project's conversation's session and last
3549
+ * turn, beside the scope's home conversation; `part` is a thread's id, `#channel`, or `~project`.
3462
3550
  */
3463
3551
  agentConversation(name, slug, part) {
3464
- return part.startsWith("#") ? path5.join(this.agentProject(name, slug), "channels", part.slice(1)) : path5.join(this.agentProject(name, slug), "threads", part);
3552
+ if (part.startsWith("#")) {
3553
+ return path5.join(this.agentProject(name, slug), "channels", part.slice(1));
3554
+ }
3555
+ return part.startsWith("~") ? path5.join(this.agentProject(name, slug), "places", part.slice(1)) : path5.join(this.agentProject(name, slug), "threads", part);
3465
3556
  }
3466
3557
  agentCursors(name) {
3467
3558
  return path5.join(this.agent(name), "cursors.json");
@@ -3495,6 +3586,111 @@ var BoardPaths = class {
3495
3586
  }
3496
3587
  };
3497
3588
 
3589
+ // packages/board-core/dist/skill-folder.js
3590
+ import { constants } from "node:fs";
3591
+ import { lstat, open as open2, readdir as readdir3, readlink, realpath } from "node:fs/promises";
3592
+ import path6 from "node:path";
3593
+ var SKILL_TEXT_LIMIT_CHARS = 1e5;
3594
+ var SKILL_TEXT_LIMIT_BYTES = SKILL_TEXT_LIMIT_CHARS * 4;
3595
+ function skillFileText(bytes) {
3596
+ const text = bytes.includes(0) ? null : bytes.toString("utf8");
3597
+ return {
3598
+ text: text === null ? null : text.slice(0, SKILL_TEXT_LIMIT_CHARS),
3599
+ truncated: text !== null && text.length > SKILL_TEXT_LIMIT_CHARS
3600
+ };
3601
+ }
3602
+ function inside(root, file) {
3603
+ const relative = path6.relative(root, file);
3604
+ return relative !== ".." && !relative.startsWith(`..${path6.sep}`) && !path6.isAbsolute(relative);
3605
+ }
3606
+ async function handleInside(root, handle) {
3607
+ if (process.platform !== "linux") {
3608
+ return true;
3609
+ }
3610
+ const file = await readlink(`/proc/self/fd/${handle.fd}`).catch(() => null);
3611
+ return file !== null && inside(root, file);
3612
+ }
3613
+ async function walk(root, prefix, found) {
3614
+ const entries = await readdir3(path6.join(root, ...prefix === "" ? [] : prefix.split("/")), {
3615
+ withFileTypes: true
3616
+ }).catch(() => []);
3617
+ for (const entry of entries.toSorted((a, b) => a.name < b.name ? -1 : 1)) {
3618
+ if (found.length >= SKILL_FILES_LIMIT) {
3619
+ return;
3620
+ }
3621
+ const relative = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
3622
+ if (!isSafeRelativePath(relative)) {
3623
+ continue;
3624
+ }
3625
+ if (entry.isDirectory()) {
3626
+ await walk(root, relative, found);
3627
+ } else if (entry.isFile() && !entry.name.endsWith(".tmp")) {
3628
+ found.push(relative);
3629
+ }
3630
+ }
3631
+ }
3632
+ async function readEntry(root, relative) {
3633
+ let real;
3634
+ try {
3635
+ real = await realpath(path6.join(root, ...relative.split("/")));
3636
+ } catch {
3637
+ return null;
3638
+ }
3639
+ if (!inside(root, real)) {
3640
+ return null;
3641
+ }
3642
+ const handle = await open2(real, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK).catch(() => null);
3643
+ if (handle === null) {
3644
+ return null;
3645
+ }
3646
+ try {
3647
+ if (!await handleInside(root, handle)) {
3648
+ return null;
3649
+ }
3650
+ const stats = await handle.stat();
3651
+ if (!stats.isFile()) {
3652
+ return null;
3653
+ }
3654
+ const buffer = Buffer.alloc(Math.min(stats.size, SKILL_TEXT_LIMIT_BYTES));
3655
+ let size = 0;
3656
+ while (size < buffer.length) {
3657
+ const { bytesRead } = await handle.read(buffer, size, buffer.length - size, size);
3658
+ if (bytesRead === 0) {
3659
+ break;
3660
+ }
3661
+ size += bytesRead;
3662
+ }
3663
+ return {
3664
+ path: relative,
3665
+ size: stats.size,
3666
+ executable: (stats.mode & 73) !== 0,
3667
+ ...skillFileText(buffer.subarray(0, size))
3668
+ };
3669
+ } finally {
3670
+ await handle.close();
3671
+ }
3672
+ }
3673
+ async function readSkillFolder(dir) {
3674
+ const info = await lstat(dir).catch(() => null);
3675
+ if (info === null || !info.isDirectory()) {
3676
+ return null;
3677
+ }
3678
+ const root = await realpath(dir).catch(() => null);
3679
+ if (root === null) {
3680
+ return null;
3681
+ }
3682
+ const found = [];
3683
+ await walk(root, "", found);
3684
+ const files = [];
3685
+ for (const relative of found) {
3686
+ const file = await readEntry(root, relative);
3687
+ if (file !== null) {
3688
+ files.push(file);
3689
+ }
3690
+ }
3691
+ return files.some((file) => file.path === "SKILL.md") ? files : null;
3692
+ }
3693
+
3498
3694
  // packages/board-core/dist/tokens.js
3499
3695
  import { createHash as createHash2, randomBytes } from "node:crypto";
3500
3696
  function mintToken() {
@@ -3508,7 +3704,7 @@ function hashToken(token) {
3508
3704
  var SYSTEM_ACTOR = { name: "board", role: USER_ROLE };
3509
3705
  var CLOSING_ATTEMPTS = 3;
3510
3706
  function ownTurn(actor, turn) {
3511
- return turn.agent === actor.name && turn.scope === actor.scope && turn.thread === actor.thread && turn.channel === actor.channel;
3707
+ return turn.agent === actor.name && turn.scope === actor.scope && turn.thread === actor.thread && turn.channel === actor.channel && turn.place === actor.place;
3512
3708
  }
3513
3709
  function namesList(names) {
3514
3710
  return names.length <= 1 ? names[0] ?? "" : `${names.slice(0, -1).join(", ")} and ${names.at(-1) ?? ""}`;
@@ -3521,37 +3717,45 @@ var SignInsSchema = z16.object({
3521
3717
  signIns: z16.array(z16.object({
3522
3718
  tokenHash: z16.string().min(1),
3523
3719
  login: z16.string().min(1),
3524
- /** The account's picture at the identity provider; sign-ins from before it have none. */
3720
+ /** The account's picture, when GitHub gave one on its own avatar host. */
3525
3721
  avatarUrl: z16.url().optional(),
3526
3722
  createdAt: IsoDateTimeSchema,
3527
3723
  expiresAt: IsoDateTimeSchema
3528
3724
  })).default([])
3529
3725
  });
3530
- var TurnStartPayloadSchema = z16.object({ project: NameSchema, turnId: UlidSchema.optional() });
3726
+ var TurnStartPayloadSchema = z16.object({ turnId: UlidSchema });
3531
3727
  var CursorsSchema = z16.object({
3532
3728
  digest: z16.string().nullable(),
3533
3729
  scopes: z16.record(z16.string(), z16.string()).default({}),
3534
- threadsFrom: z16.record(z16.string(), z16.string().nullable()).default({}),
3535
3730
  channelsFrom: z16.record(z16.string(), z16.string().nullable()).default({}),
3731
+ placesFrom: z16.record(z16.string(), z16.string().nullable()).default({}),
3536
3732
  /** How far a work role has read news, which goes to whichever of its general conversations reads first. */
3537
3733
  news: z16.string().optional()
3538
3734
  });
3539
- var NO_CURSORS = { digest: null, scopes: {}, threadsFrom: {}, channelsFrom: {} };
3735
+ var NO_CURSORS = {
3736
+ digest: null,
3737
+ scopes: {},
3738
+ channelsFrom: {},
3739
+ placesFrom: {}
3740
+ };
3540
3741
  function conversationKey(scope, part) {
3541
3742
  return part === null ? scope : `${scope}/${part}`;
3542
3743
  }
3543
3744
  function channelOfPart(part) {
3544
3745
  return part?.startsWith("#") === true ? part.slice(1) : void 0;
3545
3746
  }
3747
+ function placeOfPart(part) {
3748
+ return part?.startsWith("~") === true ? part.slice(1) : void 0;
3749
+ }
3546
3750
  function threadOfPart(part) {
3547
- return part === null || part === void 0 || part.startsWith("#") ? void 0 : part;
3751
+ return part === null || part === void 0 || part.startsWith("#") || part.startsWith("~") ? void 0 : part;
3548
3752
  }
3549
3753
  function channelStart(cursors, scope) {
3550
3754
  const from = cursors.channelsFrom[scope];
3551
3755
  return from === void 0 ? cursors.digest : from;
3552
3756
  }
3553
- function threadStart(cursors, scope) {
3554
- const from = cursors.threadsFrom[scope];
3757
+ function placeStart(cursors, scope) {
3758
+ const from = cursors.placesFrom[scope];
3555
3759
  return from === void 0 ? cursors.digest : from;
3556
3760
  }
3557
3761
  function newsStart(cursors) {
@@ -3565,7 +3769,7 @@ function cursorOf(cursors, scope, part = null) {
3565
3769
  if (part === null) {
3566
3770
  return cursors.scopes[scope] ?? cursors.digest;
3567
3771
  }
3568
- return cursors.scopes[conversationKey(scope, part)] ?? (part.startsWith("#") ? channelStart(cursors, scope) : threadStart(cursors, scope));
3772
+ return cursors.scopes[conversationKey(scope, part)] ?? (part.startsWith("#") ? channelStart(cursors, scope) : part.startsWith("~") ? placeStart(cursors, scope) : cursors.digest);
3569
3773
  }
3570
3774
  function byId(a, b) {
3571
3775
  return a.message.id < b.message.id ? -1 : a.message.id > b.message.id ? 1 : 0;
@@ -3573,21 +3777,23 @@ function byId(a, b) {
3573
3777
  function turnTime(record) {
3574
3778
  return record.endedAt ?? record.startedAt;
3575
3779
  }
3780
+ function turnConversation(record) {
3781
+ return record.thread !== void 0 ? ` in thread ${record.thread}` : record.channel !== void 0 ? ` in #${record.channel}` : record.place !== void 0 ? ` in ${record.place}'s channels` : "";
3782
+ }
3576
3783
  var PausedSchema = z16.object({ paused: z16.boolean() });
3577
3784
  var DEFAULT_LEASE_MS = 30 * 60 * 1e3;
3578
3785
  function withoutMentions(text) {
3579
3786
  return text.replaceAll(MENTION_PATTERN, "$1$2");
3580
3787
  }
3581
- var SKILL_TEXT_LIMIT_CHARS = 1e5;
3582
3788
  function skillFolderOf(proposal) {
3583
3789
  if (proposal.kind !== "skill") {
3584
3790
  return null;
3585
3791
  }
3586
3792
  const parsed = SkillProposalSchema.safeParse(proposal.charter);
3587
- return parsed.success && parsed.data.body === void 0 ? parsed.data.name : null;
3793
+ return parsed.success ? parsed.data.name : null;
3588
3794
  }
3589
3795
  function proposedFrom(origin, where) {
3590
- return origin !== void 0 && origin.scope === where.scope && origin.thread === where.thread && origin.channel === where.channel;
3796
+ return origin !== void 0 && origin.scope === where.scope && origin.thread === where.thread && origin.channel === where.channel && origin.place === where.place;
3591
3797
  }
3592
3798
  function megabytes(bytes) {
3593
3799
  return (bytes / (1024 * 1024)).toFixed(1).replace(/\.0$/, "");
@@ -3599,27 +3805,6 @@ var FRONT_DESK_TRIGGER = "user_post";
3599
3805
  var OPS_CHANNELS = ["governance"];
3600
3806
  var FRONT_DESK_CHANNELS = [ASK_CHANNEL];
3601
3807
  var AlignmentsSchema = z16.object({ applied: z16.array(z16.string()).default([]) });
3602
- var FOLLOW_DECISIONS = "ops-readers-follow-decisions";
3603
- var THREAD_RECORDS = "threads-as-records";
3604
- var DIGEST_CURSORS = "digest-replaces-inbox";
3605
- var TASK_RETURNS = "task-returns-recorded";
3606
- var OPS_CHANNEL_RETIRED = "ops-channel-retired";
3607
- var DECISIONS_CHANNEL_RETIRED = "decisions-channel-retired";
3608
- var THREAD_CONVERSATIONS = "thread-conversations";
3609
- var ASKS_CHANNEL_OPENED = "asks-channel-opened";
3610
- var KNOWLEDGE_REMOVAL = "knowledge-removal-granted";
3611
- var CHANNEL_CONVERSATIONS = "channel-conversations";
3612
- var DASHBOARD_UPDATES = "dashboard-updates-granted";
3613
- var CRON_VERBS = "cron-verbs-granted";
3614
- var KNOWLEDGE_NOTICES = "knowledge-notices";
3615
- var IMAGE_UPLOADS = "image-uploads-granted";
3616
- var KnowledgeEventSchema = z16.object({
3617
- topic: NameSchema,
3618
- project: NameSchema.nullable(),
3619
- replaced: z16.boolean().optional()
3620
- });
3621
- var CRON_VERB_NAMES = ["create_cron", "update_cron", "remove_cron"];
3622
- var OLD_DASHBOARD_LINE = "Agents may edit this file. It is rendered by the board UI.";
3623
3808
  var DASHBOARD_LINE = "The project's members keep this page current with update_dashboard.";
3624
3809
  var PLANNING_ROLES = [USER_ROLE, "steward", "concierge"];
3625
3810
  var REALLOCATING_ROLES = [USER_ROLE, "steward", "concierge"];
@@ -3639,9 +3824,9 @@ function sentence(summary) {
3639
3824
  return summary.trim().replace(/\.+$/, "");
3640
3825
  }
3641
3826
  function skillSummary(data, body) {
3642
- const fromFrontmatter = data["description"] ?? data["summary"];
3643
- if (typeof fromFrontmatter === "string" && fromFrontmatter.trim().length > 0) {
3644
- return fromFrontmatter.trim();
3827
+ const description = data["description"];
3828
+ if (typeof description === "string" && description.trim().length > 0) {
3829
+ return description.trim();
3645
3830
  }
3646
3831
  return body.split("\n").map((line) => line.trim()).find((line) => line.length > 0 && !line.startsWith("#")) ?? "";
3647
3832
  }
@@ -3683,8 +3868,8 @@ function restage(existing, edit) {
3683
3868
  };
3684
3869
  }
3685
3870
  function reopenStage(stage) {
3686
- const { completedBy: _by, completedAt: _at, ...open2 } = stage;
3687
- return open2;
3871
+ const { completedBy: _by, completedAt: _at, ...open3 } = stage;
3872
+ return open3;
3688
3873
  }
3689
3874
  function gateChanges(before, after) {
3690
3875
  const changes = [];
@@ -3765,7 +3950,7 @@ var Board = class _Board {
3765
3950
  homes;
3766
3951
  constructor(dataDir, options) {
3767
3952
  this.paths = new BoardPaths(dataDir);
3768
- this.homes = new HomeRepos(path6.join(this.paths.state(), "home-hooks"));
3953
+ this.homes = new HomeRepos(path7.join(this.paths.state(), "home-hooks"));
3769
3954
  this.leaseMs = options.leaseMs ?? DEFAULT_LEASE_MS;
3770
3955
  this.now = options.now ?? (() => /* @__PURE__ */ new Date());
3771
3956
  this.events = new EventLog(this.paths.eventLog(), () => this.newId(), this.now);
@@ -3794,368 +3979,22 @@ var Board = class _Board {
3794
3979
  return board2;
3795
3980
  }
3796
3981
  /**
3797
- * Applies each one-time alignment an existing society has not had yet. Once applied it is not
3798
- * repeated, so a member that later chooses otherwise keeps its choice.
3982
+ * Applies each one-time alignment an existing society has not had yet, and remembers it, so a
3983
+ * member that later chooses otherwise keeps its choice. A new alignment is a name and a method in
3984
+ * this list; once every board has applied it, both are deleted. None is pending.
3799
3985
  */
3800
3986
  async applyAlignments() {
3801
- const file = path6.join(this.paths.state(), "alignments.json");
3987
+ const alignments = [];
3988
+ const file = path7.join(this.paths.state(), "alignments.json");
3802
3989
  const { applied } = await exists(file) ? await readJson(file, AlignmentsSchema) : { applied: [] };
3803
- const pending = [
3804
- FOLLOW_DECISIONS,
3805
- THREAD_RECORDS,
3806
- DIGEST_CURSORS,
3807
- TASK_RETURNS,
3808
- OPS_CHANNEL_RETIRED,
3809
- DECISIONS_CHANNEL_RETIRED,
3810
- THREAD_CONVERSATIONS,
3811
- ASKS_CHANNEL_OPENED,
3812
- KNOWLEDGE_REMOVAL,
3813
- CHANNEL_CONVERSATIONS,
3814
- DASHBOARD_UPDATES,
3815
- CRON_VERBS,
3816
- KNOWLEDGE_NOTICES,
3817
- IMAGE_UPLOADS
3818
- ].filter((name) => !applied.includes(name));
3990
+ const pending = alignments.filter(({ name }) => !applied.includes(name));
3819
3991
  if (pending.length === 0) {
3820
3992
  return;
3821
3993
  }
3822
- if (pending.includes(FOLLOW_DECISIONS)) {
3823
- await this.followDecisions();
3824
- }
3825
- if (pending.includes(THREAD_RECORDS)) {
3826
- await this.recordTaskThreads();
3827
- }
3828
- if (pending.includes(DIGEST_CURSORS)) {
3829
- await this.renameInboxCursors();
3830
- }
3831
- if (pending.includes(TASK_RETURNS)) {
3832
- await this.recordTaskReturns();
3833
- }
3834
- if (pending.includes(OPS_CHANNEL_RETIRED)) {
3835
- await this.retireSocietyChannel("ops");
3836
- }
3837
- if (pending.includes(DECISIONS_CHANNEL_RETIRED)) {
3838
- await this.retireSocietyChannel("decisions");
3839
- }
3840
- if (pending.includes(THREAD_CONVERSATIONS)) {
3841
- await this.startThreadCursors();
3842
- }
3843
- if (pending.includes(ASKS_CHANNEL_OPENED)) {
3844
- await this.openAsksChannel();
3845
- }
3846
- if (pending.includes(KNOWLEDGE_REMOVAL)) {
3847
- await this.grantBeside("write_knowledge", "remove_knowledge");
3848
- }
3849
- if (pending.includes(CHANNEL_CONVERSATIONS)) {
3850
- await this.startChannelCursors();
3851
- }
3852
- if (pending.includes(DASHBOARD_UPDATES)) {
3853
- await this.grantBeside("write_knowledge", "update_dashboard");
3854
- await this.rewordDashboards();
3855
- }
3856
- if (pending.includes(CRON_VERBS)) {
3857
- await this.grantToEveryRole(CRON_VERB_NAMES);
3858
- }
3859
- if (pending.includes(KNOWLEDGE_NOTICES)) {
3860
- await this.noticeKnowledgeNotes();
3861
- }
3862
- if (pending.includes(IMAGE_UPLOADS)) {
3863
- await this.grantBeside("post_message", "upload_image");
3864
- }
3865
- await writeJson(file, { applied: [...applied, ...pending] });
3866
- }
3867
- /**
3868
- * Gives each knowledge note from before notes carried their notice the notice it would carry
3869
- * now. The event log, not the note's words, says which post is one: a knowledge verb appends
3870
- * its event and then its note under the mutex, so a note is the post right after such an event,
3871
- * by the same actor, at the top of the general channel of the topic's place. A post written to
3872
- * look like a note follows no such event and is left as it is.
3873
- */
3874
- async noticeKnowledgeNotes() {
3875
- let before;
3876
- for (const event of await this.events.readSince(null, Number.POSITIVE_INFINITY)) {
3877
- const verb = before;
3878
- before = event;
3879
- if (event.type !== "message.posted" || verb === void 0 || verb.actor !== event.actor || verb.type !== "knowledge.written" && verb.type !== "knowledge.removed") {
3880
- continue;
3881
- }
3882
- const knowledge = KnowledgeEventSchema.safeParse(verb.payload);
3883
- const id = UlidSchema.safeParse(event.payload["id"]);
3884
- if (!knowledge.success || !id.success || (event.payload["thread"] ?? null) !== null || event.payload["channel"] !== channelRef(knowledge.data.project, "general")) {
3885
- continue;
3886
- }
3887
- const { topic, project, replaced } = knowledge.data;
3888
- const file = this.paths.messageFile(this.paths.channelDir(channelRef(project, "general")), id.data, event.actor);
3889
- if (!await exists(file)) {
3890
- continue;
3891
- }
3892
- const { data, body, error } = await readLooseMarkdown(file);
3893
- if (error !== null || data["notice"] !== void 0) {
3894
- continue;
3895
- }
3896
- const notice = {
3897
- kind: "knowledge",
3898
- action: verb.type === "knowledge.removed" ? "removed" : replaced === true ? "updated" : "written",
3899
- topic,
3900
- project
3901
- };
3902
- await writeMarkdown(file, { ...data, notice }, body);
3903
- }
3904
- }
3905
- /**
3906
- * Grants `verb` once to every charter granting `beside`, the work roles' charters in practice;
3907
- * seed roles have it from their seed.
3908
- */
3909
- async grantBeside(beside, verb) {
3910
- for (const charter of await this.listRoles()) {
3911
- if (charter.verbs.includes(beside) && !charter.verbs.includes(verb)) {
3912
- await this.writeRoleUnlocked(USER_NAME, { ...charter, verbs: [...charter.verbs, verb] }, { verbsAdded: [verb] });
3913
- }
3914
- }
3915
- }
3916
- /** Grants verbs once to every charter but the user's, which holds every verb already. */
3917
- async grantToEveryRole(verbs) {
3918
- for (const charter of await this.listRoles()) {
3919
- const missing = verbs.filter((verb) => !charter.verbs.includes(verb));
3920
- if (charter.name !== USER_ROLE && missing.length > 0) {
3921
- await this.writeRoleUnlocked(USER_NAME, { ...charter, verbs: [...charter.verbs, ...missing] }, { verbsAdded: missing });
3922
- }
3923
- }
3924
- }
3925
- /** A dashboard still saying agents may edit its file says how it changes now. */
3926
- async rewordDashboards() {
3927
- for (const slug of await listDirs(this.paths.projects())) {
3928
- const file = this.paths.dashboard(slug);
3929
- if (!await exists(file)) {
3930
- continue;
3931
- }
3932
- const { data, body } = await readLooseMarkdown(file);
3933
- if (body.includes(OLD_DASHBOARD_LINE)) {
3934
- await writeMarkdown(file, data, body.replace(OLD_DASHBOARD_LINE, DASHBOARD_LINE));
3935
- }
3936
- }
3937
- }
3938
- /**
3939
- * A task sent back before tasks recorded it kept no trace outside the event log. A task still
3940
- * short of the stage its latest `task.moved` came from is in that rework, and records it.
3941
- */
3942
- async recordTaskReturns() {
3943
- const moves = /* @__PURE__ */ new Map();
3944
- for (const event of await this.events.readSince(null, Number.POSITIVE_INFINITY)) {
3945
- const taskId = event.payload["taskId"];
3946
- if (event.type === "task.moved" && typeof taskId === "string") {
3947
- moves.set(taskId, event);
3948
- }
3949
- }
3950
- for (const slug of await listDirs(this.paths.projects())) {
3951
- for (const task of await this.listTasks(slug)) {
3952
- const move = moves.get(task.id);
3953
- const from = move?.payload["from"];
3954
- if (move === void 0 || typeof from !== "string" || task.returned !== void 0 || task.completing || task.status !== "open" && task.status !== "claimed") {
3955
- continue;
3956
- }
3957
- const back = stageIndex(task, from);
3958
- if (back > stageIndex(task, task.stage)) {
3959
- await this.writeTask(slug, {
3960
- ...task,
3961
- returned: { from, by: move.actor, at: move.ts }
3962
- });
3963
- }
3964
- }
3965
- }
3966
- }
3967
- /**
3968
- * A society channel the board no longer uses leaves the society's list and every member's
3969
- * subscriptions; its old posts stay on disk. `ops` carried operations signals, which are only
3970
- * logged now; `decisions` carried proposals' outcomes, which are posts in their threads now, and
3971
- * requests to the user, which are mentions of the user wherever the question belongs.
3972
- */
3973
- async retireSocietyChannel(name) {
3974
- const doc = await readMarkdown(this.paths.societyFile(), SocietySchema);
3975
- if (doc.data.channels.includes(name)) {
3976
- await writeMarkdown(this.paths.societyFile(), { ...doc.data, channels: doc.data.channels.filter((channel) => channel !== name) }, doc.body);
3977
- }
3978
- for (const agent of await this.listAgents()) {
3979
- if (agent.subscriptions.includes(name)) {
3980
- await this.updateAgent(agent.name, (a) => ({
3981
- ...a,
3982
- subscriptions: a.subscriptions.filter((ref) => ref !== name)
3983
- }));
3984
- await this.refreshMember(agent.name);
3985
- }
3986
- }
3987
- }
3988
- /**
3989
- * Asks once opened their threads in general, where closing one posted its summary to every
3990
- * citizen and woke the steward's heartbeat for it. The society gains the asks channel, and the
3991
- * front desk follows it.
3992
- */
3993
- async openAsksChannel() {
3994
- const doc = await readMarkdown(this.paths.societyFile(), SocietySchema);
3995
- if (!doc.data.channels.includes(ASK_CHANNEL)) {
3996
- await writeMarkdown(this.paths.societyFile(), { ...doc.data, channels: [...doc.data.channels, ASK_CHANNEL] }, doc.body);
3997
- }
3998
- await ensureDir(this.paths.societyChannel(ASK_CHANNEL));
3999
- for (const agent of await this.listAgents()) {
4000
- if (agent.status !== "active" || agent.subscriptions.includes(ASK_CHANNEL)) {
4001
- continue;
4002
- }
4003
- if (!(await this.readRole(agent.role)).wakeTriggers.includes(FRONT_DESK_TRIGGER)) {
4004
- continue;
4005
- }
4006
- await this.updateAgent(agent.name, (a) => ({
4007
- ...a,
4008
- subscriptions: [...a.subscriptions, ASK_CHANNEL]
4009
- }));
4010
- await this.refreshMember(agent.name);
4011
- await this.events.append("subscription.changed", agent.name, {
4012
- channel: ASK_CHANNEL,
4013
- subscribed: true,
4014
- aligned: true
4015
- });
4016
- }
4017
- }
4018
- /** Members that predate `decisions` among the ops channels read proposals without their outcome. */
4019
- async followDecisions() {
4020
- if (!(await this.society()).channels.includes("decisions")) {
4021
- return;
4022
- }
4023
- for (const agent of await this.listAgents()) {
4024
- if (agent.status !== "active" || agent.subscriptions.includes("decisions")) {
4025
- continue;
4026
- }
4027
- if (!(await this.readRole(agent.role)).wakeTriggers.includes(OPS_WAKE_TRIGGER)) {
4028
- continue;
4029
- }
4030
- await this.updateAgent(agent.name, (a) => ({
4031
- ...a,
4032
- subscriptions: [...a.subscriptions, "decisions"]
4033
- }));
4034
- await this.refreshMember(agent.name);
4035
- await this.events.append("subscription.changed", agent.name, {
4036
- channel: "decisions",
4037
- subscribed: true,
4038
- aligned: true
4039
- });
4040
- }
4041
- }
4042
- /**
4043
- * Threads were once a state field on their task. Each becomes a record under the task's project,
4044
- * on its general channel, closed if its task has ended, and the field leaves the task file.
4045
- */
4046
- async recordTaskThreads() {
4047
- const opened = /* @__PURE__ */ new Map();
4048
- const closed = /* @__PURE__ */ new Map();
4049
- for (const event of await this.events.readSince(null, Number.POSITIVE_INFINITY)) {
4050
- const taskId = event.payload["taskId"];
4051
- if (typeof taskId !== "string") {
4052
- continue;
4053
- }
4054
- if (event.type === "thread.opened") {
4055
- opened.set(taskId, event);
4056
- } else if (event.type === "thread.closed") {
4057
- closed.set(taskId, event);
4058
- }
4059
- }
4060
- for (const slug of await listDirs(this.paths.projects())) {
4061
- const threads = this.paths.threads(slug);
4062
- const summaries = /* @__PURE__ */ new Map();
4063
- for (const message of await this.readMessagesIn(this.paths.projectChannel(slug, "general"), null)) {
4064
- if (message.task !== void 0) {
4065
- summaries.set(message.task, message.body.replace(/^Thread closed[^\n]*\n\n/, ""));
4066
- }
4067
- }
4068
- for (const file of await listFiles(this.paths.tasks(slug))) {
4069
- const raw = await readMarkdown(path6.join(this.paths.tasks(slug), file), z16.record(z16.string(), z16.unknown()));
4070
- const legacy = raw.data["thread"];
4071
- if (legacy === void 0) {
4072
- continue;
4073
- }
4074
- const task = { ...TaskFrontmatterSchema.parse(raw.data), body: raw.body };
4075
- if ((legacy === "open" || legacy === "closed") && !await exists(this.paths.threadFile(threads, task.id))) {
4076
- const ended = task.status === "done" || task.status === "abandoned";
4077
- const open2 = legacy === "open" && !ended;
4078
- const openedBy = opened.get(task.id);
4079
- const closedBy = closed.get(task.id);
4080
- await this.writeThread(threads, {
4081
- id: task.id,
4082
- channel: channelRef(slug, "general"),
4083
- title: task.title,
4084
- subject: { kind: "task", id: task.id },
4085
- state: open2 ? "open" : "closed",
4086
- openedBy: openedBy?.actor ?? task.createdBy,
4087
- openedAt: openedBy?.ts ?? task.createdAt,
4088
- ...open2 ? {} : {
4089
- closedBy: closedBy?.actor ?? SYSTEM_ACTOR.name,
4090
- closedAt: closedBy?.ts ?? this.now().toISOString()
4091
- },
4092
- body: summaries.get(task.id) ?? ""
4093
- });
4094
- }
4095
- await this.writeTask(slug, task);
4096
- }
3994
+ for (const { apply } of pending) {
3995
+ await apply();
4097
3996
  }
4098
- }
4099
- /**
4100
- * Every channel of a scope was once read in its home conversation, so a member had read each up
4101
- * to its scope's cursor. Each channel beside general starts from that cursor now, as each takes a
4102
- * conversation of its own.
4103
- */
4104
- async startChannelCursors() {
4105
- for (const agent of await this.listAgents()) {
4106
- const file = this.paths.agentCursors(agent.name);
4107
- if (!await exists(file)) {
4108
- continue;
4109
- }
4110
- const cursors = await readJson(file, CursorsSchema);
4111
- const scopes = /* @__PURE__ */ new Set([
4112
- ...Object.keys(cursors.scopes).filter((key) => !key.includes("/")),
4113
- ...agent.memberships,
4114
- SOCIETY_SCOPE
4115
- ]);
4116
- const channelsFrom = Object.fromEntries([...scopes].map((scope) => [scope, cursorOf(cursors, scope)]));
4117
- await writeJson(file, { ...cursors, channelsFrom });
4118
- }
4119
- }
4120
- /**
4121
- * Threads were once read in the scope's single conversation, so a member had read every thread
4122
- * up to its scope's cursor. Each scope's threads start from that cursor now, as each thread's
4123
- * conversation takes a cursor of its own.
4124
- */
4125
- async startThreadCursors() {
4126
- for (const agent of await this.listAgents()) {
4127
- const file = this.paths.agentCursors(agent.name);
4128
- if (!await exists(file)) {
4129
- continue;
4130
- }
4131
- const cursors = await readJson(file, CursorsSchema);
4132
- const scopes = /* @__PURE__ */ new Set([
4133
- ...Object.keys(cursors.scopes).filter((key) => !key.includes("/")),
4134
- ...agent.memberships,
4135
- SOCIETY_SCOPE
4136
- ]);
4137
- const threadsFrom = Object.fromEntries([...scopes].map((scope) => [scope, cursorOf(cursors, scope)]));
4138
- await writeJson(file, { ...cursors, threadsFrom });
4139
- }
4140
- }
4141
- /**
4142
- * The digest was once called the inbox: each cursor file keyed its cursor `inbox`, and the user
4143
- * followed every society channel and project general channel to read one. The user takes no
4144
- * turns, so it follows nothing now.
4145
- */
4146
- async renameInboxCursors() {
4147
- const Legacy = z16.object({ inbox: z16.string().nullable() });
4148
- for (const agent of await this.listAgents()) {
4149
- const file = this.paths.agentCursors(agent.name);
4150
- if (await exists(file)) {
4151
- const legacy = Legacy.safeParse(JSON.parse(await readFile4(file, "utf8")));
4152
- if (legacy.success) {
4153
- await writeJson(file, { digest: legacy.data.inbox });
4154
- }
4155
- }
4156
- }
4157
- await this.updateAgent(USER_NAME, (user) => ({ ...user, subscriptions: [] }));
4158
- await this.refreshMember(USER_NAME);
3997
+ await writeJson(file, { applied: [...applied, ...pending.map(({ name }) => name)] });
4159
3998
  }
4160
3999
  /** Writes every missing seed charter and aligns the existing ones with their seed. */
4161
4000
  async ensureSeedRoles() {
@@ -4257,13 +4096,15 @@ ${charter.purpose}
4257
4096
  const token = mintToken();
4258
4097
  const thread = threadOfPart(part);
4259
4098
  const channel = channelOfPart(part);
4099
+ const place = placeOfPart(part);
4260
4100
  this.turnTokens.set(hashToken(token), {
4261
4101
  actor: {
4262
4102
  name: agent,
4263
4103
  role,
4264
4104
  ...scope === void 0 ? {} : { scope },
4265
4105
  ...thread === void 0 ? {} : { thread },
4266
- ...channel === void 0 ? {} : { channel }
4106
+ ...channel === void 0 ? {} : { channel },
4107
+ ...place === void 0 ? {} : { place }
4267
4108
  },
4268
4109
  expiresAt: this.now().getTime() + ttlMs
4269
4110
  });
@@ -4352,7 +4193,7 @@ ${charter.purpose}
4352
4193
  });
4353
4194
  }
4354
4195
  signInsFile() {
4355
- return path6.join(this.paths.state(), "sign-ins.json");
4196
+ return path7.join(this.paths.state(), "sign-ins.json");
4356
4197
  }
4357
4198
  async readSignIns() {
4358
4199
  const file = this.signInsFile();
@@ -4449,7 +4290,7 @@ ${charter.purpose}
4449
4290
  const files = await listFiles(this.paths.tasks(project));
4450
4291
  const tasks = [];
4451
4292
  for (const file of files) {
4452
- const doc = await readMarkdown(path6.join(this.paths.tasks(project), file), TaskFrontmatterSchema);
4293
+ const doc = await readMarkdown(path7.join(this.paths.tasks(project), file), TaskFrontmatterSchema);
4453
4294
  tasks.push({ ...doc.data, body: doc.body });
4454
4295
  }
4455
4296
  return tasks;
@@ -4468,7 +4309,7 @@ ${charter.purpose}
4468
4309
  const files = await listFiles(this.paths.proposals());
4469
4310
  const proposals = [];
4470
4311
  for (const file of files) {
4471
- const doc = await readMarkdown(path6.join(this.paths.proposals(), file), ProposalFrontmatterSchema);
4312
+ const doc = await readMarkdown(path7.join(this.paths.proposals(), file), ProposalFrontmatterSchema);
4472
4313
  proposals.push({ ...doc.data, body: doc.body });
4473
4314
  }
4474
4315
  return proposals;
@@ -4495,7 +4336,7 @@ ${charter.purpose}
4495
4336
  async listMembers() {
4496
4337
  const members = [];
4497
4338
  for (const file of await listFiles(this.paths.members())) {
4498
- const doc = await readMarkdown(path6.join(this.paths.members(), file), MemberSchema);
4339
+ const doc = await readMarkdown(path7.join(this.paths.members(), file), MemberSchema);
4499
4340
  members.push({ ...doc.data, profile: doc.body });
4500
4341
  }
4501
4342
  return members;
@@ -4514,7 +4355,7 @@ ${charter.purpose}
4514
4355
  const dir = project === null ? this.paths.societyKnowledge() : this.paths.projectKnowledge(project);
4515
4356
  const topics = [];
4516
4357
  for (const file of await listFiles(dir)) {
4517
- topics.push(await this.knowledgeAt(path6.join(dir, file), project, file.replace(/\.md$/, "")));
4358
+ topics.push(await this.knowledgeAt(path7.join(dir, file), project, file.replace(/\.md$/, "")));
4518
4359
  }
4519
4360
  return topics;
4520
4361
  }
@@ -4544,7 +4385,7 @@ ${charter.purpose}
4544
4385
  const aside = this.paths.removedKnowledge(project ?? SOCIETY_SCOPE);
4545
4386
  let highest = 0;
4546
4387
  for (const file of (await listFiles(aside)).filter((name) => name.startsWith(`${topic}.`))) {
4547
- const copy = await this.knowledgeAt(path6.join(aside, file), project, topic);
4388
+ const copy = await this.knowledgeAt(path7.join(aside, file), project, topic);
4548
4389
  highest = Math.max(highest, copy.revision);
4549
4390
  }
4550
4391
  return highest;
@@ -4560,7 +4401,7 @@ ${charter.purpose}
4560
4401
  async listSkillsIn(dir, scope) {
4561
4402
  const skills = [];
4562
4403
  for (const entry of await listDirs(dir)) {
4563
- const file = path6.join(dir, entry, "SKILL.md");
4404
+ const file = path7.join(dir, entry, "SKILL.md");
4564
4405
  if (!await exists(file)) {
4565
4406
  continue;
4566
4407
  }
@@ -4650,7 +4491,7 @@ ${charter.purpose}
4650
4491
  if (!wanted.delete(file.slice(0, 26))) {
4651
4492
  continue;
4652
4493
  }
4653
- const { data } = await readMarkdown(path6.join(dir, file), MessageFrontmatterSchema);
4494
+ const { data } = await readMarkdown(path7.join(dir, file), MessageFrontmatterSchema);
4654
4495
  found.push(MessageRefSchema.parse(data));
4655
4496
  }
4656
4497
  if (wanted.size === 0) {
@@ -4682,7 +4523,7 @@ ${charter.purpose}
4682
4523
  if (!file.endsWith(".md")) {
4683
4524
  continue;
4684
4525
  }
4685
- const doc = await readMarkdown(path6.join(threads, file), ThreadFrontmatterSchema);
4526
+ const doc = await readMarkdown(path7.join(threads, file), ThreadFrontmatterSchema);
4686
4527
  if (doc.data.state !== "open") {
4687
4528
  continue;
4688
4529
  }
@@ -4709,7 +4550,7 @@ ${charter.purpose}
4709
4550
  const all = [];
4710
4551
  for (const threads of await this.threadScopes()) {
4711
4552
  for (const file of await listFiles(threads)) {
4712
- const doc = await readMarkdown(path6.join(threads, file), ThreadFrontmatterSchema);
4553
+ const doc = await readMarkdown(path7.join(threads, file), ThreadFrontmatterSchema);
4713
4554
  const messages = await listFiles(this.paths.threadMessages(threads, doc.data.id));
4714
4555
  const last = messages.at(-1);
4715
4556
  all.push({
@@ -4957,7 +4798,7 @@ ${DASHBOARD_LINE}
4957
4798
  if (event.type === "turn.started") {
4958
4799
  const start = TurnStartPayloadSchema.safeParse(event.payload);
4959
4800
  if (start.success)
4960
- starts.set(start.data.turnId ?? start.data.project, event.ts);
4801
+ starts.set(start.data.turnId, event.ts);
4961
4802
  continue;
4962
4803
  }
4963
4804
  if (event.type !== "turn.completed" && event.type !== "turn.failed") {
@@ -4965,9 +4806,11 @@ ${DASHBOARD_LINE}
4965
4806
  }
4966
4807
  const parsed = TurnHistoryPayloadSchema.safeParse(event.payload);
4967
4808
  if (parsed.success) {
4968
- const key = parsed.data.turnId ?? parsed.data.project;
4969
- const startedAt = starts.get(key);
4970
- starts.delete(key);
4809
+ const { turnId } = parsed.data;
4810
+ const startedAt = turnId === void 0 ? void 0 : starts.get(turnId);
4811
+ if (turnId !== void 0) {
4812
+ starts.delete(turnId);
4813
+ }
4971
4814
  entries.push({
4972
4815
  id: event.id,
4973
4816
  ts: event.ts,
@@ -5076,16 +4919,20 @@ ${DASHBOARD_LINE}
5076
4919
  }
5077
4920
  /**
5078
4921
  * How many unread digest messages a member has in each conversation: a scope's home, for its
5079
- * general channel, one of its other channels, or one of its threads. The heartbeat asks, so that it wakes a member where its unread
4922
+ * general channel, one of its other channels, one of its threads, or, in the society scope, a
4923
+ * project's channels. The heartbeat asks, so that it wakes a member where its unread
5080
4924
  * messages are and nowhere else. Society channels count only for a member woken in the society
5081
4925
  * scope; a project member reads them at its next turn. Two kinds of post are in the digest but not
5082
4926
  * counted, and are read when the member wakes for something else: a task step's, which wakes
5083
4927
  * whoever it hands work to, and the board's own, operations signals and landing announcements,
5084
- * whose signals that call for action wake their readers directly.
4928
+ * whose signals that call for action wake their readers directly. A closed thread counts for
4929
+ * nothing: its conversation has ended, and a turn there could only read its posts and be refused
4930
+ * a reply, while a wake queued before it closed, as for a mention, still runs there.
5085
4931
  */
5086
4932
  async unreadByConversation(actor) {
5087
4933
  const { cursors, upto } = await this.digestSnapshot(actor.name);
5088
4934
  const counts = /* @__PURE__ */ new Map();
4935
+ const closed = /* @__PURE__ */ new Map();
5089
4936
  const unscoped = { name: actor.name, role: actor.role };
5090
4937
  for (const { scope, part, message, news } of await this.digestSince(unscoped, cursors, upto)) {
5091
4938
  if (news === true || message.step !== void 0 || message.author === SYSTEM_ACTOR.name) {
@@ -5095,11 +4942,23 @@ ${DASHBOARD_LINE}
5095
4942
  if (posted !== scope && scope !== SOCIETY_SCOPE) {
5096
4943
  continue;
5097
4944
  }
4945
+ const thread = threadOfPart(part);
4946
+ if (thread !== void 0) {
4947
+ let ended = closed.get(thread);
4948
+ if (ended === void 0) {
4949
+ ended = (await this.tryFindThread(thread))?.thread.state === "closed";
4950
+ closed.set(thread, ended);
4951
+ }
4952
+ if (ended) {
4953
+ continue;
4954
+ }
4955
+ }
5098
4956
  const key = conversationKey(scope, part);
5099
4957
  const entry = counts.get(key) ?? {
5100
4958
  scope,
5101
- thread: threadOfPart(part) ?? null,
4959
+ thread: thread ?? null,
5102
4960
  channel: channelOfPart(part) ?? null,
4961
+ place: placeOfPart(part) ?? null,
5103
4962
  count: 0
5104
4963
  };
5105
4964
  counts.set(key, { ...entry, count: entry.count + 1 });
@@ -5157,8 +5016,9 @@ ${DASHBOARD_LINE}
5157
5016
  * thread it takes part in, and for the front desk, which every post by the user wakes, every post
5158
5017
  * by the user wherever it is. With a conversation, only what is filed there: for a thread, every
5159
5018
  * message of the thread, since the conversation is about it; for a channel's, that channel's
5160
- * top-level messages; for a scope's home, general's and those of a project the member is not in,
5161
- * since each thread and each other channel is a conversation of its own. Nothing newer than
5019
+ * top-level messages; for a project's in the society scope, the top-level messages of every
5020
+ * channel of a project the member is not in; for a scope's home, general's, since each thread,
5021
+ * each other channel, and each such project is a conversation of its own. Nothing newer than
5162
5022
  * `upto` counts (see `digestSnapshot`).
5163
5023
  */
5164
5024
  async digestSince(actor, cursors, upto, conversation) {
@@ -5178,11 +5038,17 @@ ${DASHBOARD_LINE}
5178
5038
  const frontDesk = charter.wakeTriggers.includes(FRONT_DESK_TRIGGER);
5179
5039
  const threadParticipation = /* @__PURE__ */ new Map();
5180
5040
  const openChannels = /* @__PURE__ */ new Map();
5041
+ const liveProjects = /* @__PURE__ */ new Map();
5181
5042
  const collected = [];
5182
5043
  const newsFrom = charter.societyScope ? void 0 : newsStart(cursors);
5183
5044
  const scopes = conversation !== void 0 ? [conversation.scope] : [...agent.memberships, SOCIETY_SCOPE];
5184
5045
  const starts = [
5185
- ...scopes.flatMap((each) => conversation === void 0 ? [cursorOf(cursors, each), threadStart(cursors, each), channelStart(cursors, each)] : [cursorOf(cursors, each, conversation.part)]),
5046
+ ...scopes.flatMap((each) => conversation === void 0 ? [
5047
+ cursorOf(cursors, each),
5048
+ cursors.digest,
5049
+ channelStart(cursors, each),
5050
+ placeStart(cursors, each)
5051
+ ] : [cursorOf(cursors, each, conversation.part)]),
5186
5052
  ...newsFrom === void 0 ? [] : [newsFrom]
5187
5053
  ];
5188
5054
  const known = starts.filter((start) => start !== null);
@@ -5192,7 +5058,7 @@ ${DASHBOARD_LINE}
5192
5058
  const thread = message.thread ?? null;
5193
5059
  const asked = message.mentions.includes(actor.name) || frontDesk && message.author === USER_NAME;
5194
5060
  if (newsFrom !== void 0 && thread === null && filedIn === SOCIETY_SCOPE && !asked) {
5195
- if ((newsFrom === null || message.id > newsFrom) && subscribed.has(message.channel) && message.author !== actor.name) {
5061
+ if ((conversation === void 0 || conversation.part === null) && (newsFrom === null || message.id > newsFrom) && subscribed.has(message.channel) && message.author !== actor.name) {
5196
5062
  collected.push({
5197
5063
  scope: conversation?.scope ?? SOCIETY_SCOPE,
5198
5064
  part: null,
@@ -5203,12 +5069,18 @@ ${DASHBOARD_LINE}
5203
5069
  continue;
5204
5070
  }
5205
5071
  const named = thread === null ? channelConversation(filedIn, message.channel) : void 0;
5206
- let open2 = named === void 0 ? void 0 : openChannels.get(message.channel);
5207
- if (named !== void 0 && open2 === void 0) {
5208
- open2 = await this.channelOpen(message.channel);
5209
- openChannels.set(message.channel, open2);
5210
- }
5211
- const part = thread ?? (open2 === true ? `#${named}` : null);
5072
+ let open3 = named === void 0 ? void 0 : openChannels.get(message.channel);
5073
+ if (named !== void 0 && open3 === void 0) {
5074
+ open3 = await this.channelOpen(message.channel);
5075
+ openChannels.set(message.channel, open3);
5076
+ }
5077
+ const project = thread === null ? placeConversation(filedIn, message.channel) : void 0;
5078
+ let live = project === void 0 ? void 0 : liveProjects.get(project);
5079
+ if (project !== void 0 && live === void 0) {
5080
+ live = await this.projectLive(project);
5081
+ liveProjects.set(project, live);
5082
+ }
5083
+ const part = thread ?? (open3 === true ? `#${named}` : live === true ? `~${project}` : null);
5212
5084
  if (conversation !== void 0 && (filedIn !== conversation.scope || part !== conversation.part)) {
5213
5085
  continue;
5214
5086
  }
@@ -5273,7 +5145,7 @@ ${task.body}`;
5273
5145
  }
5274
5146
  }
5275
5147
  for (const file of await listFiles(this.paths.projectKnowledge(project))) {
5276
- const content = await readFile4(path6.join(this.paths.projectKnowledge(project), file), "utf8");
5148
+ const content = await readFile4(path7.join(this.paths.projectKnowledge(project), file), "utf8");
5277
5149
  if (content.toLowerCase().includes(needle)) {
5278
5150
  hits.push({
5279
5151
  kind: "knowledge",
@@ -5294,7 +5166,7 @@ ${task.body}`;
5294
5166
  shared.push({
5295
5167
  kind: "knowledge",
5296
5168
  ref: `society/knowledge/${file}`,
5297
- file: path6.join(this.paths.societyKnowledge(), file)
5169
+ file: path7.join(this.paths.societyKnowledge(), file)
5298
5170
  });
5299
5171
  }
5300
5172
  for (const skill of await this.listSocietySkills()) {
@@ -5307,7 +5179,7 @@ ${task.body}`;
5307
5179
  shared.push({
5308
5180
  kind: "memory",
5309
5181
  ref: `memory/${file}`,
5310
- file: path6.join(this.paths.agentMemory(actor.name), file)
5182
+ file: path7.join(this.paths.agentMemory(actor.name), file)
5311
5183
  });
5312
5184
  }
5313
5185
  for (const entry of shared) {
@@ -5397,7 +5269,7 @@ ${current.body}`);
5397
5269
  });
5398
5270
  const aside = this.paths.removedKnowledge(args.project ?? SOCIETY_SCOPE);
5399
5271
  await mkdir4(aside, { recursive: true });
5400
- await rename3(file, path6.join(aside, `${args.topic}.${removed.removedAt.replaceAll(":", "-")}.md`));
5272
+ await rename3(file, path7.join(aside, `${args.topic}.${removed.removedAt.replaceAll(":", "-")}.md`));
5401
5273
  await this.events.append("knowledge.removed", actor.name, {
5402
5274
  topic: args.topic,
5403
5275
  project: args.project
@@ -5445,7 +5317,7 @@ ${current.body}`);
5445
5317
  });
5446
5318
  const aside = this.paths.removedSkills();
5447
5319
  await mkdir4(aside, { recursive: true });
5448
- await rename3(this.paths.societySkill(args.name), path6.join(aside, `${args.name}.${removed.removedAt.replaceAll(":", "-")}`));
5320
+ await rename3(this.paths.societySkill(args.name), path7.join(aside, `${args.name}.${removed.removedAt.replaceAll(":", "-")}`));
5449
5321
  await this.events.append("skill.removed", actor.name, {
5450
5322
  name: args.name,
5451
5323
  reason: args.reason
@@ -5522,7 +5394,7 @@ ${current.body}`);
5522
5394
  type,
5523
5395
  size: bytes.byteLength
5524
5396
  });
5525
- const name = path6.basename(args.path).replaceAll(/[[\]\\\r\n]/g, "");
5397
+ const name = path7.basename(args.path).replaceAll(/[[\]\\\r\n]/g, "");
5526
5398
  return {
5527
5399
  id,
5528
5400
  type,
@@ -5548,7 +5420,7 @@ ${current.body}`);
5548
5420
  async listCrons() {
5549
5421
  const crons = [];
5550
5422
  for (const file of await listFiles(this.paths.crons())) {
5551
- crons.push(await this.readCronFile(path6.join(this.paths.crons(), file)));
5423
+ crons.push(await this.readCronFile(path7.join(this.paths.crons(), file)));
5552
5424
  }
5553
5425
  return crons;
5554
5426
  }
@@ -5670,11 +5542,16 @@ ${current.body}`);
5670
5542
  }
5671
5543
  /**
5672
5544
  * Where a cron fires for its citizen: the thread, the channel, or the scope's home it names, else
5673
- * the conversation of the turn that set it when it wakes its setter, else its home outside projects.
5545
+ * the conversation of the turn that set it when it wakes its setter, a project's channels' in
5546
+ * the society scope included, else its home outside projects.
5674
5547
  */
5675
5548
  async cronConversation(actor, agent, named) {
5676
5549
  const own = actor.name === agent.name;
5677
5550
  if (named.project === void 0 && named.thread_id === void 0 && named.channel === void 0) {
5551
+ if (own && actor.scope === SOCIETY_SCOPE && actor.place !== void 0) {
5552
+ await this.readActiveProject(actor.place);
5553
+ return { scope: SOCIETY_SCOPE, place: actor.place };
5554
+ }
5678
5555
  return own && actor.scope !== void 0 ? this.cronConversation(actor, agent, {
5679
5556
  project: actor.scope,
5680
5557
  thread_id: actor.thread,
@@ -5750,7 +5627,8 @@ ${current.body}`);
5750
5627
  agent: cron.agent,
5751
5628
  project: cron.scope,
5752
5629
  ...cron.thread === void 0 ? {} : { thread: cron.thread },
5753
- ...cron.channel === void 0 ? {} : { channel: cron.channel }
5630
+ ...cron.channel === void 0 ? {} : { channel: cron.channel },
5631
+ ...cron.place === void 0 ? {} : { place: cron.place }
5754
5632
  };
5755
5633
  }
5756
5634
  async readCronFile(file) {
@@ -5783,11 +5661,12 @@ ${current.body}`);
5783
5661
  const args = VerbInputs.open_thread.parse(input);
5784
5662
  await this.authorize(actor, "open_thread");
5785
5663
  return this.mutex.run(async () => {
5786
- const opening = await this.threadToOpen(args);
5787
- if (await this.tryFindThread(opening.id) !== null) {
5788
- throw new BoardError("INVALID_STATE", `${opening.subject?.kind ?? "thread"} ${opening.id} already has a thread`);
5789
- }
5790
- return this.openThreadUnlocked(actor.name, opening);
5664
+ await this.assertChannelOpen(args.channel);
5665
+ return this.openThreadUnlocked(actor.name, {
5666
+ id: this.newId(),
5667
+ channel: args.channel,
5668
+ title: args.title
5669
+ });
5791
5670
  });
5792
5671
  }
5793
5672
  /** Writes a new open thread and its event. Callers hold the mutex and chose where it hangs. */
@@ -5822,28 +5701,13 @@ ${current.body}`);
5822
5701
  subject: { kind: "proposal", id: proposal.id }
5823
5702
  };
5824
5703
  }
5825
- /**
5826
- * The open thread of a proposal being decided. A proposal from before proposals opened their
5827
- * threads gets one now, opened as its proposer, and a thread closed before the decision opens
5828
- * again.
5829
- */
5830
- async proposalThreadUnlocked(by, proposal) {
5704
+ /** The open thread of a proposal being decided, which `propose` opened with it. */
5705
+ async proposalThreadUnlocked(proposal) {
5831
5706
  const found = await this.tryFindThread(proposal.id);
5832
- if (found?.thread.state === "open") {
5833
- return found.thread;
5707
+ if (found?.thread.state !== "open") {
5708
+ throw new BoardError("INVALID_STATE", `proposal ${proposal.id} has no open thread`);
5834
5709
  }
5835
- if (found === null) {
5836
- return this.openThreadUnlocked(proposal.proposedBy, this.proposalThreadOpening(proposal));
5837
- }
5838
- const { closedBy: _by, closedAt: _at, ...kept } = found.thread;
5839
- const reopened = await this.writeThread(found.threads, { ...kept, state: "open" });
5840
- await this.events.append("thread.opened", by, {
5841
- threadId: proposal.id,
5842
- channel: reopened.channel,
5843
- subject: reopened.subject ?? null,
5844
- reopened: true
5845
- });
5846
- return reopened;
5710
+ return found.thread;
5847
5711
  }
5848
5712
  /** Where a task's thread hangs, what it is called, and what it is about. */
5849
5713
  taskThreadOpening(task) {
@@ -5855,79 +5719,21 @@ ${current.body}`);
5855
5719
  };
5856
5720
  }
5857
5721
  /**
5858
- * The open thread of a task in play, for a verb about to post into it. A task from before tasks
5859
- * opened their threads gets one now, and a thread closed early while its task is in play opens
5860
- * again; an ended task's thread stays closed.
5722
+ * The open thread of a task in play, for a verb about to post into it, which `createTask` opened
5723
+ * with the task; an ended task's thread closed with it.
5861
5724
  */
5862
- async taskThreadUnlocked(by, task) {
5725
+ async taskThreadUnlocked(task) {
5863
5726
  const found = await this.tryFindThread(task.id);
5864
5727
  if (found?.thread.state === "open") {
5865
5728
  return found.thread;
5866
5729
  }
5867
- if (task.status === "done" || task.status === "abandoned") {
5868
- throw new BoardError("INVALID_STATE", `task ${task.id} is ${task.status} and its thread closed with it`);
5869
- }
5870
- if (found === null) {
5871
- return this.openThreadUnlocked(by, this.taskThreadOpening(task));
5872
- }
5873
- const { closedBy: _by, closedAt: _at, ...kept } = found.thread;
5874
- const reopened = await this.writeThread(found.threads, { ...kept, state: "open" });
5875
- await this.events.append("thread.opened", by, {
5876
- threadId: task.id,
5877
- channel: reopened.channel,
5878
- subject: reopened.subject ?? null,
5879
- reopened: true
5880
- });
5881
- return reopened;
5730
+ throw new BoardError("INVALID_STATE", task.status === "done" || task.status === "abandoned" ? `task ${task.id} is ${task.status} and its thread closed with it` : `task ${task.id} has no open thread`);
5882
5731
  }
5883
5732
  /** Posts what a task verb was told to say into the task's thread, as its author, with the step it recorded. */
5884
5733
  async postTaskNote(author, task, body, step) {
5885
- const thread = await this.taskThreadUnlocked(author, task);
5734
+ const thread = await this.taskThreadUnlocked(task);
5886
5735
  return this.appendThreadMessage(author, thread.id, body, step === void 0 ? {} : { step });
5887
5736
  }
5888
- /** Where a new thread hangs, what it is called, and what it is about. */
5889
- async threadToOpen(args) {
5890
- if (args.task_id !== void 0 && args.proposal_id !== void 0) {
5891
- throw new BoardError("VALIDATION", "a thread is about one task or one proposal, not both");
5892
- }
5893
- if (args.task_id !== void 0) {
5894
- const { task, project } = await this.findTask(args.task_id);
5895
- if (task.status === "done" || task.status === "abandoned") {
5896
- throw new BoardError("INVALID_STATE", `task ${task.id} is ${task.status}`);
5897
- }
5898
- const channel = args.channel ?? channelRef(project, task.channel);
5899
- if (parseChannelRef(channel).project !== project) {
5900
- throw new BoardError("VALIDATION", `a task's thread hangs off a channel of its project, ${project}`);
5901
- }
5902
- await this.assertChannelOpen(channel);
5903
- return {
5904
- id: task.id,
5905
- channel,
5906
- title: args.title ?? task.title,
5907
- subject: { kind: "task", id: task.id }
5908
- };
5909
- }
5910
- if (args.proposal_id !== void 0) {
5911
- const proposal = await this.readProposal(args.proposal_id);
5912
- if (proposal.status !== "proposed") {
5913
- throw new BoardError("INVALID_STATE", `proposal ${proposal.id} is already ${proposal.status}`);
5914
- }
5915
- const channel = args.channel ?? "governance";
5916
- await this.assertChannelOpen(channel);
5917
- const described = `${proposal.kind} proposal: ${describeCharter(proposal.kind, proposal.charter)}`;
5918
- return {
5919
- id: proposal.id,
5920
- channel,
5921
- title: args.title ?? described.slice(0, 200),
5922
- subject: { kind: "proposal", id: proposal.id }
5923
- };
5924
- }
5925
- if (args.channel === void 0 || args.title === void 0) {
5926
- throw new BoardError("VALIDATION", "a thread needs a task_id, a proposal_id, or a channel and a title");
5927
- }
5928
- await this.assertChannelOpen(args.channel);
5929
- return { id: this.newId(), channel: args.channel, title: args.title };
5930
- }
5931
5737
  /**
5932
5738
  * Where the board learns which turns are in flight, from the server that hosts them, so it
5933
5739
  * refuses to end a conversation under someone else's turn.
@@ -5961,17 +5767,20 @@ ${current.body}`);
5961
5767
  async openThreadIds(dir, keep) {
5962
5768
  const ids = /* @__PURE__ */ new Set();
5963
5769
  for (const file of await listFiles(dir)) {
5964
- const doc = await readMarkdown(path6.join(dir, file), ThreadFrontmatterSchema);
5770
+ const doc = await readMarkdown(path7.join(dir, file), ThreadFrontmatterSchema);
5965
5771
  if (doc.data.state === "open" && keep({ ...doc.data, body: doc.body })) {
5966
5772
  ids.add(doc.data.id);
5967
5773
  }
5968
5774
  }
5969
5775
  return ids;
5970
5776
  }
5971
- /** Refuses to archive a project while a turn runs in it or in a thread its archive closes. */
5777
+ /**
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.
5780
+ */
5972
5781
  async assertProjectQuiet(actor, slug) {
5973
5782
  const threads = await this.openThreadIds(this.paths.threads(slug), () => true);
5974
- this.assertNoTurnCut(actor, `project ${slug} cannot be archived`, (turn) => turn.scope === slug || turn.thread !== void 0 && threads.has(turn.thread));
5783
+ this.assertNoTurnCut(actor, `project ${slug} cannot be archived`, (turn) => turn.scope === slug || turn.place === slug || turn.thread !== void 0 && threads.has(turn.thread));
5975
5784
  }
5976
5785
  async closeThread(actor, input) {
5977
5786
  const args = VerbInputs.close_thread.parse(input);
@@ -6512,7 +6321,8 @@ ${args.summary}`);
6512
6321
  filedFrom: {
6513
6322
  scope: actor.scope,
6514
6323
  ...actor.thread === void 0 ? {} : { thread: actor.thread },
6515
- ...actor.channel === void 0 ? {} : { channel: actor.channel }
6324
+ ...actor.channel === void 0 ? {} : { channel: actor.channel },
6325
+ ...actor.place === void 0 ? {} : { place: actor.place }
6516
6326
  }
6517
6327
  },
6518
6328
  charter: parsed.data
@@ -6924,19 +6734,20 @@ ${rationale}`}`);
6924
6734
  project: input.project,
6925
6735
  ...input.thread === void 0 ? {} : { thread: input.thread },
6926
6736
  ...input.channel === void 0 ? {} : { channel: input.channel },
6737
+ ...input.place === void 0 ? {} : { place: input.place },
6927
6738
  messages: [...input.messages]
6928
6739
  });
6929
6740
  });
6930
6741
  }
6931
6742
  /**
6932
6743
  * Where a conversation's session ids and last turn are kept: the scope's home, or beside it a
6933
- * thread's or a channel's, named by `part` as `conversationPart` gives it.
6744
+ * thread's, a channel's, or a project's, named by `part` as `conversationPart` gives it.
6934
6745
  */
6935
6746
  sessionDir(agent, scope, part) {
6936
6747
  return part === void 0 ? this.paths.agentProject(agent, scope) : this.paths.agentConversation(agent, scope, part);
6937
6748
  }
6938
6749
  async readSessions(agent, project, part) {
6939
- const file = path6.join(this.sessionDir(agent, project, part), "sessions.json");
6750
+ const file = path7.join(this.sessionDir(agent, project, part), "sessions.json");
6940
6751
  return await exists(file) ? readJson(file, SessionsFileSchema) : {};
6941
6752
  }
6942
6753
  /**
@@ -6947,15 +6758,34 @@ ${rationale}`}`);
6947
6758
  await this.mutex.run(async () => {
6948
6759
  const dir = this.sessionDir(agent, project, part);
6949
6760
  await ensureDir(dir);
6950
- const file = path6.join(dir, "sessions.json");
6761
+ const file = path7.join(dir, "sessions.json");
6951
6762
  const current = await exists(file) ? await readJson(file, SessionsFileSchema) : {};
6952
6763
  const kept = current.runner === void 0 || current.runner === runner ? current : {};
6953
6764
  await writeJson(file, { ...kept, [cli]: sessionId, runner });
6954
6765
  });
6955
6766
  }
6956
- /** The last turn of one conversation: the scope's home, a channel's, or a thread's. */
6767
+ /**
6768
+ * Drops a conversation's session for a CLI, so its next turn starts a new one, when the record
6769
+ * still names `sessionId`; returns whether it did.
6770
+ */
6771
+ async dropSession(agent, project, cli, sessionId, part) {
6772
+ return this.mutex.run(async () => {
6773
+ const file = path7.join(this.sessionDir(agent, project, part), "sessions.json");
6774
+ if (!await exists(file)) {
6775
+ return false;
6776
+ }
6777
+ const current = await readJson(file, SessionsFileSchema);
6778
+ if (current[cli] !== sessionId) {
6779
+ return false;
6780
+ }
6781
+ const { [cli]: _dropped, ...kept } = current;
6782
+ await writeJson(file, kept);
6783
+ return true;
6784
+ });
6785
+ }
6786
+ /** The last turn of one conversation: the scope's home, a channel's, a project's, or a thread's. */
6957
6787
  async readLastTurn(agent, project, part) {
6958
- const file = path6.join(this.sessionDir(agent, project, part), "last-turn.json");
6788
+ const file = path7.join(this.sessionDir(agent, project, part), "last-turn.json");
6959
6789
  return await exists(file) ? readJson(file, TurnRecordSchema) : null;
6960
6790
  }
6961
6791
  /** The latest turn a citizen took in a scope, in any of its conversations there. */
@@ -6963,8 +6793,9 @@ ${rationale}`}`);
6963
6793
  let latest = await this.readLastTurn(agent, project);
6964
6794
  const place = this.paths.agentProject(agent, project);
6965
6795
  const parts = [
6966
- ...await listDirs(path6.join(place, "threads")),
6967
- ...(await listDirs(path6.join(place, "channels"))).map((name) => `#${name}`)
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}`)
6968
6799
  ];
6969
6800
  for (const part of parts) {
6970
6801
  const turn = await this.readLastTurn(agent, project, part);
@@ -6985,6 +6816,7 @@ ${rationale}`}`);
6985
6816
  project: parsed.project,
6986
6817
  ...parsed.thread === void 0 ? {} : { thread: parsed.thread },
6987
6818
  ...parsed.channel === void 0 ? {} : { channel: parsed.channel },
6819
+ ...parsed.place === void 0 ? {} : { place: parsed.place },
6988
6820
  trigger: parsed.trigger.kind,
6989
6821
  session: parsed.session,
6990
6822
  runner: parsed.runner
@@ -7014,7 +6846,8 @@ ${rationale}`}`);
7014
6846
  agent: parsed.agent,
7015
6847
  project: parsed.project,
7016
6848
  ...parsed.thread === void 0 ? {} : { thread: parsed.thread },
7017
- ...parsed.channel === void 0 ? {} : { channel: parsed.channel }
6849
+ ...parsed.channel === void 0 ? {} : { channel: parsed.channel },
6850
+ ...parsed.place === void 0 ? {} : { place: parsed.place }
7018
6851
  });
7019
6852
  }
7020
6853
  await this.events.append(failed ? "turn.failed" : "turn.completed", parsed.agent, {
@@ -7022,6 +6855,7 @@ ${rationale}`}`);
7022
6855
  project: parsed.project,
7023
6856
  ...parsed.thread === void 0 ? {} : { thread: parsed.thread },
7024
6857
  ...parsed.channel === void 0 ? {} : { channel: parsed.channel },
6858
+ ...parsed.place === void 0 ? {} : { place: parsed.place },
7025
6859
  trigger: parsed.trigger.kind,
7026
6860
  session: parsed.session,
7027
6861
  exitReason: parsed.exitReason,
@@ -7031,7 +6865,10 @@ ${rationale}`}`);
7031
6865
  model: parsed.model,
7032
6866
  summary: parsed.status?.summary ?? null,
7033
6867
  needsUserDecision: parsed.status?.needsUserDecision ?? false,
7034
- error: parsed.error
6868
+ error: parsed.error,
6869
+ ...parsed.resumeFailed === void 0 ? {} : { resumeFailed: parsed.resumeFailed },
6870
+ ...parsed.transient === true ? { transient: true } : {},
6871
+ ...parsed.sessionDropped === true ? { sessionDropped: true } : {}
7035
6872
  });
7036
6873
  });
7037
6874
  }
@@ -7068,6 +6905,20 @@ ${rationale}`}`);
7068
6905
  return task;
7069
6906
  });
7070
6907
  }
6908
+ /**
6909
+ * Posts the board's word on a task's work into the task's thread, such as commits a runner
6910
+ * could not put on its branch: as the board, with every `@name` written as a plain name, so it
6911
+ * wakes nobody and counts toward no heartbeat. Null, posting nothing, once the thread is closed.
6912
+ */
6913
+ async postTaskNotice(taskId, body) {
6914
+ return this.mutex.run(async () => {
6915
+ const found = await this.tryFindThread(taskId);
6916
+ if (found === null || found.thread.state !== "open") {
6917
+ return null;
6918
+ }
6919
+ return this.appendThreadMessage(SYSTEM_ACTOR.name, taskId, withoutMentions(body));
6920
+ });
6921
+ }
7071
6922
  /**
7072
6923
  * Records that a citizen's workspace in a conversation that has ended holds work on no branch, so
7073
6924
  * the scheduler gives the citizen a closing turn there to decide about it. False, recording
@@ -7078,7 +6929,7 @@ ${rationale}`}`);
7078
6929
  const payload = {
7079
6930
  agent: input.agent,
7080
6931
  project: input.scope,
7081
- ...conversation.kind === "thread" ? { thread: conversation.id } : { channel: conversation.name }
6932
+ ...conversation.kind === "thread" ? { thread: conversation.id } : conversation.kind === "channel" ? { channel: conversation.name } : { place: conversation.project }
7082
6933
  };
7083
6934
  return this.mutex.run(async () => {
7084
6935
  const earlier = (await this.events.readSince(null, Number.MAX_SAFE_INTEGER)).filter((event) => event.type === "workspace.leftovers" && Object.entries(payload).every(([key, value]) => event.payload[key] === value)).length;
@@ -7130,9 +6981,13 @@ ${rationale}`}`);
7130
6981
  }
7131
6982
  /**
7132
6983
  * Whether a conversation with a workspace of its own has ended: a thread closed, a task ended
7133
- * (for one from before tasks had threads), or a channel archived.
6984
+ * (for one from before tasks had threads), a channel archived, or a project archived, which ends
6985
+ * every citizen's conversation of its channels in the society scope.
7134
6986
  */
7135
6987
  async conversationEnded(conversation) {
6988
+ if (conversation.kind === "place") {
6989
+ return !await this.projectLive(conversation.project);
6990
+ }
7136
6991
  if (conversation.kind === "channel") {
7137
6992
  const place = conversation.scope === SOCIETY_SCOPE ? null : conversation.scope;
7138
6993
  return !await this.channelOpen(channelRef(place, conversation.name));
@@ -7206,11 +7061,11 @@ ${rationale}`}`);
7206
7061
  }
7207
7062
  /** Small named state files under data/state, for cursors the scheduler must keep across restarts. */
7208
7063
  async readState(name, schema, fallback) {
7209
- const file = path6.join(this.paths.state(), `${name}.json`);
7064
+ const file = path7.join(this.paths.state(), `${name}.json`);
7210
7065
  return await exists(file) ? readJson(file, schema) : fallback;
7211
7066
  }
7212
7067
  async writeState(name, value) {
7213
- await this.mutex.run(() => writeJson(path6.join(this.paths.state(), `${name}.json`), value));
7068
+ await this.mutex.run(() => writeJson(path7.join(this.paths.state(), `${name}.json`), value));
7214
7069
  }
7215
7070
  /** The project's dashboard, empty at revision 0 when it has none. */
7216
7071
  async readDashboard(slug) {
@@ -7241,7 +7096,7 @@ ${rationale}`}`);
7241
7096
  async listRunners() {
7242
7097
  const runners2 = [];
7243
7098
  for (const file of await listFiles(this.paths.runners())) {
7244
- runners2.push((await readMarkdown(path6.join(this.paths.runners(), file), RunnerSchema)).data);
7099
+ runners2.push((await readMarkdown(path7.join(this.paths.runners(), file), RunnerSchema)).data);
7245
7100
  }
7246
7101
  return runners2;
7247
7102
  }
@@ -7293,7 +7148,7 @@ ${rationale}`}`);
7293
7148
  return this.runnerTokens.get(hashToken(token)) ?? null;
7294
7149
  }
7295
7150
  runnerTokensFile() {
7296
- return path6.join(this.paths.state(), "runners.json");
7151
+ return path7.join(this.paths.state(), "runners.json");
7297
7152
  }
7298
7153
  async readRunnerTokens() {
7299
7154
  const file = this.runnerTokensFile();
@@ -7342,26 +7197,26 @@ ${rationale}`}`);
7342
7197
  async listHomeConflicts(agent) {
7343
7198
  const home = this.paths.agent(agent);
7344
7199
  const found = [];
7345
- const walk = async (dir, prefix) => {
7346
- for (const entry of await readdir3(dir, { withFileTypes: true }).catch(() => [])) {
7200
+ const walk2 = async (dir, prefix) => {
7201
+ for (const entry of await readdir4(dir, { withFileTypes: true }).catch(() => [])) {
7347
7202
  if (prefix === "" && SKIPPED_IN_HOME.has(entry.name)) {
7348
7203
  continue;
7349
7204
  }
7350
7205
  const relative = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
7351
7206
  if (entry.isDirectory()) {
7352
- await walk(path6.join(dir, entry.name), relative);
7207
+ await walk2(path7.join(dir, entry.name), relative);
7353
7208
  continue;
7354
7209
  }
7355
7210
  const file = entry.isFile() ? conflictCopyOf(relative) : null;
7356
7211
  if (file !== null) {
7357
- const since = await this.homes.changedAt(home, relative) ?? (await stat3(path6.join(home, relative)).catch(() => null))?.mtime.toISOString();
7212
+ const since = await this.homes.changedAt(home, relative) ?? (await stat3(path7.join(home, relative)).catch(() => null))?.mtime.toISOString();
7358
7213
  if (since !== void 0) {
7359
7214
  found.push({ path: relative, file, since });
7360
7215
  }
7361
7216
  }
7362
7217
  }
7363
7218
  };
7364
- await walk(home, "");
7219
+ await walk2(home, "");
7365
7220
  return found.toSorted((a, b) => a.path.localeCompare(b.path));
7366
7221
  }
7367
7222
  /** The newest `limit` changes to an agent's home, newest first; empty while it is no repository. */
@@ -7438,7 +7293,7 @@ ${rationale}`}`);
7438
7293
  async writeTurnRecord(record) {
7439
7294
  const dir = this.sessionDir(record.agent, record.project, conversationPart(record));
7440
7295
  await ensureDir(dir);
7441
- await writeJson(path6.join(dir, "last-turn.json"), record);
7296
+ await writeJson(path7.join(dir, "last-turn.json"), record);
7442
7297
  }
7443
7298
  // ---------------------------------------------------------------------------------------------
7444
7299
  // Internals
@@ -7557,8 +7412,8 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
7557
7412
  async ensureAgentProject(name, slug) {
7558
7413
  const dir = this.paths.agentProject(name, slug);
7559
7414
  await ensureDir(dir);
7560
- if (!await exists(path6.join(dir, "sessions.json"))) {
7561
- await writeJson(path6.join(dir, "sessions.json"), {});
7415
+ if (!await exists(path7.join(dir, "sessions.json"))) {
7416
+ await writeJson(path7.join(dir, "sessions.json"), {});
7562
7417
  }
7563
7418
  }
7564
7419
  /**
@@ -7590,7 +7445,7 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
7590
7445
  const at = turn?.endedAt ?? turn?.startedAt;
7591
7446
  if (turn !== null && at !== void 0 && (lastTurnAt === void 0 || at > lastTurnAt)) {
7592
7447
  lastTurnAt = at;
7593
- lastTurnOutcome = `${turn.trigger.kind} on ${scope}: ${turn.exitReason ?? "running"}${turn.status === null ? "" : `, ${turn.status.summary.slice(0, 160)}`}`;
7448
+ lastTurnOutcome = `${turn.trigger.kind} on ${scope}${turnConversation(turn)}: ${turn.exitReason ?? "running"}${turn.status === null ? "" : `, ${turn.status.summary.slice(0, 160)}`}`;
7594
7449
  lastModel = turn.model ?? void 0;
7595
7450
  }
7596
7451
  }
@@ -7720,15 +7575,9 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
7720
7575
  case "archive":
7721
7576
  await this.validateArchive(ArchiveProposalSchema.parse(charter).project);
7722
7577
  return;
7723
- case "reallocation": {
7724
- const parsed = ReallocationProposalSchema.safeParse(charter);
7725
- if (parsed.success) {
7726
- await this.reallocatedRole(parsed.data);
7727
- } else {
7728
- LegacyReallocationSchema.parse(charter);
7729
- }
7578
+ case "reallocation":
7579
+ await this.reallocatedRole(ReallocationProposalSchema.parse(charter));
7730
7580
  return;
7731
- }
7732
7581
  default:
7733
7582
  return;
7734
7583
  }
@@ -7788,11 +7637,7 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
7788
7637
  return { project: input.project, members };
7789
7638
  }
7790
7639
  case "reallocation": {
7791
- const parsed = ReallocationProposalSchema.safeParse(proposal.charter);
7792
- if (!parsed.success) {
7793
- return void 0;
7794
- }
7795
- const { next, changed } = await this.reallocatedRole(parsed.data);
7640
+ const { next, changed } = await this.reallocatedRole(ReallocationProposalSchema.parse(proposal.charter));
7796
7641
  await this.writeRoleUnlocked(by, next, meta);
7797
7642
  return { role: next.name, changed };
7798
7643
  }
@@ -7802,25 +7647,21 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
7802
7647
  }
7803
7648
  /**
7804
7649
  * Publishes a promoted skill under the society's skills, where every citizen's skills index lists
7805
- * it: the folder the board took from its proposer's home, or the text proposed alone. A
7806
- * replacement is the whole skill, so files the new version dropped go with the old one.
7650
+ * it: the folder the board took from its proposer's home. A replacement is the whole skill, so
7651
+ * files the new version dropped go with the old one.
7807
7652
  */
7808
7653
  async promoteSkillUnlocked(by, proposal, skill, meta) {
7809
7654
  const dir = this.paths.societySkill(skill.name);
7810
7655
  const file = this.paths.societySkillFile(skill.name);
7811
7656
  const replaced = await exists(file);
7812
7657
  const taken = proposal.skillFiles;
7813
- const staged = path6.join(this.paths.proposalFiles(proposal.id), "SKILL.md");
7814
- if (skill.body === void 0 && (taken === void 0 || !await exists(staged))) {
7658
+ const staged = path7.join(this.paths.proposalFiles(proposal.id), "SKILL.md");
7659
+ if (taken === void 0 || !await exists(staged)) {
7815
7660
  throw new BoardError("INVALID_STATE", `proposal ${proposal.id} holds no skill files`);
7816
7661
  }
7817
7662
  await rm2(dir, { recursive: true, force: true });
7818
- if (skill.body === void 0) {
7819
- await cp(this.paths.proposalFiles(proposal.id), dir, { recursive: true });
7820
- } else {
7821
- await ensureDir(dir);
7822
- }
7823
- const body = skill.body ?? (await readLooseMarkdown(file)).body;
7663
+ await cp(this.paths.proposalFiles(proposal.id), dir, { recursive: true });
7664
+ const { body } = await readLooseMarkdown(file);
7824
7665
  await writeMarkdown(file, {
7825
7666
  name: skill.name,
7826
7667
  description: skill.summary,
@@ -7828,7 +7669,7 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
7828
7669
  proposedBy: proposal.proposedBy,
7829
7670
  promotedBy: by,
7830
7671
  promotedAt: this.now().toISOString(),
7831
- ...taken === void 0 ? {} : { commit: taken.commit }
7672
+ commit: taken.commit
7832
7673
  }
7833
7674
  }, body);
7834
7675
  await this.events.append("skill.promoted", by, {
@@ -7836,7 +7677,7 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
7836
7677
  summary: skill.summary,
7837
7678
  proposedBy: proposal.proposedBy,
7838
7679
  replaced,
7839
- ...taken === void 0 ? {} : { files: taken.files.length },
7680
+ files: taken.files.length,
7840
7681
  ...meta
7841
7682
  });
7842
7683
  await this.appendMessage(SYSTEM_ACTOR.name, "general", `Skill ${skill.name} ${replaced ? "updated in" : "promoted to"} the society: ${sentence(skill.summary)}. Every citizen's skills index now lists it.`);
@@ -7873,8 +7714,8 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
7873
7714
  const dir = this.paths.proposalFiles(proposal.id);
7874
7715
  await rm2(dir, { recursive: true, force: true });
7875
7716
  for (const entry of taken.files) {
7876
- const target = path6.join(dir, ...entry.path.split("/"));
7877
- await ensureDir(path6.dirname(target));
7717
+ const target = path7.join(dir, ...entry.path.split("/"));
7718
+ await ensureDir(path7.dirname(target));
7878
7719
  await writeFile4(target, contents.get(entry.path) ?? Buffer.alloc(0));
7879
7720
  await chmod2(target, entry.executable ? 493 : 420);
7880
7721
  }
@@ -7932,7 +7773,8 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
7932
7773
  const where = {
7933
7774
  scope: turn.project,
7934
7775
  ...turn.thread === void 0 ? {} : { thread: turn.thread },
7935
- ...turn.channel === void 0 ? {} : { channel: turn.channel }
7776
+ ...turn.channel === void 0 ? {} : { channel: turn.channel },
7777
+ ...turn.place === void 0 ? {} : { place: turn.place }
7936
7778
  };
7937
7779
  await this.mutex.run(async () => {
7938
7780
  for (const proposal of await this.listProposals()) {
@@ -7964,16 +7806,25 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
7964
7806
  if (!isSafeRelativePath(entry.path)) {
7965
7807
  continue;
7966
7808
  }
7967
- const bytes = await readFile4(path6.join(dir, ...entry.path.split("/"))).catch(() => null);
7968
- const text = bytes === null || bytes.includes(0) ? null : bytes.toString("utf8");
7809
+ const bytes = await readFile4(path7.join(dir, ...entry.path.split("/"))).catch(() => null);
7969
7810
  files.push({
7970
7811
  ...entry,
7971
- text: text === null ? null : text.slice(0, SKILL_TEXT_LIMIT_CHARS),
7972
- truncated: text !== null && text.length > SKILL_TEXT_LIMIT_CHARS
7812
+ ...bytes === null ? { text: null, truncated: false } : skillFileText(bytes)
7973
7813
  });
7974
7814
  }
7975
7815
  return files;
7976
7816
  }
7817
+ /**
7818
+ * A society skill's folder as it is published, with each file's text, for the skill's view; an
7819
+ * unknown skill, or a folder without a SKILL.md, is not found.
7820
+ */
7821
+ async readSocietySkillFiles(name) {
7822
+ const files = await readSkillFolder(this.paths.societySkill(NameSchema.parse(name)));
7823
+ if (files === null) {
7824
+ throw new BoardError("NOT_FOUND", `the society has no skill ${name}`);
7825
+ }
7826
+ return files;
7827
+ }
7977
7828
  async validateAddAgent(input) {
7978
7829
  if (await exists(this.paths.agentFile(input.name))) {
7979
7830
  throw new BoardError("ALREADY_EXISTS", `agent ${input.name} already exists`);
@@ -8104,7 +7955,7 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
8104
7955
  this.assertNoTurnCut(actor, `${ref} cannot be archived`, (turn) => turn.channel === channel && turn.scope === scope || turn.thread !== void 0 && closing.has(turn.thread));
8105
7956
  const threadsClosed = [];
8106
7957
  for (const file of await listFiles(threads)) {
8107
- const doc = await readMarkdown(path6.join(threads, file), ThreadFrontmatterSchema);
7958
+ const doc = await readMarkdown(path7.join(threads, file), ThreadFrontmatterSchema);
8108
7959
  if (doc.data.channel !== ref || doc.data.state !== "open") {
8109
7960
  continue;
8110
7961
  }
@@ -8210,7 +8061,8 @@ ${charter.purpose}
8210
8061
  }
8211
8062
  }
8212
8063
  /**
8213
- * Every member leaves, every open thread on its channels closes, and the project is marked; its
8064
+ * Every member leaves, every open thread on its channels closes, every citizen's conversation of
8065
+ * its channels in the society scope ends with the crons there, and the project is marked; its
8214
8066
  * files, repository, and history stay. The notice is the board's, since the reason is free text
8215
8067
  * a proposer wrote and a board-authored mention would wake whoever it names.
8216
8068
  */
@@ -8235,7 +8087,7 @@ ${charter.purpose}
8235
8087
  const threads = this.paths.threads(slug);
8236
8088
  const threadsClosed = [];
8237
8089
  for (const file of await listFiles(threads)) {
8238
- const doc = await readMarkdown(path6.join(threads, file), ThreadFrontmatterSchema);
8090
+ const doc = await readMarkdown(path7.join(threads, file), ThreadFrontmatterSchema);
8239
8091
  if (doc.data.state !== "open") {
8240
8092
  continue;
8241
8093
  }
@@ -8253,7 +8105,7 @@ ${charter.purpose}
8253
8105
  });
8254
8106
  threadsClosed.push(doc.data.id);
8255
8107
  }
8256
- await this.endCronsUnlocked(by, `project ${slug} was archived`, (cron) => cron.scope === slug || cron.thread !== void 0 && threadsClosed.includes(cron.thread));
8108
+ await this.endCronsUnlocked(by, `project ${slug} was archived`, (cron) => cron.scope === slug || cron.place === slug || cron.thread !== void 0 && threadsClosed.includes(cron.thread));
8257
8109
  const project = await this.updateProject(slug, (p) => ({
8258
8110
  ...p,
8259
8111
  members: [],
@@ -8310,10 +8162,10 @@ ${charter.purpose}
8310
8162
  ...project,
8311
8163
  members: project.members.filter((member) => member !== name)
8312
8164
  }));
8313
- const sessions = path6.join(this.paths.agentProject(name, slug), "sessions.json");
8165
+ const sessions = path7.join(this.paths.agentProject(name, slug), "sessions.json");
8314
8166
  if (await exists(sessions)) {
8315
8167
  const archived = `sessions.archived.${ts.replace(/[:.]/g, "-")}.json`;
8316
- await rename3(sessions, path6.join(this.paths.agentProject(name, slug), archived));
8168
+ await rename3(sessions, path7.join(this.paths.agentProject(name, slug), archived));
8317
8169
  }
8318
8170
  }
8319
8171
  this.tokenIndex.delete(current.tokenHash);
@@ -8393,6 +8245,17 @@ ${charter.purpose}
8393
8245
  return false;
8394
8246
  }
8395
8247
  }
8248
+ /**
8249
+ * Whether a project exists and is not archived, so citizens' conversations of its channels in
8250
+ * the society scope are still going.
8251
+ */
8252
+ async projectLive(slug) {
8253
+ try {
8254
+ return (await this.readProject(slug)).archived === void 0;
8255
+ } catch {
8256
+ return false;
8257
+ }
8258
+ }
8396
8259
  /**
8397
8260
  * A channel that takes new posts, threads, tasks, and followers: it exists, it is not archived,
8398
8261
  * and its project is not archived.
@@ -8503,6 +8366,7 @@ ${charter.purpose}
8503
8366
  await this.validateProvision(filed.kind, filed.charter);
8504
8367
  }
8505
8368
  this.assertNoTurnCut(actor, `proposal ${proposalId} cannot be decided`, (turn) => turn.thread === proposalId);
8369
+ const thread = await this.proposalThreadUnlocked(filed);
8506
8370
  if (outcome === "approved" && filed.kind === "archive") {
8507
8371
  await this.assertProjectQuiet(actor, ArchiveProposalSchema.parse(filed.charter).project);
8508
8372
  }
@@ -8544,7 +8408,6 @@ ${charter.purpose}
8544
8408
  const verdict = outcome === "approved" ? "Approved" : "Rejected";
8545
8409
  const why = reason === void 0 ? "" : ` Reason: ${reason}.`;
8546
8410
  const result = provision === void 0 ? "" : ` Provisioned: ${Object.entries(provision).map(([key, value]) => `${key} ${plain(value)}`).join(", ")}.`;
8547
- const thread = await this.proposalThreadUnlocked(actor.name, proposal);
8548
8411
  const post = `${verdict}: ${describeCharter(proposal.kind, proposal.charter)}.${why}${result}`;
8549
8412
  await this.appendThreadMessage(actor.name, thread.id, actor.name === SYSTEM_ACTOR.name ? withoutMentions(post) : post, { step: { action: outcome } });
8550
8413
  await this.endThreadOf(proposalId, actor.name, outcome);
@@ -8643,7 +8506,7 @@ ${charter.purpose}
8643
8506
  if (since !== null && id <= since || upto !== void 0 && id > upto) {
8644
8507
  continue;
8645
8508
  }
8646
- const doc = await readMarkdown(path6.join(dir, file), MessageFrontmatterSchema);
8509
+ const doc = await readMarkdown(path7.join(dir, file), MessageFrontmatterSchema);
8647
8510
  messages.push({ ...doc.data, body: doc.body });
8648
8511
  }
8649
8512
  return messages;
@@ -8739,11 +8602,21 @@ function besideFields(beside) {
8739
8602
  if (beside === void 0) {
8740
8603
  return {};
8741
8604
  }
8742
- return "thread" in beside ? { thread: beside.thread } : { channel: beside.channel };
8605
+ if ("thread" in beside) {
8606
+ return { thread: beside.thread };
8607
+ }
8608
+ return "channel" in beside ? { channel: beside.channel } : { place: beside.place };
8609
+ }
8610
+ function namedIn(dispatch) {
8611
+ return {
8612
+ ...dispatch.thread === void 0 ? {} : { thread: dispatch.thread.id },
8613
+ ...dispatch.channel === void 0 ? {} : { channel: dispatch.channel },
8614
+ ...dispatch.place === void 0 ? {} : { place: dispatch.place }
8615
+ };
8743
8616
  }
8744
8617
  function conversationKey2(agent, scope, beside) {
8745
8618
  const fields = besideFields(beside);
8746
- return sessionKey(agent, scope, conversationPart({ thread: fields.thread?.id, channel: fields.channel }));
8619
+ return sessionKey(agent, scope, conversationPart({ thread: fields.thread?.id, channel: fields.channel, place: fields.place }));
8747
8620
  }
8748
8621
  var DEFAULT_TIMINGS = Object.freeze({
8749
8622
  pollMs: 1e3,
@@ -8754,12 +8627,14 @@ var DEFAULT_TIMINGS = Object.freeze({
8754
8627
  leaseSweepMs: 6e4,
8755
8628
  opsIntervalMs: 5 * 6e4,
8756
8629
  signalRepeatMs: 6 * 36e5,
8630
+ signalWakeMs: 24 * 36e5,
8757
8631
  costReportMs: 36e5,
8758
8632
  staleThreadMs: 24 * 36e5,
8759
8633
  idleMemberMs: 3 * 24 * 36e5,
8760
8634
  homeConflictMs: 24 * 36e5,
8761
8635
  scaleCooldownMs: 36e5,
8762
- reflectionMs: 24 * 36e5
8636
+ reflectionMs: 24 * 36e5,
8637
+ transientRetryMs: [6e4, 3 * 6e4, 10 * 6e4]
8763
8638
  });
8764
8639
  var SchedulerTimingsSchema = z17.object({
8765
8640
  pollMs: z17.number().positive(),
@@ -8770,18 +8645,20 @@ var SchedulerTimingsSchema = z17.object({
8770
8645
  leaseSweepMs: z17.number().positive(),
8771
8646
  opsIntervalMs: z17.number().positive(),
8772
8647
  signalRepeatMs: z17.number().positive(),
8648
+ signalWakeMs: z17.number().positive(),
8773
8649
  costReportMs: z17.number().positive(),
8774
8650
  staleThreadMs: z17.number().positive(),
8775
8651
  idleMemberMs: z17.number().positive(),
8776
8652
  homeConflictMs: z17.number().positive(),
8777
8653
  scaleCooldownMs: z17.number().positive(),
8778
- reflectionMs: z17.number().positive()
8654
+ reflectionMs: z17.number().positive(),
8655
+ transientRetryMs: z17.array(z17.number().positive())
8779
8656
  }).partial();
8780
8657
  function parseTimings(input) {
8781
8658
  const parsed = SchedulerTimingsSchema.parse(input);
8782
8659
  const overrides = {};
8783
8660
  for (const [key, value] of Object.entries(parsed)) {
8784
- if (typeof value === "number") {
8661
+ if (value !== void 0) {
8785
8662
  Object.assign(overrides, { [key]: value });
8786
8663
  }
8787
8664
  }
@@ -8805,6 +8682,11 @@ var StateSchema = z17.object({
8805
8682
  /** Queued dispatches survive a restart; the cursor has already moved past the events that made them. */
8806
8683
  pending: z17.record(z17.string(), PendingSchema).default({}),
8807
8684
  opsReported: z17.record(z17.string(), z17.string()).default({}),
8685
+ /**
8686
+ * When each condition of a waking kind that still holds last woke the readers of signals, and
8687
+ * the level it woke at for the kinds that have one (`wakeLevel`); forgotten with the condition.
8688
+ */
8689
+ opsWoken: z17.record(z17.string(), z17.object({ at: z17.string(), level: z17.number().optional() })).default({}),
8808
8690
  releaseCounts: z17.record(z17.string(), z17.number().int().nonnegative()).default({}),
8809
8691
  lastOps: z17.string().nullable().default(null),
8810
8692
  lastScaled: z17.record(z17.string(), z17.string()).default({}),
@@ -8829,6 +8711,22 @@ var SILENT_LOG = { info() {
8829
8711
  }, error() {
8830
8712
  } };
8831
8713
  var WAKING_SIGNALS = new Set(WAKING_SIGNAL_KINDS);
8714
+ var DAY_MS = 24 * 36e5;
8715
+ var IDLE_WAKE_STEPS_MS = [7 * DAY_MS, 14 * DAY_MS, 30 * DAY_MS];
8716
+ function wakeLevel(signal) {
8717
+ switch (signal.kind) {
8718
+ case "idle_member":
8719
+ return IDLE_WAKE_STEPS_MS.filter((step) => signal.value >= step).length;
8720
+ case "churn":
8721
+ return signal.value;
8722
+ default:
8723
+ return void 0;
8724
+ }
8725
+ }
8726
+ function wokenAt(signal, now) {
8727
+ const level = wakeLevel(signal);
8728
+ return { at: iso(now), ...level === void 0 ? {} : { level } };
8729
+ }
8832
8730
  var HEARTBEAT_TRIGGER = "heartbeat";
8833
8731
  var OPS_TRIGGER = "ops_event";
8834
8732
  var USER_POST_TRIGGER = "user_post";
@@ -8885,6 +8783,8 @@ var Scheduler = class {
8885
8783
  sweeping = /* @__PURE__ */ new Set();
8886
8784
  /** Crons whose fire's turn is running; a cron's next time passes while one is. */
8887
8785
  cronsInFlight = /* @__PURE__ */ new Set();
8786
+ /** Turns in a row the model service turned away, by session key, which `retryRefused` counts. */
8787
+ refused = /* @__PURE__ */ new Map();
8888
8788
  state = StateSchema.parse({
8889
8789
  cursor: null,
8890
8790
  lastHeartbeat: {}
@@ -9097,10 +8997,6 @@ var Scheduler = class {
9097
8997
  await this.wakeCreator(taskId, project, creator, event.actor, now);
9098
8998
  }
9099
8999
  await this.wakeDependents(taskId, event.actor, now);
9100
- const threaded = await this.board.readThread(taskId).then(() => true, () => false);
9101
- if (!threaded) {
9102
- this.sweep({ kind: "thread", id: taskId });
9103
- }
9104
9000
  return;
9105
9001
  }
9106
9002
  case "thread.closed": {
@@ -9215,6 +9111,21 @@ var Scheduler = class {
9215
9111
  }
9216
9112
  return;
9217
9113
  }
9114
+ case "project.archived": {
9115
+ const slug = stringOf(payload["slug"]);
9116
+ if (slug === null) {
9117
+ return;
9118
+ }
9119
+ this.sweep({ kind: "place", project: slug });
9120
+ for (const [key, item] of this.pending) {
9121
+ const { dispatch } = item;
9122
+ if (dispatch.place === slug) {
9123
+ this.pending.delete(key);
9124
+ this.log.info({ agent: dispatch.agent, project: dispatch.project, place: slug }, "dropping project wake, the project is archived");
9125
+ }
9126
+ }
9127
+ return;
9128
+ }
9218
9129
  case "agent.left": {
9219
9130
  const name = stringOf(payload["name"]);
9220
9131
  const project = stringOf(payload["project"]);
@@ -9245,7 +9156,7 @@ var Scheduler = class {
9245
9156
  }
9246
9157
  case "ops.signal": {
9247
9158
  const parsed = OpsSignalSchema.safeParse(payload);
9248
- if (!parsed.success || !WAKING_SIGNALS.has(parsed.data.kind)) {
9159
+ if (!parsed.success || !signalWakes(parsed.data)) {
9249
9160
  return;
9250
9161
  }
9251
9162
  await this.wakeSignalReaders(parsed.data, event.actor, now);
@@ -9516,11 +9427,12 @@ var Scheduler = class {
9516
9427
  const scope = stringOf(payload["project"]);
9517
9428
  const thread = stringOf(payload["thread"]);
9518
9429
  const channel = stringOf(payload["channel"]);
9430
+ const place = stringOf(payload["place"]);
9519
9431
  const agent = name === null ? null : await this.tryReadAgent(name);
9520
9432
  if (agent === null || scope === null) {
9521
9433
  return;
9522
9434
  }
9523
- const beside = thread !== null ? { thread: await this.conversationOf(thread, scope) } : channel !== null ? { channel } : null;
9435
+ const beside = thread !== null ? { thread: await this.conversationOf(thread, scope) } : channel !== null ? { channel } : place !== null ? { place } : null;
9524
9436
  if (beside === null) {
9525
9437
  return;
9526
9438
  }
@@ -9565,7 +9477,7 @@ var Scheduler = class {
9565
9477
  }
9566
9478
  return {
9567
9479
  scope,
9568
- beside: conversation.kind === "thread" ? { thread: await this.conversationOf(conversation.id, scope) } : { channel: conversation.name }
9480
+ beside: conversation.kind === "thread" ? { thread: await this.conversationOf(conversation.id, scope) } : conversation.kind === "channel" ? { channel: conversation.name } : { place: conversation.project }
9569
9481
  };
9570
9482
  }
9571
9483
  /**
@@ -9583,14 +9495,23 @@ var Scheduler = class {
9583
9495
  }
9584
9496
  /**
9585
9497
  * The conversation a post wakes a citizen in, for a turn in `scope`: its thread's, else its
9586
- * channel's when that is a channel of the scope other than general, else the scope's home.
9498
+ * channel's when that is a channel of the scope other than general, else, for a turn in the
9499
+ * society scope, its project's when it is a project's channel, general included, else the
9500
+ * scope's home.
9587
9501
  */
9588
9502
  async postedIn(scope, channel, threadId) {
9589
9503
  if (threadId !== void 0) {
9590
9504
  return { thread: await this.conversationOf(threadId, scope) };
9591
9505
  }
9592
- const name = channel === null ? void 0 : channelConversation(scope, channel);
9593
- return name === void 0 ? void 0 : { channel: name };
9506
+ if (channel === null) {
9507
+ return void 0;
9508
+ }
9509
+ const name = channelConversation(scope, channel);
9510
+ if (name !== void 0) {
9511
+ return { channel: name };
9512
+ }
9513
+ const place = placeConversation(scope, channel);
9514
+ return place === void 0 ? void 0 : { place };
9594
9515
  }
9595
9516
  async tryReadAgent(name) {
9596
9517
  try {
@@ -9732,7 +9653,7 @@ var Scheduler = class {
9732
9653
  };
9733
9654
  for (const entry of unread) {
9734
9655
  if (entry.scope === project) {
9735
- const beside = entry.thread !== null ? { thread: await this.conversationOf(entry.thread, project) } : entry.channel !== null ? { channel: entry.channel } : void 0;
9656
+ const beside = entry.thread !== null ? { thread: await this.conversationOf(entry.thread, project) } : entry.channel !== null ? { channel: entry.channel } : entry.place !== null ? { place: entry.place } : void 0;
9736
9657
  reasonsIn(beside).digestSize += entry.count;
9737
9658
  }
9738
9659
  }
@@ -9819,7 +9740,10 @@ var Scheduler = class {
9819
9740
  if (cron.thread !== void 0) {
9820
9741
  return { thread: await this.conversationOf(cron.thread, cron.scope) };
9821
9742
  }
9822
- return cron.channel === void 0 ? void 0 : { channel: cron.channel };
9743
+ if (cron.channel !== void 0) {
9744
+ return { channel: cron.channel };
9745
+ }
9746
+ return cron.place === void 0 ? void 0 : { place: cron.place };
9823
9747
  }
9824
9748
  /** Whether a cron's wake waits in the queue or its turn is running. */
9825
9749
  cronQueuedOrRunning(id) {
@@ -9943,25 +9867,56 @@ var Scheduler = class {
9943
9867
  // ---------------------------------------------------------------------------------------------
9944
9868
  // Operations: signals from state, the scaling rule, and the spend report
9945
9869
  // ---------------------------------------------------------------------------------------------
9870
+ /**
9871
+ * Logs each condition when it is new and again every `signalRepeatMs` while it holds, and marks
9872
+ * each log with whether it wakes the readers of signals (`wakesNow`), so a repeat only logs.
9873
+ */
9946
9874
  async runOperations(now) {
9947
9875
  const signals = await this.collectSignals(now);
9948
9876
  const current = new Set(signals.map((signal) => signal.key));
9949
- for (const key of Object.keys(this.state.opsReported)) {
9950
- if (!current.has(key)) {
9951
- delete this.state.opsReported[key];
9877
+ for (const record of [this.state.opsReported, this.state.opsWoken]) {
9878
+ for (const key of Object.keys(record)) {
9879
+ if (!current.has(key)) {
9880
+ delete record[key];
9881
+ }
9952
9882
  }
9953
9883
  }
9954
9884
  for (const signal of signals) {
9955
9885
  const reported = this.state.opsReported[signal.key];
9956
- if (reported !== void 0 && now - Date.parse(reported) < this.timings.signalRepeatMs) {
9886
+ const wakes = this.wakesNow(signal, reported !== void 0, now);
9887
+ if (!wakes && reported !== void 0 && now - Date.parse(reported) < this.timings.signalRepeatMs) {
9957
9888
  continue;
9958
9889
  }
9959
- await this.board.publishSignal(signal);
9890
+ await this.board.publishSignal({ ...signal, wakes });
9960
9891
  this.state.opsReported[signal.key] = iso(now);
9892
+ if (wakes) {
9893
+ this.state.opsWoken[signal.key] = wokenAt(signal, now);
9894
+ }
9961
9895
  }
9962
9896
  await this.scaleRoles(now);
9963
9897
  await this.reportCost(now);
9964
9898
  }
9899
+ /**
9900
+ * Whether a condition wakes the readers of signals at this pass, which only a waking kind does:
9901
+ * when it is new; for an idle member or churn, when it has risen past the level it last woke at
9902
+ * (`wakeLevel`); for the other kinds, once its last wake is `signalWakeMs` old. Checked on every
9903
+ * pass, so a step wakes when it is crossed, between repeats.
9904
+ */
9905
+ wakesNow(signal, held, now) {
9906
+ if (!WAKING_SIGNALS.has(signal.kind)) {
9907
+ return false;
9908
+ }
9909
+ const last = this.state.opsWoken[signal.key];
9910
+ if (last === void 0) {
9911
+ if (held) {
9912
+ this.state.opsWoken[signal.key] = wokenAt(signal, now);
9913
+ return false;
9914
+ }
9915
+ return true;
9916
+ }
9917
+ const level = wakeLevel(signal);
9918
+ return level === void 0 ? now - Date.parse(last.at) >= this.timings.signalWakeMs : level > (last.level ?? 0);
9919
+ }
9965
9920
  /** Every operations condition that holds right now, from counters and timers only. */
9966
9921
  async collectSignals(now) {
9967
9922
  const signals = [];
@@ -10204,8 +10159,7 @@ var Scheduler = class {
10204
10159
  const attempt = steer({
10205
10160
  agent: dispatch.agent,
10206
10161
  project: dispatch.project,
10207
- ...dispatch.thread === void 0 ? {} : { thread: dispatch.thread.id },
10208
- ...dispatch.channel === void 0 ? {} : { channel: dispatch.channel }
10162
+ ...namedIn(dispatch)
10209
10163
  }).then((outcome) => {
10210
10164
  const current = this.pending.get(key);
10211
10165
  if (outcome !== "busy" && current !== void 0) {
@@ -10226,7 +10180,7 @@ var Scheduler = class {
10226
10180
  return false;
10227
10181
  }
10228
10182
  const { dispatch } = item;
10229
- const cursor = await this.board.digestCursor(dispatch.agent, dispatch.project, conversationPart({ thread: dispatch.thread?.id, channel: dispatch.channel }));
10183
+ const cursor = await this.board.digestCursor(dispatch.agent, dispatch.project, conversationPart(namedIn(dispatch)));
10230
10184
  return cursor !== null && cursor >= item.newestMessage;
10231
10185
  }
10232
10186
  async dispatchReady(now) {
@@ -10263,6 +10217,7 @@ var Scheduler = class {
10263
10217
  project: dispatch.project,
10264
10218
  thread: dispatch.thread?.id,
10265
10219
  channel: dispatch.channel,
10220
+ place: dispatch.place,
10266
10221
  trigger: dispatch.trigger.kind
10267
10222
  }, "dispatching turn");
10268
10223
  const crons = dispatch.crons ?? [];
@@ -10278,6 +10233,7 @@ var Scheduler = class {
10278
10233
  exitReason: record.exitReason,
10279
10234
  costUsd: record.costUsd
10280
10235
  }, "turn finished");
10236
+ await this.retryRefused(key, { ...item, dispatch }, record);
10281
10237
  } catch (error) {
10282
10238
  this.log.error({ agent: dispatch.agent, project: dispatch.project, error: String(error) }, "turn crashed");
10283
10239
  } finally {
@@ -10296,6 +10252,48 @@ var Scheduler = class {
10296
10252
  }
10297
10253
  }
10298
10254
  }
10255
+ /**
10256
+ * A turn the model service turned away for now, overloaded or failing on its side, is sent again:
10257
+ * the same wake in the same conversation, after the next delay of `transientRetryMs`, with every
10258
+ * check a queued wake gets at dispatch. A wake already queued there, as a heartbeat's or a new
10259
+ * post's, runs the conversation instead. Once the delays are spent the heartbeat takes over, and
10260
+ * a `turn_refused` signal, which wakes nobody, says so. A turn that ends any other way ends the run.
10261
+ */
10262
+ async retryRefused(key, item, record) {
10263
+ if (record.transient !== true || record.exitReason !== "error") {
10264
+ this.refused.delete(key);
10265
+ return;
10266
+ }
10267
+ const refused = (this.refused.get(key) ?? 0) + 1;
10268
+ this.refused.set(key, refused);
10269
+ const { dispatch } = item;
10270
+ const delay = this.timings.transientRetryMs[refused - 1];
10271
+ if (delay !== void 0) {
10272
+ if (this.pending.has(key)) {
10273
+ return;
10274
+ }
10275
+ const { steeredThrough: _steered, ...retry } = item;
10276
+ this.pending.set(key, { ...retry, readyAt: this.now().getTime() + delay });
10277
+ this.log.info({ agent: dispatch.agent, project: dispatch.project, refused, delayMs: delay }, "the model service turned the turn away, sending its wake again");
10278
+ return;
10279
+ }
10280
+ if (refused !== this.timings.transientRetryMs.length + 1) {
10281
+ return;
10282
+ }
10283
+ const agent = await this.tryReadAgent(dispatch.agent);
10284
+ const where = dispatch.thread !== void 0 ? `thread ${dispatch.thread.id}` : dispatch.channel !== void 0 ? `channel ${dispatch.channel}` : dispatch.place !== void 0 ? `project ${dispatch.place}'s channels` : "its home";
10285
+ await this.board.publishSignal({
10286
+ kind: "turn_refused",
10287
+ key: `turn_refused:${key}`,
10288
+ summary: `${dispatch.agent}'s turns in ${where} were turned away by the model service ${refused} times in a row${record.model === null ? "" : ` on ${record.model}`}, the last with "${record.error ?? "no error"}"; its heartbeat takes over`,
10289
+ value: refused,
10290
+ threshold: refused,
10291
+ agent: dispatch.agent,
10292
+ ...agent === null ? {} : { role: agent.role },
10293
+ ...dispatch.project === SOCIETY_SCOPE ? {} : { project: dispatch.project },
10294
+ ...dispatch.thread?.task === true ? { taskId: dispatch.thread.id } : {}
10295
+ });
10296
+ }
10299
10297
  /**
10300
10298
  * Whether a ready turn waits for another of its citizen's turns that must run alone: its
10301
10299
  * onboarding in the project, queued or running, which comes before any other work of its there;
@@ -10379,9 +10377,9 @@ import { randomUUID } from "node:crypto";
10379
10377
 
10380
10378
  // packages/turn-host/dist/render.js
10381
10379
  var TURN_CONTRACT = [
10382
- "- Every turn belongs to one conversation, named at the top of your prompt: your home conversation in a project or the society, for its general channel and for work tied to no other channel and no thread; the conversation of one other channel of the place, a workstream such as a release; or the conversation of one thread you take part in, a task's, a proposal's, or a topic's. Each has its own session and its own turns, which may run while this one does. The prompt carries this conversation only: in a thread, its new messages, or the whole thread when the conversation is new, and in a task's thread the task and its stage; in a channel's or at home, that channel's or general's messages that concern you. Read anything else you need with get_task, search, and the board's files, and leave other channels', other threads', and other projects' work to their own turns.",
10380
+ "- Every turn belongs to one conversation, named at the top of your prompt: your home conversation in a project or the society, for its general channel and for work tied to no other channel and no thread; the conversation of one other channel of the place, a workstream such as a release; outside projects, the conversation of one project's channels, general and its workstreams together, for posts there that ask something of you while you are not a member; or the conversation of one thread you take part in, a task's, a proposal's, or a topic's. Each has its own session and its own turns, which may run while this one does. The prompt carries this conversation only: in a thread, its new messages, or the whole thread when the conversation is new, and in a task's thread the task and its stage; in a channel's, a project's, or at home, that channel's, that project's channels', or general's messages that concern you. Read anything else you need with get_task, search, and the board's files, and leave other channels', other threads', and other projects' work to their own turns.",
10383
10381
  "- Act through the board tools (the `board` MCP server). Holding a stage is a lease; every turn that touches the task renews it.",
10384
- "- 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. 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.",
10382
+ "- 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.",
10385
10383
  "- 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.",
10386
10384
  "- Silence is allowed. If the digest needs no reply, post nothing.",
10387
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.",
@@ -10414,7 +10412,7 @@ var GOVERNANCE = [
10414
10412
  ' - retirement: {"agent", "reason"}',
10415
10413
  ' - archive: {"project", "reason"}: approval archives the project: its members leave, its open threads close, and nothing more is posted, filed, or joined there, while its files and history stay; refused while a task there is in play',
10416
10414
  ` - reallocation: {"role", "changes": {"maxReplicas"?, "backlogThreshold"?, "wakeTriggers"?, "verbs"?}, "description"}: approval applies the changes to the existing role, each the field's new value and verbs and wakeTriggers whole lists, for its members from their next turn; refused for a role that does not exist or that has those values already. Only the user decides one that changes verbs or maxReplicas`,
10417
- ' - skill: {"name", "summary"}: the folder skills/<name>/ in your home, SKILL.md and every file beside it, which the board takes as it stands when this turn ends into society/proposals/<id>/, where whoever decides reads it; a proposal whose folder has no SKILL.md then is rejected by the board. Approval publishes it under society/skills/<name>/, where every citizen\'s skills index lists it until the steward or the user removes it with `remove_skill`; ask the steward to remove one that is wrong or superseded. Once yours is approved, delete skills/<name>/ from your home and report memoryUpdated: the board leaves your copy there, and your index would list it beside the published one and follow it as it drifts from what was reviewed. To revise a published skill, copy its folder home, edit it, and propose it again. A proposal from before folders carries "body", its SKILL.md text alone',
10415
+ ' - skill: {"name", "summary"}: the folder skills/<name>/ in your home, SKILL.md and every file beside it, which the board takes as it stands when this turn ends into society/proposals/<id>/, where whoever decides reads it; a proposal whose folder has no SKILL.md then is rejected by the board. Approval publishes it under society/skills/<name>/, where every citizen\'s skills index lists it until the steward or the user removes it with `remove_skill`; ask the steward to remove one that is wrong or superseded. Once yours is approved, delete skills/<name>/ from your home and report memoryUpdated: the board leaves your copy there, and your index would list it beside the published one and follow it as it drifts from what was reviewed. To revise a published skill, copy its folder home, edit it, and propose it again.',
10418
10416
  "- Projects and membership: a project is a lasting area of work, not one request; `create_project` opens one with its general channel (front desk and user), and `configure_project` sets its completion effect, its display name, or its default branch, never its slug (user, steward, concierge). A workstream within a project or the society, such as a release, gets a channel of its own: `create_channel` opens it, which its opener follows, and announces it in the place's general, where members subscribe when it concerns them, `create_task` with `channel` files the workstream's tasks there, and `archive_channel` ends it once none of its tasks is in play, leaving its history readable (user, steward, concierge; others propose a channel). A project whose work is finished or has moved to another project is archived by proposal, once no task there is in play. `join_project` and `leave_project` move yourself, or another citizen when you are the concierge, the steward, or the user; joining gives the pair a worktree and an onboarding turn. A society role, one that keeps watch over the whole society as the concierge and the steward do, is never in a project: it moves others, never itself.",
10419
10417
  "- Prefer scaling an existing role over inventing one, with a reallocation of its replicas, backlog threshold, wakes, or verbs; a new role is justified by work a project needs that no existing role covers. A role that needs a tool the board lacks is an engineering task, not a hiring request.",
10420
10418
  "- Operations signals are counters and timers the board logs, listed in the prompt of a role that reads them: stages waiting for a holder, backlog per role, stages waiting on a role nobody fills, churn, stale threads, idle members, missing capabilities, replicas added, spend. They are not posts in any channel; interpreting them is your judgment."
@@ -10454,7 +10452,7 @@ function renderInstructions(input) {
10454
10452
  if (norms.length > 0) {
10455
10453
  sections.push("", "## Society norms", "", norms);
10456
10454
  }
10457
- sections.push("", "## Core memory", "", memory.length === 0 ? "(empty)" : memory, "", "## Skills", "", ...renderSkillsIndex(input.skills ?? []), "", "## Where things are", "", `- Your home directory: ${input.homeDir}. memory/core.md is loaded every turn; memory/<topic>.md is your archive; skills/<name>/SKILL.md are your skills; projects/<slug>/notes.md are your notes. You may read and write all of it, and the board's search covers your archive and skills for you alone. The runner keeps it in git and shares it with the board after every turn, so whatever machine your next turn runs on has it; leave git there to the runner. Files over ${HOME_FILE_LIMIT_BYTES / (1024 * 1024)} MB are not kept.`, `- ${HOME_SCRATCH}/ in your home is your working folder in turns outside any project, and a thread's or a channel's conversation there works in a folder of its own inside it, which goes when the thread closes or the channel is archived: none of it is kept or shared, so put downloads, environments, and other working files there, and keep the rest of your home for what you mean to remember.`, `- The board projection: ${input.boardDir} (read-only markdown: society and project channels, tasks, threads; shared knowledge under projects/<slug>/knowledge/ and society/knowledge/; the society's skills under society/skills/; each project's dashboard, the status page the user reads on its view, at projects/<slug>/dashboard.md, which its members change with update_dashboard). Search it with your file tools; act through the board tools.`, "", "## Turn contract", "", TURN_CONTRACT, "", "## Planning", "", PLANNING, "", "## Governance", "", GOVERNANCE);
10455
+ sections.push("", "## Core memory", "", memory.length === 0 ? "(empty)" : memory, "", "## Skills", "", ...renderSkillsIndex(input.skills ?? []), "", "## Where things are", "", `- Your home directory: ${input.homeDir}. memory/core.md is loaded every turn; memory/<topic>.md is your archive; skills/<name>/SKILL.md are your skills; projects/<slug>/notes.md are your notes. You may read and write all of it, and the board's search covers your archive and skills for you alone. The runner keeps it in git and shares it with the board after every turn, so whatever machine your next turn runs on has it; leave git there to the runner. Files over ${HOME_FILE_LIMIT_BYTES / (1024 * 1024)} MB are not kept.`, `- ${HOME_SCRATCH}/ in your home is your working folder in turns outside any project, and a thread's, a channel's, or a project's channels' conversation there works in a folder of its own inside it, which goes when the thread closes or the channel or the project is archived: none of it is kept or shared, so put downloads, environments, and other working files there, and keep the rest of your home for what you mean to remember.`, `- The board projection: ${input.boardDir} (read-only markdown: society and project channels, tasks, threads; shared knowledge under projects/<slug>/knowledge/ and society/knowledge/; the society's skills under society/skills/; each project's dashboard, the status page the user reads on its view, at projects/<slug>/dashboard.md, which its members change with update_dashboard). Search it with your file tools; act through the board tools.`, "", "## Turn contract", "", TURN_CONTRACT, "", "## Planning", "", PLANNING, "", "## Governance", "", GOVERNANCE);
10458
10456
  if (input.onboarding !== void 0) {
10459
10457
  sections.push("", "## First turn", "", renderOnboardingPreamble(input.onboarding));
10460
10458
  }
@@ -10608,7 +10606,7 @@ function buildTurnPrompt(input) {
10608
10606
  lines.push("Operations signals arrived; they are listed under Operations signals below. Decide whether a proposal is warranted, and stay silent if not.");
10609
10607
  }
10610
10608
  if (dispatch.trigger.kind === "stage") {
10611
- lines.push("A stage is waiting for you: claim it with claim_task unless you hold it already, do the work in your worktree, which is on the task's branch, commit there, then advance_task. If the plan no longer fits, reshape it with plan_task.");
10609
+ lines.push("A stage is waiting for you: claim it with claim_task unless you hold it already, do the work in your worktree, which is on the task's branch unless Your workspace says otherwise, commit there, then advance_task. If the plan no longer fits, reshape it with plan_task.");
10612
10610
  }
10613
10611
  if (dispatch.trigger.kind === "task_done") {
10614
10612
  lines.push("A task you created is done. Tell whoever asked for it, if anyone did.");
@@ -10627,8 +10625,11 @@ function buildTurnPrompt(input) {
10627
10625
  if (conversation === null && dispatch.channel !== void 0) {
10628
10626
  const ref = channelRef(dispatch.project === SOCIETY_SCOPE ? null : dispatch.project, dispatch.channel);
10629
10627
  lines.push(`This turn is in your conversation of ${ref}, a workstream with turns of its own: its posts, and the work it asks for. Your home conversation for ${dispatch.project} carries its general channel, and each other channel and each thread you take part in is a conversation of its own; read one when you need it, and leave its work to its turn. Post here with post_message and channel ${ref}.`);
10628
+ } else if (conversation === null && dispatch.place !== void 0) {
10629
+ lines.push(`This turn is in your conversation of project ${dispatch.place}'s channels, from outside the project: the posts in its general and its other channels that concern you, and the work they ask for. Your home conversation for ${dispatch.project} carries its general channel, and each other project's channels, each other channel, and each thread you take part in is a conversation of its own with turns of its own; read one when you need it, and leave its work to its turn. Answer in the channel a post came from with post_message.`);
10630
10630
  } else if (conversation === null) {
10631
- lines.push(`This turn is in your home conversation for ${dispatch.project}: its general channel, and work tied to no other channel and no thread. Each other channel, a workstream, and each thread you take part in, a task's, a proposal's, or a topic's, is a conversation of its own with turns of its own; read one when you need it, and leave its work to its turn.`);
10631
+ const besides = dispatch.project === SOCIETY_SCOPE ? "Each other channel, each project's channels you are asked in from outside the project, and each thread you take part in, a task's, a proposal's, or a topic's," : "Each other channel, a workstream, and each thread you take part in, a task's, a proposal's, or a topic's,";
10632
+ lines.push(`This turn is in your home conversation for ${dispatch.project}: its general channel, and work tied to no other channel and no thread. ${besides} is a conversation of its own with turns of its own; read one when you need it, and leave its work to its turn.`);
10632
10633
  } else {
10633
10634
  const { thread, task } = conversation;
10634
10635
  const about = thread.subject === void 0 ? "a topic" : `${thread.subject.kind} ${thread.subject.id}`;
@@ -10663,7 +10664,9 @@ function buildTurnPrompt(input) {
10663
10664
  lines.push("", "## The user stopped your previous turn", "", `It was ended on purpose at ${last.endedAt ?? "its end"}, not by a failure. Do not pick its work up again unless this turn asks for it. The worktree may hold uncommitted changes; run git status before doing anything else.`);
10664
10665
  }
10665
10666
  if (last !== null && (last.exitReason === "error" || last.exitReason === "timeout")) {
10666
- lines.push("", "## Your previous turn did not finish", "", `It ended with ${last.exitReason}${last.error === null ? "" : `: ${last.error}`}.`, "The worktree may hold uncommitted changes. Run git status before doing anything else.");
10667
+ lines.push("", "## Your previous turn did not finish", "", `It ended with ${last.exitReason}${last.error === null ? "" : `: ${last.error}`}.`, ...last.sessionDropped === true ? [
10668
+ "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
+ ] : [], "The worktree may hold uncommitted changes. Run git status before doing anything else.");
10667
10670
  }
10668
10671
  const society = input.societyView;
10669
10672
  if (society !== void 0 && society !== null) {
@@ -10719,7 +10722,7 @@ function buildTurnPrompt(input) {
10719
10722
  if (signals.length === 0) {
10720
10723
  lines.push("None since your last turn here.");
10721
10724
  } else {
10722
- lines.push("Counters and timers the board logged since your last turn here, oldest first. A condition that persists is logged again every few hours; one that clears is not logged.", "");
10725
+ lines.push("Counters and timers the board logged since your last turn here, each condition once at its latest log, oldest first, with the hourly spend reports summed into one line. A condition that persists is logged again every few hours; one that clears is not logged.", "");
10723
10726
  for (const { ts, signal } of signals) {
10724
10727
  lines.push(signalLine(ts, signal));
10725
10728
  }
@@ -10759,6 +10762,13 @@ function capitalized(text) {
10759
10762
  }
10760
10763
 
10761
10764
  // packages/turn-host/dist/host.js
10765
+ function conversationOfJob(job) {
10766
+ return {
10767
+ ...job.thread === void 0 ? {} : { thread: job.thread },
10768
+ ...job.channel === void 0 ? {} : { channel: job.channel },
10769
+ ...job.place === void 0 ? {} : { place: job.place }
10770
+ };
10771
+ }
10762
10772
  var SILENT = { info() {
10763
10773
  }, warn() {
10764
10774
  }, error() {
@@ -10769,7 +10779,50 @@ var DEFAULT_RESIDENT_IDLE_MS = 10 * 6e4;
10769
10779
  var LEASE_RENEW_MS = 10 * 6e4;
10770
10780
  var USER_POST_TRIGGER2 = "user_post";
10771
10781
  var OPS_TRIGGER2 = "ops_event";
10782
+ var RESUME_RETRIES = 3;
10783
+ function stuckNotice(stuck) {
10784
+ const more = stuck.total - stuck.commits.length;
10785
+ const commits = `${stuck.commits.map((commit) => `\`${commit}\``).join(", ")}${more > 0 ? `, and ${more} more` : ""}`;
10786
+ return `${stuck.agent}'s worktree for this task holds commits that \`${stuck.branch}\` lacks and that the runner cannot fast-forward it to, because they no longer build on its tip or another worktree's commits build on it too: ${commits}. Until someone puts them on the branch, it lacks them, and so does what lands from it; any worktree of the project's clone can cherry-pick or merge them by id.`;
10787
+ }
10772
10788
  var MAX_SIGNALS = 30;
10789
+ var SIGNAL_READ = 500;
10790
+ var REPORT_TURNS = /^(\d+) turn\(s\) completed /;
10791
+ function sumSpend(records, partial) {
10792
+ const reports = records.filter((record) => record.signal.kind === "turn_cost");
10793
+ const last = reports.at(-1);
10794
+ if (last === void 0 || reports.length === 1 && !partial) {
10795
+ return [...records];
10796
+ }
10797
+ let cost = 0;
10798
+ let turns2 = 0;
10799
+ let dearest = last;
10800
+ for (const report of reports) {
10801
+ cost += report.signal.value;
10802
+ const counted = REPORT_TURNS.exec(report.signal.summary)?.[1];
10803
+ turns2 = turns2 === null || counted === void 0 ? null : turns2 + Number(counted);
10804
+ if (report.signal.value > dearest.signal.value) {
10805
+ dearest = report;
10806
+ }
10807
+ }
10808
+ const first = reports[0] ?? last;
10809
+ const one = reports.length === 1;
10810
+ const summary = [
10811
+ `${turns2 === null ? "turns" : `${turns2} turn(s)`} completed for $${cost.toFixed(2)} in ${reports.length} hourly report(s) logged ${one ? `at ${last.ts}` : `from ${first.ts} to ${last.ts}`}`,
10812
+ ...one ? [] : [`the dearest, logged at ${dearest.ts}, was $${dearest.signal.value.toFixed(2)}`],
10813
+ ...partial ? [`the read holds the newest ${SIGNAL_READ} signals, so these are lower bounds`] : []
10814
+ ].join("; ");
10815
+ const summed = { ...last, signal: { ...last.signal, summary, value: cost } };
10816
+ return records.flatMap((record) => record === last ? [summed] : record.signal.kind === "turn_cost" ? [] : [record]);
10817
+ }
10818
+ function latestPerKey(records) {
10819
+ const latest = /* @__PURE__ */ new Map();
10820
+ for (const record of records) {
10821
+ latest.delete(record.signal.key);
10822
+ latest.set(record.signal.key, record);
10823
+ }
10824
+ return [...latest.values()];
10825
+ }
10773
10826
  var TurnHost = class {
10774
10827
  board;
10775
10828
  mcpUrl;
@@ -10813,13 +10866,15 @@ var TurnHost = class {
10813
10866
  const actor = { name: agent.name, role: agent.role };
10814
10867
  const thread = dispatch.thread;
10815
10868
  const channel = thread === void 0 ? dispatch.channel : void 0;
10816
- const part = conversationPart({ thread: thread?.id, channel });
10869
+ const place = thread === void 0 && channel === void 0 && dispatch.project === SOCIETY_SCOPE ? dispatch.place : void 0;
10870
+ const part = conversationPart({ thread: thread?.id, channel, place });
10817
10871
  const startedAt = this.now().toISOString();
10818
10872
  const base = {
10819
10873
  agent: agent.name,
10820
10874
  project: dispatch.project,
10821
10875
  ...thread === void 0 ? {} : { thread: thread.id },
10822
10876
  ...channel === void 0 ? {} : { channel },
10877
+ ...place === void 0 ? {} : { place },
10823
10878
  runner: runner.name,
10824
10879
  cli: agent.cli,
10825
10880
  session: null,
@@ -10844,23 +10899,25 @@ var TurnHost = class {
10844
10899
  const charter = await this.board.readRole(agent.role);
10845
10900
  const societyScope = dispatch.project === SOCIETY_SCOPE;
10846
10901
  const taskId = thread?.task === true ? thread.id : void 0;
10902
+ const task = taskId === void 0 ? null : await this.board.findTask(taskId).then((found) => found.task).catch(() => null);
10847
10903
  const project = societyScope ? null : await this.board.readProject(dispatch.project);
10848
- const workspace = await this.workspaceOf(project, thread, channel);
10904
+ const workspace = await this.workspaceOf(project, thread, channel, place, task);
10849
10905
  const sessions = await this.board.readSessions(agent.name, dispatch.project, part);
10850
10906
  const recorded = sessions.runner === runner.name ? sessions[agent.cli] : void 0;
10851
10907
  const newSession = recorded === void 0;
10852
10908
  const lastTurn = await this.board.readLastTurn(agent.name, dispatch.project, part);
10853
10909
  const costSoFarUsd = !newSession && lastTurn?.session === recorded ? lastTurn.sessionCostUsd ?? lastTurn.costUsd : 0;
10910
+ const resumeRetries = !newSession && lastTurn?.session === recorded && lastTurn.resumeFailed === "retry" ? lastTurn.resumeRetries ?? 1 : 0;
10854
10911
  const digest = await this.board.readDigest({
10855
10912
  ...actor,
10856
10913
  scope: dispatch.project,
10857
10914
  ...thread === void 0 ? {} : { thread: thread.id },
10858
- ...channel === void 0 ? {} : { channel }
10915
+ ...channel === void 0 ? {} : { channel },
10916
+ ...place === void 0 ? {} : { place }
10859
10917
  }, { advance: false, limit: thread === void 0 ? 50 : 100 });
10860
10918
  const threadSoFar = thread !== void 0 && newSession ? (await this.board.listThread(thread.id).catch(() => [])).slice(-100) : null;
10861
10919
  const messages = threadSoFar ?? digest.messages;
10862
10920
  const cursorAfter = threadSoFar?.at(-1)?.id ?? digest.cursor;
10863
- const task = taskId === void 0 ? null : await this.board.findTask(taskId).then((found) => found.task).catch(() => null);
10864
10921
  const held = task === null ? [] : (await this.board.heldClaims(agent.name)).filter((each) => each.id === task.id);
10865
10922
  const onboarding = dispatch.onboarding || newSession && part === void 0 ? {
10866
10923
  agentName: agent.name,
@@ -10903,7 +10960,7 @@ var TurnHost = class {
10903
10960
  dir: `${PATH_TOKENS.board}/projects/${dispatch.project}/knowledge`,
10904
10961
  topics: await this.board.listKnowledge(dispatch.project)
10905
10962
  };
10906
- const signals = part === void 0 && charter.wakeTriggers.includes(OPS_TRIGGER2) ? (await this.board.listSignals(500)).filter((record2) => wakeScope(agent, record2.signal.project ?? null) === dispatch.project && (lastTurn === null || record2.ts > lastTurn.startedAt)).slice(-MAX_SIGNALS) : null;
10963
+ const signals = part === void 0 && charter.wakeTriggers.includes(OPS_TRIGGER2) ? await this.signalsSince(agent, dispatch.project, lastTurn) : null;
10907
10964
  const waiting = task !== null && task.status === "open" && !task.completing && mayHoldStage(actor, task) ? [task] : [];
10908
10965
  const threads = /* @__PURE__ */ new Map();
10909
10966
  for (const id of new Set(messages.flatMap((message) => message.thread ?? []))) {
@@ -10919,7 +10976,7 @@ var TurnHost = class {
10919
10976
  fresh: threadSoFar !== null,
10920
10977
  ...threadRecord.state === "closed" ? { replyIn: await this.replyIn(threadRecord) } : {}
10921
10978
  };
10922
- const ending = dispatch.trigger.kind === "closing" ? await this.endingOf(dispatch.project, threadRecord, task, channel) : null;
10979
+ const ending = dispatch.trigger.kind === "closing" ? await this.endingOf(dispatch.project, threadRecord, task, channel, place) : null;
10923
10980
  const society = await this.board.society();
10924
10981
  const crons = [];
10925
10982
  for (const id of dispatch.crons ?? []) {
@@ -10968,6 +11025,7 @@ var TurnHost = class {
10968
11025
  scope: dispatch.project,
10969
11026
  ...thread === void 0 ? {} : { thread: thread.id },
10970
11027
  ...channel === void 0 ? {} : { channel },
11028
+ ...place === void 0 ? {} : { place },
10971
11029
  session: recorded ?? null,
10972
11030
  costSoFarUsd,
10973
11031
  instructions,
@@ -10994,6 +11052,7 @@ var TurnHost = class {
10994
11052
  stopRequestedBy: null,
10995
11053
  held: held.map((each) => each.id),
10996
11054
  costSoFarUsd,
11055
+ resumeRetries,
10997
11056
  configuredModel: agent.model ?? null,
10998
11057
  transcript: [],
10999
11058
  coldToken: residentKey === null ? token : null,
@@ -11007,6 +11066,7 @@ var TurnHost = class {
11007
11066
  project: dispatch.project,
11008
11067
  thread: thread?.id,
11009
11068
  channel,
11069
+ place,
11010
11070
  runner: runner.name,
11011
11071
  session: recorded,
11012
11072
  newSession,
@@ -11043,8 +11103,7 @@ var TurnHost = class {
11043
11103
  turnId,
11044
11104
  agent: turn.job.agent,
11045
11105
  project: turn.job.scope,
11046
- thread: turn.job.thread,
11047
- channel: turn.job.channel,
11106
+ ...conversationOfJob(turn.job),
11048
11107
  messages: sent.messages
11049
11108
  }).catch((error) => {
11050
11109
  this.log.warn({ turnId, error: String(error) }, "could not record a steer");
@@ -11052,17 +11111,14 @@ var TurnHost = class {
11052
11111
  }
11053
11112
  }
11054
11113
  turn.transcript.push(entry);
11055
- this.onEvent?.(turn.job.agent, turn.job.scope, entry.event, {
11056
- thread: turn.job.thread,
11057
- channel: turn.job.channel
11058
- });
11114
+ this.onEvent?.(turn.job.agent, turn.job.scope, entry.event, conversationOfJob(turn.job));
11059
11115
  }
11060
11116
  }
11061
11117
  /** The turn in flight in a conversation, if any. */
11062
11118
  turnIn(agent, scope, conversation = {}) {
11063
11119
  for (const turn of this.open.values()) {
11064
11120
  const { job } = turn;
11065
- if (job.agent === agent && job.scope === scope && job.thread === conversation.thread && job.channel === conversation.channel) {
11121
+ if (job.agent === agent && job.scope === scope && job.thread === conversation.thread && job.channel === conversation.channel && job.place === conversation.place) {
11066
11122
  return { turnId: job.turnId, runner: turn.runner, cli: job.cli };
11067
11123
  }
11068
11124
  }
@@ -11073,8 +11129,7 @@ var TurnHost = class {
11073
11129
  return [...this.open.values()].map(({ job }) => ({
11074
11130
  agent: job.agent,
11075
11131
  scope: job.scope,
11076
- ...job.thread === void 0 ? {} : { thread: job.thread },
11077
- ...job.channel === void 0 ? {} : { channel: job.channel }
11132
+ ...conversationOfJob(job)
11078
11133
  }));
11079
11134
  }
11080
11135
  /** The turns in flight, for the interface: their conversations and where a mention reaches them. */
@@ -11083,8 +11138,7 @@ var TurnHost = class {
11083
11138
  turnId: turn.job.turnId,
11084
11139
  agent: turn.job.agent,
11085
11140
  scope: turn.job.scope,
11086
- ...turn.job.thread === void 0 ? {} : { thread: turn.job.thread },
11087
- ...turn.job.channel === void 0 ? {} : { channel: turn.job.channel },
11141
+ ...conversationOfJob(turn.job),
11088
11142
  cli: turn.job.cli,
11089
11143
  ...turn.record.trigger.channel === void 0 ? {} : { askedIn: turn.record.trigger.channel },
11090
11144
  runner: turn.runner
@@ -11104,12 +11158,7 @@ var TurnHost = class {
11104
11158
  return "busy";
11105
11159
  }
11106
11160
  const { job } = turn;
11107
- const digest = await this.board.readDigest({
11108
- ...turn.actor,
11109
- scope: job.scope,
11110
- ...job.thread === void 0 ? {} : { thread: job.thread },
11111
- ...job.channel === void 0 ? {} : { channel: job.channel }
11112
- }, {
11161
+ const digest = await this.board.readDigest({ ...turn.actor, scope: job.scope, ...conversationOfJob(job) }, {
11113
11162
  ...turn.delivered === null ? {} : { since_cursor: turn.delivered },
11114
11163
  advance: false,
11115
11164
  limit: 50
@@ -11169,6 +11218,8 @@ var TurnHost = class {
11169
11218
  }
11170
11219
  const { job } = turn;
11171
11220
  const part = conversationPart(job);
11221
+ const resumeRetries = outcome.resumeFailed === "retry" ? turn.resumeRetries + 1 : void 0;
11222
+ const dropSession = job.session !== null && (outcome.resumeFailed === "lost" || (resumeRetries ?? 0) >= RESUME_RETRIES);
11172
11223
  const finished = {
11173
11224
  ...turn.record,
11174
11225
  session: outcome.session,
@@ -11182,20 +11233,36 @@ var TurnHost = class {
11182
11233
  toolCalls: turn.transcript.filter((entry) => entry.event.type === "tool_call").length,
11183
11234
  model: outcome.model ?? turn.configuredModel,
11184
11235
  ...outcome.work === null ? {} : { work: outcome.work },
11185
- ...outcome.exitReason === "stopped" ? { stoppedBy: turn.stopRequestedBy ?? USER_NAME } : {}
11236
+ ...outcome.exitReason === "stopped" ? { stoppedBy: turn.stopRequestedBy ?? USER_NAME } : {},
11237
+ ...outcome.resumeFailed === void 0 ? {} : { resumeFailed: outcome.resumeFailed },
11238
+ ...outcome.transient === true ? { transient: true } : {},
11239
+ ...resumeRetries === void 0 ? {} : { resumeRetries },
11240
+ ...dropSession ? { sessionDropped: true } : {}
11186
11241
  };
11187
11242
  const ended = await this.conversationEnded(job);
11188
11243
  const closing = turn.record.trigger.kind === "closing";
11189
11244
  const decided = closing && outcome.exitReason !== "error" && outcome.exitReason !== "timeout";
11190
11245
  const recorded = await this.recorded(turn, async () => {
11191
- if (outcome.session !== job.session) {
11246
+ if (dropSession && job.session !== null) {
11247
+ await this.board.dropSession(job.agent, job.scope, job.cli, job.session, part);
11248
+ this.log.warn({
11249
+ agent: job.agent,
11250
+ project: job.scope,
11251
+ thread: job.thread,
11252
+ channel: job.channel,
11253
+ place: job.place,
11254
+ session: job.session,
11255
+ resumeFailed: outcome.resumeFailed,
11256
+ resumeRetries
11257
+ }, "dropped a session its CLI could not resume");
11258
+ } else if (outcome.session !== null && outcome.session !== job.session) {
11192
11259
  await this.board.writeSession(job.agent, job.scope, job.cli, outcome.session, turn.runner, part);
11193
11260
  }
11194
11261
  if (outcome.exitReason === "completed" || outcome.exitReason === "blocked" || outcome.exitReason === "stopped") {
11195
11262
  await this.board.setDigestCursor(job.agent, job.scope, turn.delivered, part);
11196
11263
  await this.renewLeases(turn.actor, turn.held);
11197
11264
  if (outcome.status?.needsUserDecision === true) {
11198
- await this.askUser(turn.actor, job, turn.record.startedAt, outcome.status.summary);
11265
+ await this.askUser(turn.actor, job, turn.record.trigger.channel, turn.record.startedAt, outcome.status.summary);
11199
11266
  }
11200
11267
  }
11201
11268
  await this.board.finishTurn(finished, turn.transcript);
@@ -11207,7 +11274,7 @@ var TurnHost = class {
11207
11274
  conversation
11208
11275
  });
11209
11276
  if (!again) {
11210
- const where = conversation.kind === "thread" ? `thread ${conversation.id}` : `channel ${conversation.name}`;
11277
+ const where = conversation.kind === "thread" ? `thread ${conversation.id}` : conversation.kind === "channel" ? `channel ${conversation.name}` : `project ${conversation.project}'s channels`;
11211
11278
  await this.board.publishSignal({
11212
11279
  kind: "stuck_workspace",
11213
11280
  key: `stuck_workspace:${job.agent}:${job.scope}:${conversationPart(job) ?? ""}`,
@@ -11224,12 +11291,18 @@ var TurnHost = class {
11224
11291
  this.log.warn({ runner: turn.runner, cli: job.cli, error: String(error) }, "could not record a quota");
11225
11292
  });
11226
11293
  }
11294
+ for (const stuck of outcome.stuck ?? []) {
11295
+ await this.board.postTaskNotice(stuck.branch.slice("task/".length), stuckNotice(stuck)).catch((error) => {
11296
+ this.log.warn({ branch: stuck.branch, agent: stuck.agent, error: String(error) }, "could not tell a task's thread of commits its branch cannot take");
11297
+ });
11298
+ }
11227
11299
  if (recorded) {
11228
11300
  this.log.info({
11229
11301
  agent: job.agent,
11230
11302
  project: job.scope,
11231
11303
  thread: job.thread,
11232
11304
  channel: job.channel,
11305
+ place: job.place,
11233
11306
  exitReason: finished.exitReason
11234
11307
  }, "turn finished");
11235
11308
  await this.takeSkillFiles(finished);
@@ -11296,6 +11369,7 @@ var TurnHost = class {
11296
11369
  project: dispatch.project,
11297
11370
  ...dispatch.thread === void 0 ? {} : { thread: dispatch.thread.id },
11298
11371
  ...dispatch.thread !== void 0 || dispatch.channel === void 0 ? {} : { channel: dispatch.channel },
11372
+ ...dispatch.thread !== void 0 || dispatch.channel !== void 0 || dispatch.project !== SOCIETY_SCOPE || dispatch.place === void 0 ? {} : { place: dispatch.place },
11299
11373
  runner,
11300
11374
  cli: agent?.cli ?? null,
11301
11375
  session: null,
@@ -11342,24 +11416,37 @@ var TurnHost = class {
11342
11416
  return token;
11343
11417
  }
11344
11418
  /**
11345
- * Where a conversation works: a task's on its branch, any other thread's and a channel's in a
11419
+ * The signals a reader's prompt lists: those logged for its scope since its last turn there,
11420
+ * the spend reports summed, then each condition once at its latest log, the newest kept.
11421
+ */
11422
+ async signalsSince(agent, scope, lastTurn) {
11423
+ const read2 = await this.board.listSignals(SIGNAL_READ + 1);
11424
+ const past = read2.length > SIGNAL_READ ? read2[0] : void 0;
11425
+ const partial = past !== void 0 && (lastTurn === null || past.ts > lastTurn.startedAt);
11426
+ const since = read2.slice(-SIGNAL_READ).filter((record) => wakeScope(agent, record.signal.project ?? null) === scope && (lastTurn === null || record.ts > lastTurn.startedAt));
11427
+ return latestPerKey(sumSpend(since, partial)).slice(-MAX_SIGNALS);
11428
+ }
11429
+ /**
11430
+ * Where a conversation works: a task's on its branch, which its stage's holder has to itself,
11431
+ * any other thread's, a channel's, and a project's channels' seen from the society scope in a
11346
11432
  * place of its own, and the home in the citizen's own.
11347
11433
  */
11348
- async workspaceOf(project, thread, channel) {
11434
+ async workspaceOf(project, thread, channel, place, task) {
11349
11435
  const own = thread !== void 0 ? thread.task ? {} : { thread: thread.id } : channel === void 0 ? {} : { channel };
11350
11436
  if (project === null) {
11351
- return { kind: "home", ...own };
11437
+ return { kind: "home", ...own, ...place === void 0 ? {} : { place } };
11352
11438
  }
11353
11439
  const repo = projectRepo(project);
11354
11440
  if (thread?.task === true) {
11355
- return { kind: "task", repo, taskId: thread.id };
11441
+ const holder = task?.status === "claimed" && task.claimedBy !== void 0 && task.claimedBy !== USER_NAME && (task.leaseExpiresAt === void 0 || new Date(task.leaseExpiresAt).getTime() > this.now().getTime()) ? task.claimedBy : void 0;
11442
+ return { kind: "task", repo, taskId: thread.id, ...holder === void 0 ? {} : { holder } };
11356
11443
  }
11357
- const branches = (await this.board.listTasks(project.slug)).filter((task) => task.status === "open" || task.status === "claimed").map((task) => `task/${task.id}`);
11444
+ const branches = (await this.board.listTasks(project.slug)).filter((each) => each.status === "open" || each.status === "claimed").map((each) => `task/${each.id}`);
11358
11445
  return { kind: "project", repo, branches, ...own };
11359
11446
  }
11360
11447
  /**
11361
- * Whether a thread conversation's task or thread has ended, or a channel conversation's channel
11362
- * was archived, which frees its workspace.
11448
+ * Whether a thread conversation's task or thread has ended, a channel conversation's channel was
11449
+ * archived, or a project conversation's project was, which frees its workspace.
11363
11450
  */
11364
11451
  async conversationEnded(job) {
11365
11452
  const conversation = workspaceConversationOf(job);
@@ -11373,14 +11460,14 @@ var TurnHost = class {
11373
11460
  * How a closing turn's conversation ended, and where its citizen asks someone else to take over
11374
11461
  * work that is not its own: the thread's channel while it is open, else the place's general.
11375
11462
  */
11376
- async endingOf(scope, thread, task, channel) {
11463
+ async endingOf(scope, thread, task, channel, project) {
11377
11464
  const place = scope === SOCIETY_SCOPE ? null : scope;
11378
11465
  if (thread !== void 0) {
11379
11466
  const closed = `the thread "${thread.title}" on ${thread.channel} was closed${thread.closedBy === void 0 ? "" : ` by ${thread.closedBy}`}${task === null ? "" : `, its task ${task.status}`}`;
11380
11467
  return { how: closed, askIn: await this.replyIn(thread) };
11381
11468
  }
11382
11469
  if (channel === void 0) {
11383
- return null;
11470
+ return project === void 0 ? null : { how: `project ${project} was archived`, askIn: channelRef(place, "general") };
11384
11471
  }
11385
11472
  const ref = channelRef(place, channel);
11386
11473
  const archived = (await this.board.listChannels()).find((each) => each.ref === ref)?.archived;
@@ -11394,13 +11481,15 @@ var TurnHost = class {
11394
11481
  * the user, which is what puts it before them. One that mentioned the user nowhere asks in its
11395
11482
  * own thread when it was in one that is still open, so the answer comes back to the same
11396
11483
  * conversation, and otherwise in a thread of its own on its conversation's channel, or its scope's
11397
- * general for the home, opened as the citizen with its summary. One that proposed something the
11398
- * user decides has put its decision before the user already, where deciding it clears it; a
11399
- * mention would stay until the user posted beside it.
11484
+ * general for the home, opened as the citizen with its summary; a project's conversation in the
11485
+ * society scope asks on the channel of the project its wake came from, else the project's general.
11486
+ * One that proposed something the user decides has put its decision before the user already,
11487
+ * where deciding it clears it; a mention would stay until the user posted beside it.
11400
11488
  */
11401
- async askUser(actor, job, since, summary) {
11489
+ async askUser(actor, job, askedIn, since, summary) {
11402
11490
  const threadId = job.thread;
11403
11491
  const place = job.scope === SOCIETY_SCOPE ? null : job.scope;
11492
+ const channel = job.place === void 0 ? channelRef(place, job.channel ?? "general") : askedIn !== void 0 && parseChannelRef(askedIn).project === job.place ? askedIn : channelRef(job.place, "general");
11404
11493
  try {
11405
11494
  if (await this.board.hasMentioned(actor.name, USER_NAME, since)) {
11406
11495
  return;
@@ -11418,7 +11507,7 @@ var TurnHost = class {
11418
11507
  return;
11419
11508
  }
11420
11509
  const thread = await this.board.openThread(actor, {
11421
- channel: channelRef(place, job.channel ?? "general"),
11510
+ channel,
11422
11511
  title: `${actor.name} asks for a decision: ${summary}`.slice(0, 200)
11423
11512
  });
11424
11513
  await this.board.postMessage(actor, {
@@ -11734,7 +11823,8 @@ var RunnerHub = class {
11734
11823
  async steer(conversation) {
11735
11824
  const turn = this.host.turnIn(conversation.agent, conversation.project, {
11736
11825
  thread: conversation.thread,
11737
- channel: conversation.channel
11826
+ channel: conversation.channel,
11827
+ place: conversation.place
11738
11828
  });
11739
11829
  const seat = turn === null ? void 0 : this.seats.get(turn.runner);
11740
11830
  if (turn === null || seat === void 0 || seat.send === null) {
@@ -11951,11 +12041,11 @@ var RunnerHub = class {
11951
12041
  * NOT_FOUND when the project has no runner yet or the branch or the path does not exist, and
11952
12042
  * `RunnerAwayError` while its runner is away.
11953
12043
  */
11954
- async readBranch(project, branch, path10) {
12044
+ async readBranch(project, branch, path11) {
11955
12045
  const { seat, repo } = await this.projectSeat(project, "branchReads", "read files");
11956
- const file = BranchFileSchema.nullable().parse(await this.request(seat, (request) => ({ type: "file", request, read: { repo, branch, path: path10 } }), FILE_TIMEOUT_MS));
12046
+ const file = BranchFileSchema.nullable().parse(await this.request(seat, (request) => ({ type: "file", request, read: { repo, branch, path: path11 } }), FILE_TIMEOUT_MS));
11957
12047
  if (file === null) {
11958
- throw new BoardError("NOT_FOUND", `${branch} has no ${path10 === "" ? "commit" : path10}`);
12048
+ throw new BoardError("NOT_FOUND", `${branch} has no ${path11 === "" ? "commit" : path11}`);
11959
12049
  }
11960
12050
  return file;
11961
12051
  }
@@ -11975,11 +12065,11 @@ var RunnerHub = class {
11975
12065
  * What a branch of a project's repository did to one file since it left the default branch, read
11976
12066
  * on the runner the project lives on, with the same refusals as `branchChanges`.
11977
12067
  */
11978
- async branchDiff(project, branch, path10) {
12068
+ async branchDiff(project, branch, path11) {
11979
12069
  const { seat, repo } = await this.projectSeat(project, "branchChanges", "list changes");
11980
- const diff = BranchFileDiffSchema.nullable().parse(await this.request(seat, (request) => ({ type: "diff", request, read: { repo, branch, path: path10 } }), FILE_TIMEOUT_MS));
12070
+ const diff = BranchFileDiffSchema.nullable().parse(await this.request(seat, (request) => ({ type: "diff", request, read: { repo, branch, path: path11 } }), FILE_TIMEOUT_MS));
11981
12071
  if (diff === null) {
11982
- throw new BoardError("NOT_FOUND", `${branch} did not change ${path10}`);
12072
+ throw new BoardError("NOT_FOUND", `${branch} did not change ${path11}`);
11983
12073
  }
11984
12074
  return diff;
11985
12075
  }
@@ -11990,7 +12080,7 @@ var RunnerHub = class {
11990
12080
  * back as a `BoardError` in the runner's words.
11991
12081
  */
11992
12082
  async readTurnImage(actor, file) {
11993
- const turn = this.host.runningTurns().find((each) => each.agent === actor.name && each.scope === actor.scope && each.thread === actor.thread && each.channel === actor.channel);
12083
+ const turn = this.host.runningTurns().find((each) => each.agent === actor.name && each.scope === actor.scope && each.thread === actor.thread && each.channel === actor.channel && each.place === actor.place);
11994
12084
  if (turn === void 0) {
11995
12085
  throw new BoardError("INVALID_STATE", `${actor.name} has no turn running in this conversation to read ${file} from`);
11996
12086
  }
@@ -12223,6 +12313,8 @@ import pino from "pino";
12223
12313
  // packages/board-mcp/dist/index.js
12224
12314
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12225
12315
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
12316
+ var MAX_TOOL_INPUT_ELEMENTS = 1e3;
12317
+ var MAX_REQUEST_BODY_SIZE = 1024 * 1024;
12226
12318
  var BOARD_TOOLS = VERB_NAMES.map((name) => ({
12227
12319
  name,
12228
12320
  description: VERB_DESCRIPTIONS[name],
@@ -12245,7 +12337,7 @@ function describeError(error) {
12245
12337
  return String(error);
12246
12338
  }
12247
12339
  async function createBoardMcpServer(board2, actor, version) {
12248
- const server2 = new McpServer({ name: "stellaris-board", version });
12340
+ const server2 = new McpServer({ name: "stellaris-board", version }, { maxToolInputElements: MAX_TOOL_INPUT_ELEMENTS });
12249
12341
  const charter = await board2.readRole(actor.role);
12250
12342
  for (const tool of toolsForRole(charter)) {
12251
12343
  server2.registerTool(tool.name, { description: tool.description, inputSchema: tool.inputSchema }, async (args) => {
@@ -12266,7 +12358,10 @@ async function createBoardMcpServer(board2, actor, version) {
12266
12358
  }
12267
12359
  async function handleMcpRequest(board2, actor, request, version) {
12268
12360
  const server2 = await createBoardMcpServer(board2, actor, version);
12269
- const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true });
12361
+ const transport = new WebStandardStreamableHTTPServerTransport({
12362
+ enableJsonResponse: true,
12363
+ maxRequestBodySize: MAX_REQUEST_BODY_SIZE
12364
+ });
12270
12365
  await server2.connect(transport);
12271
12366
  try {
12272
12367
  return await transport.handleRequest(request, {
@@ -12283,6 +12378,25 @@ import { compress } from "hono/compress";
12283
12378
  import { streamSSE as streamSSE2 } from "hono/streaming";
12284
12379
  import { z as z19, ZodError } from "zod";
12285
12380
 
12381
+ // apps/server/dist/body-limit.js
12382
+ import { bodyLimit } from "hono/body-limit";
12383
+ var ENROLL_BODY_LIMIT_BYTES = 16 * 1024;
12384
+ var API_BODY_LIMIT_BYTES = 1024 * 1024;
12385
+ var RUNNER_BODY_LIMIT_BYTES = 8 * 1024 * 1024;
12386
+ var RUNNER_ANSWER_LIMIT_BYTES = 16 * 1024 * 1024;
12387
+ function limitBody(maxSize) {
12388
+ return bodyLimit({
12389
+ maxSize,
12390
+ onError: (c) => {
12391
+ c.header("connection", "close");
12392
+ return c.json({
12393
+ error: "PAYLOAD_TOO_LARGE",
12394
+ message: `the request body is over the ${maxSize} bytes this route takes`
12395
+ }, 413);
12396
+ }
12397
+ });
12398
+ }
12399
+
12286
12400
  // apps/server/dist/github-auth.js
12287
12401
  import { randomBytes as randomBytes2 } from "node:crypto";
12288
12402
  import { Hono } from "hono";
@@ -12490,7 +12604,7 @@ function rateLimit(options) {
12490
12604
  }
12491
12605
 
12492
12606
  // apps/server/dist/runner-routes.js
12493
- import path7 from "node:path";
12607
+ import path8 from "node:path";
12494
12608
  import { Hono as Hono2 } from "hono";
12495
12609
  import { streamSSE } from "hono/streaming";
12496
12610
 
@@ -12590,8 +12704,9 @@ function bearer(c) {
12590
12704
  function runnerRoutes(board2, hub, enrollments, enrollLimit) {
12591
12705
  const routes = new Hono2();
12592
12706
  if (enrollments !== void 0) {
12593
- routes.post("/enroll", enrollLimit ?? ((_c, next) => next()), async (c) => c.json(enrollments.open(RunnerEnrollRequestSchema.parse(await c.req.json()))));
12594
- routes.post("/enroll/poll", async (c) => {
12707
+ const enrollBody = limitBody(ENROLL_BODY_LIMIT_BYTES);
12708
+ routes.post("/enroll", enrollLimit ?? ((_c, next) => next()), enrollBody, async (c) => c.json(enrollments.open(RunnerEnrollRequestSchema.parse(await c.req.json()))));
12709
+ routes.post("/enroll/poll", enrollBody, async (c) => {
12595
12710
  const { deviceCode } = RunnerEnrollPollSchema.parse(await c.req.json());
12596
12711
  const status = enrollments.poll(deviceCode);
12597
12712
  return status === null ? c.json({ error: "NOT_FOUND", message: "no such enrollment; it may have expired" }, 404) : c.json(status);
@@ -12606,7 +12721,8 @@ function runnerRoutes(board2, hub, enrollments, enrollLimit) {
12606
12721
  c.set("runner", runner);
12607
12722
  await next();
12608
12723
  });
12609
- routes.post("/hello", async (c) => c.json(await hub.register(c.get("runner"), RunnerHelloSchema.parse(await c.req.json()))));
12724
+ const body = limitBody(RUNNER_BODY_LIMIT_BYTES);
12725
+ routes.post("/hello", body, async (c) => c.json(await hub.register(c.get("runner"), RunnerHelloSchema.parse(await c.req.json()))));
12610
12726
  routes.get("/stream", (c) => {
12611
12727
  const runner = c.get("runner");
12612
12728
  return streamSSE(c, async (stream) => {
@@ -12644,7 +12760,7 @@ function runnerRoutes(board2, hub, enrollments, enrollLimit) {
12644
12760
  }
12645
12761
  });
12646
12762
  });
12647
- routes.post("/turns/:id/events", async (c) => {
12763
+ routes.post("/turns/:id/events", body, async (c) => {
12648
12764
  const { entries } = TurnEventsSchema.parse(await c.req.json());
12649
12765
  await hub.turnEvents(c.get("runner"), UlidSchema.parse(c.req.param("id")), entries);
12650
12766
  return c.json({ ok: true });
@@ -12653,21 +12769,21 @@ function runnerRoutes(board2, hub, enrollments, enrollLimit) {
12653
12769
  hub.received(c.get("runner"), UlidSchema.parse(c.req.param("id")));
12654
12770
  return c.json({ ok: true });
12655
12771
  });
12656
- routes.post("/turns/:id/outcome", async (c) => c.json(await hub.turnOutcome(c.get("runner"), UlidSchema.parse(c.req.param("id")), TurnOutcomeSchema.parse(await c.req.json()))));
12657
- routes.post("/requests/:id", async (c) => {
12772
+ routes.post("/turns/:id/outcome", body, async (c) => c.json(await hub.turnOutcome(c.get("runner"), UlidSchema.parse(c.req.param("id")), TurnOutcomeSchema.parse(await c.req.json()))));
12773
+ routes.post("/requests/:id", limitBody(RUNNER_ANSWER_LIMIT_BYTES), async (c) => {
12658
12774
  hub.answer(c.get("runner"), c.req.param("id"), RunnerAnswerSchema.parse(await c.req.json()));
12659
12775
  return c.json({ ok: true });
12660
12776
  });
12661
- routes.post("/workspaces", async (c) => {
12777
+ routes.post("/workspaces", body, async (c) => {
12662
12778
  hub.heldWorkspaces(c.get("runner"), HeldWorkspacesSchema.parse(await c.req.json()).workspaces);
12663
12779
  return c.json({ ok: true });
12664
12780
  });
12665
- routes.post("/warm", async (c) => {
12781
+ routes.post("/warm", body, async (c) => {
12666
12782
  hub.warmChanged(c.get("runner"), WarmSessionsSchema.parse(await c.req.json()).warm);
12667
12783
  return c.json({ ok: true });
12668
12784
  });
12669
12785
  routes.get("/board/manifest", async (c) => c.json({ files: await board2.boardManifest() }));
12670
- routes.post("/board/read", async (c) => c.json({ files: await board2.readBoardFiles(FileReadSchema.parse(await c.req.json()).paths) }));
12786
+ routes.post("/board/read", body, async (c) => c.json({ files: await board2.readBoardFiles(FileReadSchema.parse(await c.req.json()).paths) }));
12671
12787
  const home = new Hono2();
12672
12788
  home.use("*", async (c, next) => {
12673
12789
  const agent = NameSchema.parse(c.req.param("agent"));
@@ -12683,7 +12799,7 @@ function runnerRoutes(board2, hub, enrollments, enrollLimit) {
12683
12799
  const marker = `/homes/${agent}/git`;
12684
12800
  const at = c.req.path.indexOf(marker);
12685
12801
  return homeGit.serve({
12686
- root: path7.dirname(repository),
12802
+ root: path8.dirname(repository),
12687
12803
  agent,
12688
12804
  rest: at === -1 ? "" : c.req.path.slice(at + marker.length),
12689
12805
  request: c.req.raw,
@@ -12696,7 +12812,7 @@ function runnerRoutes(board2, hub, enrollments, enrollLimit) {
12696
12812
 
12697
12813
  // apps/server/dist/static.js
12698
12814
  import { readFile as readFile5, stat as stat4 } from "node:fs/promises";
12699
- import path8 from "node:path";
12815
+ import path9 from "node:path";
12700
12816
  var CONTENT_TYPES = {
12701
12817
  ".html": "text/html; charset=utf-8",
12702
12818
  ".js": "text/javascript; charset=utf-8",
@@ -12724,11 +12840,11 @@ function within2(root, pathname) {
12724
12840
  } catch {
12725
12841
  return null;
12726
12842
  }
12727
- const candidate = path8.resolve(root, `.${decoded}`);
12728
- return candidate === root || candidate.startsWith(root + path8.sep) ? candidate : null;
12843
+ const candidate = path9.resolve(root, `.${decoded}`);
12844
+ return candidate === root || candidate.startsWith(root + path9.sep) ? candidate : null;
12729
12845
  }
12730
12846
  function spaHandler(staticDir) {
12731
- const root = path8.resolve(staticDir);
12847
+ const root = path9.resolve(staticDir);
12732
12848
  return async (c) => {
12733
12849
  const { pathname } = new URL(c.req.url);
12734
12850
  if (pathname.startsWith("/api/")) {
@@ -12739,14 +12855,14 @@ function spaHandler(staticDir) {
12739
12855
  if (file !== null && await isFile(file)) {
12740
12856
  return c.body(await readFile5(file), 200, {
12741
12857
  ...headers,
12742
- "content-type": CONTENT_TYPES[path8.extname(file)] ?? "application/octet-stream",
12858
+ "content-type": CONTENT_TYPES[path9.extname(file)] ?? "application/octet-stream",
12743
12859
  "cache-control": pathname.startsWith("/assets/") ? "public, max-age=31536000, immutable" : "no-cache"
12744
12860
  });
12745
12861
  }
12746
12862
  if (pathname.startsWith("/assets/")) {
12747
12863
  return c.text("not found", 404, headers);
12748
12864
  }
12749
- const index = path8.join(root, "index.html");
12865
+ const index = path9.join(root, "index.html");
12750
12866
  if (!await isFile(index)) {
12751
12867
  return c.text("The interface is not built: run pnpm build:web.", 404);
12752
12868
  }
@@ -12819,6 +12935,7 @@ function createApp(deps) {
12819
12935
  };
12820
12936
  const api = new Hono3();
12821
12937
  api.use("*", authenticate);
12938
+ api.on(["POST", "PUT", "PATCH", "DELETE"], "*", limitBody(API_BODY_LIMIT_BYTES));
12822
12939
  api.get("/me", (c) => {
12823
12940
  const signIn = board2.signInOf(bearer2(c) ?? "");
12824
12941
  return c.json(signIn === null ? c.get("actor") : {
@@ -12912,6 +13029,7 @@ function createApp(deps) {
12912
13029
  });
12913
13030
  api.get("/society/knowledge", async (c) => c.json(await board2.listKnowledge(null)));
12914
13031
  api.get("/skills", async (c) => c.json(await board2.listSocietySkills()));
13032
+ api.get("/skills/:name/files", async (c) => c.json(await board2.readSocietySkillFiles(NameSchema.parse(c.req.param("name")))));
12915
13033
  api.post("/agents/:name/retire", async (c) => {
12916
13034
  const body = RetireBodySchema.parse(await c.req.json());
12917
13035
  const { tokenHash: _hash, ...agent } = await board2.retireAgent(c.get("actor"), {
@@ -12967,14 +13085,23 @@ function createApp(deps) {
12967
13085
  api.get("/projects/:slug/tasks", async (c) => c.json(await board2.listTasks(c.req.param("slug"))));
12968
13086
  api.get("/projects/:slug/dashboard", async (c) => c.json(await board2.readDashboard(c.req.param("slug"))));
12969
13087
  api.get("/projects/:slug/knowledge", async (c) => c.json(await board2.listKnowledge(c.req.param("slug"))));
13088
+ const branchFile = async (c, project, branch, file) => {
13089
+ if (deps.runners === void 0) {
13090
+ throw new BoardError("NOT_FOUND", "this board has no runners to read files from");
13091
+ }
13092
+ return c.json(await deps.runners.readBranch(project, branch, BranchPathSchema.parse(file)));
13093
+ };
13094
+ const projectFile = async (c, file) => {
13095
+ const project = await board2.readProject(NameSchema.parse(c.req.param("slug")));
13096
+ return branchFile(c, project.slug, project.defaultBranch, file);
13097
+ };
13098
+ api.get("/projects/:slug/files", (c) => projectFile(c, ""));
13099
+ api.get("/projects/:slug/files/:path{.+}", (c) => projectFile(c, c.req.param("path")));
12970
13100
  api.get("/tasks/:id", async (c) => c.json((await board2.findTask(c.req.param("id"))).task));
12971
13101
  api.get("/tasks/:id/thread", async (c) => c.json(await board2.listThread(c.req.param("id"))));
12972
13102
  const taskFile = async (c, file) => {
12973
13103
  const { task } = await board2.findTask(UlidSchema.parse(c.req.param("id")));
12974
- if (deps.runners === void 0) {
12975
- throw new BoardError("NOT_FOUND", "this board has no runners to read a task's files from");
12976
- }
12977
- return c.json(await deps.runners.readBranch(task.project, `task/${task.id}`, BranchPathSchema.parse(file)));
13104
+ return branchFile(c, task.project, `task/${task.id}`, file);
12978
13105
  };
12979
13106
  api.get("/tasks/:id/files", (c) => taskFile(c, ""));
12980
13107
  api.get("/tasks/:id/changes", async (c) => {
@@ -13355,7 +13482,10 @@ var TurnHub = class {
13355
13482
  get lastSeq() {
13356
13483
  return this.seq;
13357
13484
  }
13358
- /** Records one event of a turn, in its scope and its conversation there: a thread's, a channel's, or home. */
13485
+ /**
13486
+ * Records one event of a turn, in its scope and its conversation there: a thread's, a channel's,
13487
+ * a project's channels' in the society scope, or home.
13488
+ */
13359
13489
  push(agent, project, event, conversation = {}) {
13360
13490
  this.seq += 1;
13361
13491
  const item = {
@@ -13365,6 +13495,7 @@ var TurnHub = class {
13365
13495
  project,
13366
13496
  ...conversation.thread === void 0 ? {} : { thread: conversation.thread },
13367
13497
  ...conversation.channel === void 0 ? {} : { channel: conversation.channel },
13498
+ ...conversation.place === void 0 ? {} : { place: conversation.place },
13368
13499
  event
13369
13500
  };
13370
13501
  this.buffer.push(item);
@@ -13441,8 +13572,8 @@ var view = {
13441
13572
  return scheduler.activeSignals;
13442
13573
  }
13443
13574
  };
13444
- var webDir = process.env["STELLARIS_WEB_DIR"] ?? path9.resolve(import.meta.dirname, "../../web/dist");
13445
- var webBuilt = await access2(path9.join(webDir, "index.html")).then(() => true, () => false);
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);
13446
13577
  var app = createApp({
13447
13578
  board,
13448
13579
  version: VERSION,