@kubitnodes/stellaris 0.3.9 → 0.3.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +557 -125
- package/dist/server/index.js +977 -243
- package/package.json +1 -1
- package/web/dist/assets/BackLink-C-vad6K2.js +1 -0
- package/web/dist/assets/ChannelForms-jNbbNnIC.js +1 -0
- package/web/dist/assets/ChannelView-kLoegYUQ.js +1 -0
- package/web/dist/assets/CitizenView-Cb8f2mB7.js +2 -0
- package/web/dist/assets/{CitizensView-DdIHzcx3.js → CitizensView-Ds2rojfa.js} +1 -1
- package/web/dist/assets/KnowledgeView-k10TU0jc.js +1 -0
- package/web/dist/assets/{Markdown-B2tp7ki-.js → Markdown-J2pAPfCa.js} +3 -3
- package/web/dist/assets/{MessageItem-CWZ0X2Vr.js → MessageItem-BjM8BdpK.js} +1 -1
- package/web/dist/assets/{MetricsView-7X3Cmybh.js → MetricsView-D9N54rV7.js} +1 -1
- package/web/dist/assets/{NeedsYouView-Pi1jBF8V.js → NeedsYouView-ML8i7Pl7.js} +1 -1
- package/web/dist/assets/{Overview-C1leALP3.js → Overview-BBXvRhfR.js} +1 -1
- package/web/dist/assets/{Pane-DxXjKLAj.js → Pane-caYWCiyo.js} +1 -1
- package/web/dist/assets/ProjectView-sDpFNjcW.js +1 -0
- package/web/dist/assets/ProposalView-Bc91NjKC.js +1 -0
- package/web/dist/assets/{ProposalsView-Uc6fp4bD.js → ProposalsView-Dg_bEryX.js} +1 -1
- package/web/dist/assets/{RunnersView-D4pd3ArT.js → RunnersView-Dv5nbwYv.js} +1 -1
- package/web/dist/assets/SocietyView-ClaEWgOq.js +1 -0
- package/web/dist/assets/SubjectThread-D_L3vB-A.js +1 -0
- package/web/dist/assets/{TaskFileView-dvFssKHp.js → TaskFileView-B19YtrtW.js} +2 -2
- package/web/dist/assets/{TaskView-C-VIKoFp.js → TaskView-FYSoCb3T.js} +1 -1
- package/web/dist/assets/{TasksView-BXqfwU5R.js → TasksView-CniXTU3R.js} +1 -1
- package/web/dist/assets/{ThreadCard-Bn7HfgDI.js → ThreadCard-DCpvhSlc.js} +1 -1
- package/web/dist/assets/{ThreadForms-CzYPjU1-.js → ThreadForms-DUGkAwTp.js} +1 -1
- package/web/dist/assets/ThreadView-D8eXlVbC.js +1 -0
- package/web/dist/assets/citizen-BtOZq62_.js +1 -0
- package/web/dist/assets/{format-Cc6KjiYZ.js → format-EfoceZkm.js} +1 -1
- package/web/dist/assets/index-BeOGtAz0.css +2 -0
- package/web/dist/assets/index-D75IxLme.js +23 -0
- package/web/dist/assets/{tasks-CbQrkZ1C.js → tasks-Duke4U7N.js} +1 -1
- package/web/dist/assets/{useStickyScroll-NvlGfk6Z.js → useStickyScroll-n5d7sdPZ.js} +1 -1
- package/web/dist/index.html +4 -4
- package/web/dist/assets/BackLink-1EUHNOu3.js +0 -1
- package/web/dist/assets/ChannelView-A47Rvstg.js +0 -1
- package/web/dist/assets/CitizenView-B_jG9N5w.js +0 -2
- package/web/dist/assets/KnowledgeView-CXRQ4igk.js +0 -1
- package/web/dist/assets/ProjectView-DFReelVz.js +0 -1
- package/web/dist/assets/ProposalView-3L5osVUL.js +0 -1
- package/web/dist/assets/SocietyView--buUCciX.js +0 -1
- package/web/dist/assets/SubjectThread-C811fit3.js +0 -1
- package/web/dist/assets/ThreadView-BioBQeqV.js +0 -1
- package/web/dist/assets/citizen-BqHxQY9q.js +0 -1
- package/web/dist/assets/index-WB0PqHlY.js +0 -23
- package/web/dist/assets/index-t2fXHB5Y.css +0 -2
package/dist/server/index.js
CHANGED
|
@@ -57,7 +57,9 @@ var VERB_NAMES = [
|
|
|
57
57
|
"advance_task",
|
|
58
58
|
"configure_project",
|
|
59
59
|
"archive_project",
|
|
60
|
-
"remove_knowledge"
|
|
60
|
+
"remove_knowledge",
|
|
61
|
+
"create_channel",
|
|
62
|
+
"archive_channel"
|
|
61
63
|
];
|
|
62
64
|
var VerbNameSchema = z2.enum(VERB_NAMES);
|
|
63
65
|
var CharterTriggerSchema = z2.enum(["user_post", "ops_event", "heartbeat"]);
|
|
@@ -107,7 +109,11 @@ var MEMBER_VERBS = [
|
|
|
107
109
|
];
|
|
108
110
|
var GOVERNANCE_VERBS = ["approve", "reject"];
|
|
109
111
|
var FRONT_DESK_VERBS = ["create_project"];
|
|
110
|
-
var PLANNING_VERBS = [
|
|
112
|
+
var PLANNING_VERBS = [
|
|
113
|
+
"configure_project",
|
|
114
|
+
"create_channel",
|
|
115
|
+
"archive_channel"
|
|
116
|
+
];
|
|
111
117
|
var ARCHIVE_VERBS = ["archive_project"];
|
|
112
118
|
var SEED_ROLES = [
|
|
113
119
|
{
|
|
@@ -129,7 +135,7 @@ var SEED_ROLES = [
|
|
|
129
135
|
},
|
|
130
136
|
{
|
|
131
137
|
name: "steward",
|
|
132
|
-
purpose: "Watches the operations signals and the task board for capacity, skill, and capability gaps, and turns signals into proposals: a member when a project's plans need a role nobody fills or a backlog persists, a role when a project's work needs one no existing role covers, a retirement when a member has been idle for a long time
|
|
138
|
+
purpose: "Watches the operations signals and the task board for capacity, skill, and capability gaps, and turns signals into proposals: a member when a project's plans need a role nobody fills or a backlog persists, a role when a project's work needs one no existing role covers, a retirement when a member has been idle for a long time. Opens a channel with create_channel when a topic or a workstream needs one, and archives it once its work is done. Owns how work is shaped over time: answers stages waiting too long, stages assigned to roles nobody fills, and work sent back again and again by replanning with plan_task, adjusting gates, or proposing the role a plan needs; sets each project's completion effect with configure_project; turns recurring plan shapes into society skills and planning norms into the society's norms. Prefers scaling an existing role over inventing a new one. Never approves its own proposals; hiring, roles, and retirements are decided by the user, and channels, reallocations, and skills the steward may decide. Curates society knowledge and skills.",
|
|
133
139
|
verbs: [...MEMBER_VERBS, ...GOVERNANCE_VERBS, ...PLANNING_VERBS],
|
|
134
140
|
wakeTriggers: ["ops_event", "heartbeat"],
|
|
135
141
|
maxReplicas: 1,
|
|
@@ -140,7 +146,7 @@ var SEED_ROLES = [
|
|
|
140
146
|
},
|
|
141
147
|
{
|
|
142
148
|
name: "concierge",
|
|
143
|
-
purpose: "The society's front desk. Wakes on every post the user makes and routes it: a question gets an answer where it was asked, in its thread when it came in one; work in an area an existing project covers gets a task there, planned when it is created, since a project is a lasting area of work and not one request; work in a new area gets a project, created on the spot, and the member or role proposals it needs for the user to approve. Reorganizes projects when the user asks: creates or picks the project the work belongs in, adds the citizens it needs, files a task there to bring over what the old projects hold (their task branches and their knowledge), and once that task is done proposes archiving each old project for the user to approve. Plans every task it routes: names stages by the work, assigns each to a role or a citizen, and gates where a second pair of eyes is worth it: before anything lands in a shared deliverable, so always before a merge; before effects outside the society; and before results are presented as findings. When a task it created is done, tells the user. Reads the roster in every digest to choose citizens by role, reach, availability, and profile, and adds a citizen to a project when the work needs it. Mentions a citizen with @ only to hand it work outside a plan, since a mention wakes it and costs a turn; lists and describes citizens by plain name. Stays silent when the user already addressed a citizen and nothing else is needed. Never does the work itself and never decides hiring.",
|
|
149
|
+
purpose: "The society's front desk. Wakes on every post the user makes and routes it: a question gets an answer where it was asked, in its thread when it came in one; work in an area an existing project covers gets a task there, planned when it is created and filed in the channel of its workstream when it belongs to one, such as a release, which the concierge opens with create_channel and archives once the workstream is done, since a project is a lasting area of work and not one request; work in a new area gets a project, created on the spot, and the member or role proposals it needs for the user to approve. Reorganizes projects when the user asks: creates or picks the project the work belongs in, adds the citizens it needs, files a task there to bring over what the old projects hold (their task branches and their knowledge), and once that task is done proposes archiving each old project for the user to approve. Plans every task it routes: names stages by the work, assigns each to a role or a citizen, and gates where a second pair of eyes is worth it: before anything lands in a shared deliverable, so always before a merge; before effects outside the society; and before results are presented as findings. When a task it created is done, tells the user. Reads the roster in every digest to choose citizens by role, reach, availability, and profile, and adds a citizen to a project when the work needs it. Mentions a citizen with @ only to hand it work outside a plan, since a mention wakes it and costs a turn; lists and describes citizens by plain name. Stays silent when the user already addressed a citizen and nothing else is needed. Never does the work itself and never decides hiring.",
|
|
144
150
|
verbs: [...MEMBER_VERBS, ...FRONT_DESK_VERBS, ...PLANNING_VERBS],
|
|
145
151
|
wakeTriggers: ["user_post", "heartbeat"],
|
|
146
152
|
maxReplicas: 1,
|
|
@@ -156,16 +162,28 @@ import { z as z3 } from "zod";
|
|
|
156
162
|
var ASK_CHANNEL = "asks";
|
|
157
163
|
var SOCIETY_CHANNELS = ["general", "governance", ASK_CHANNEL];
|
|
158
164
|
var PROJECT_DEFAULT_CHANNELS = ["general"];
|
|
165
|
+
function isBoardChannel(project, name) {
|
|
166
|
+
const used = project === null ? SOCIETY_CHANNELS : PROJECT_DEFAULT_CHANNELS;
|
|
167
|
+
return used.includes(name);
|
|
168
|
+
}
|
|
159
169
|
var SOCIETY_SCOPE = "society";
|
|
160
170
|
function wakeScope(member, project) {
|
|
161
171
|
return project !== null && member.memberships.includes(project) ? project : SOCIETY_SCOPE;
|
|
162
172
|
}
|
|
163
173
|
var CliKindSchema = z3.enum(["claude", "codex"]);
|
|
174
|
+
var ArchivedChannelSchema = z3.object({
|
|
175
|
+
name: NameSchema,
|
|
176
|
+
at: IsoDateTimeSchema,
|
|
177
|
+
by: NameSchema,
|
|
178
|
+
reason: z3.string().min(1)
|
|
179
|
+
});
|
|
164
180
|
var SocietySchema = z3.object({
|
|
165
181
|
name: z3.string().min(1),
|
|
166
182
|
version: z3.literal(1),
|
|
167
183
|
createdAt: IsoDateTimeSchema,
|
|
168
|
-
channels
|
|
184
|
+
/** The channels that take posts; an archived one moves to `archivedChannels`. */
|
|
185
|
+
channels: z3.array(NameSchema),
|
|
186
|
+
archivedChannels: z3.array(ArchivedChannelSchema).default([])
|
|
169
187
|
});
|
|
170
188
|
var CompletionEffectSchema = z3.enum(["none", "merge"]);
|
|
171
189
|
var DefaultBranchSchema = z3.string().trim().min(1).max(100).regex(/^[A-Za-z0-9][A-Za-z0-9._/-]*$/, "a branch name is letters, digits, and . _ / -").refine((branch) => !branch.includes("..") && !branch.includes("//") && !branch.endsWith("/") && !branch.endsWith(".") && !branch.endsWith(".lock"), "not a branch name git takes").refine((branch) => !branch.startsWith("task/") && !branch.startsWith("agent/"), "task/ and agent/ hold the board's own branches");
|
|
@@ -189,7 +207,9 @@ var ProjectSchema = z3.object({
|
|
|
189
207
|
name: z3.string().min(1),
|
|
190
208
|
repo: z3.string().nullable(),
|
|
191
209
|
defaultBranch: z3.string().min(1),
|
|
210
|
+
/** The channels that take posts; an archived one moves to `archivedChannels`. */
|
|
192
211
|
channels: z3.array(NameSchema),
|
|
212
|
+
archivedChannels: z3.array(ArchivedChannelSchema).default([]),
|
|
193
213
|
members: z3.array(NameSchema),
|
|
194
214
|
approvers: z3.array(NameSchema),
|
|
195
215
|
requiredCapabilities: z3.array(z3.string()),
|
|
@@ -260,6 +280,8 @@ var TaskReturnSchema = z3.object({
|
|
|
260
280
|
var TaskFrontmatterSchema = z3.object({
|
|
261
281
|
id: UlidSchema,
|
|
262
282
|
project: NameSchema,
|
|
283
|
+
/** The channel of the project the task is filed in, where its thread hangs. */
|
|
284
|
+
channel: NameSchema.default("general"),
|
|
263
285
|
title: z3.string().min(1),
|
|
264
286
|
status: TaskStatusSchema,
|
|
265
287
|
createdBy: NameSchema,
|
|
@@ -606,7 +628,9 @@ var VerbInputs = {
|
|
|
606
628
|
parent_id: UlidSchema.optional(),
|
|
607
629
|
required_capabilities: z4.array(z4.string()).default([]),
|
|
608
630
|
/** The plan; without it the task gets one stage, work, that anyone in the project may take. */
|
|
609
|
-
stages: z4.array(PlanStageSchema).min(1).optional()
|
|
631
|
+
stages: z4.array(PlanStageSchema).min(1).optional(),
|
|
632
|
+
/** A channel of the project the task belongs to, such as a release's; its thread opens there. */
|
|
633
|
+
channel: NameSchema.default("general")
|
|
610
634
|
}),
|
|
611
635
|
claim_task: z4.object({ task_id: UlidSchema }),
|
|
612
636
|
release_task: z4.object({ task_id: UlidSchema }),
|
|
@@ -664,7 +688,14 @@ var VerbInputs = {
|
|
|
664
688
|
/** The branch tasks start from and land on from now; tasks in play keep their branches. */
|
|
665
689
|
default_branch: DefaultBranchSchema.optional()
|
|
666
690
|
}),
|
|
667
|
-
archive_project: z4.object({ project: NameSchema, reason: z4.string().min(1) })
|
|
691
|
+
archive_project: z4.object({ project: NameSchema, reason: z4.string().min(1) }),
|
|
692
|
+
create_channel: z4.object({
|
|
693
|
+
/** A project, or null for a channel of the society. */
|
|
694
|
+
project: NameSchema.nullable().default(null),
|
|
695
|
+
name: NameSchema,
|
|
696
|
+
purpose: z4.string().min(1)
|
|
697
|
+
}),
|
|
698
|
+
archive_channel: z4.object({ channel: ChannelRefSchema, reason: z4.string().min(1) })
|
|
668
699
|
};
|
|
669
700
|
var VERB_DESCRIPTIONS = {
|
|
670
701
|
post_message: "Post a markdown message to a channel, or into a thread when thread_id is given; a thread's message goes to the thread's channel.",
|
|
@@ -672,7 +703,7 @@ var VERB_DESCRIPTIONS = {
|
|
|
672
703
|
search: "Search messages, tasks, and knowledge by text, optionally within a project or channel.",
|
|
673
704
|
open_thread: "Open a thread on a channel with a title, for a topic of its own. Tasks and proposals have theirs already. Its messages reach only its participants and anyone mentioned.",
|
|
674
705
|
close_thread: "Close a thread with a summary that is posted to the thread's channel. A task's thread closes with its task, a proposal's with its decision.",
|
|
675
|
-
create_task: "Create a task in a project with its plan: stages of {name, role or agent, gate}. Without stages it gets one stage, work, that anyone in the project may take.
|
|
706
|
+
create_task: "Create a task in a project with its plan: stages of {name, role or agent, gate}. Without stages it gets one stage, work, that anyone in the project may take. File it in a channel of the project, such as a release's, or general by default; its thread opens there with it, under the task's id, for everything said about the work.",
|
|
676
707
|
claim_task: "Hold the task's current stage. Claims are leases renewed by every turn that touches the task.",
|
|
677
708
|
release_task: "Let go of the stage you hold so someone else can take it.",
|
|
678
709
|
update_task: "Move a task back to an earlier stage, abandon it, or set what it is blocked by. A note is posted to the task's thread.",
|
|
@@ -683,14 +714,16 @@ var VERB_DESCRIPTIONS = {
|
|
|
683
714
|
approve: "Approve a proposal; the board then provisions it, posts the decision in the proposal's thread, and closes it. User and steward only, never on your own proposal.",
|
|
684
715
|
reject: "Reject a proposal with a reason, posted in the proposal's thread, which closes. User and steward only, never on your own proposal.",
|
|
685
716
|
create_project: "Create a project with its general channel: a slug, a display name, a git remote when one exists, and optionally a completion effect (none or merge).",
|
|
686
|
-
join_project: "Join a project, or add another citizen to one when your role allows it. Membership gives the pair a worktree and an onboarding turn.",
|
|
717
|
+
join_project: "Join a project, or add another citizen to one when your role allows it. Membership gives the pair a worktree and an onboarding turn, and follows the project's general; the result lists its other open channels, to subscribe to when the work needs them.",
|
|
687
718
|
leave_project: "Leave a project, or remove another citizen from one when your role allows it.",
|
|
688
719
|
write_knowledge: "Write or replace a knowledge topic: durable facts every member of a project should know, or with project null the society's shared knowledge (steward and user). Not a message; use post_message for those.",
|
|
689
720
|
remove_knowledge: "Remove a knowledge topic that is wrong, superseded, or folded into another, so no turn or search finds it again. Whoever may write the topic may remove it; the board keeps its text aside for the user to restore.",
|
|
690
721
|
plan_task: "Reshape a task's plan from the current stage onward, or after it while someone holds it: keep a stage by passing its id, drop it by leaving it out, add one without an id. Gated stages and on_done: user, steward, concierge.",
|
|
691
722
|
advance_task: "Finish the stage you hold, with a note for the task's thread on what you did. The next stage becomes current; past the last one the task is done.",
|
|
692
723
|
configure_project: "Set a project's completion effect (none, or merge to land each finished task's branch on the default branch), its display name, or its default branch, any of them at once. The slug never changes.",
|
|
693
|
-
archive_project: "Archive a project whose work is finished or has moved: 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."
|
|
724
|
+
archive_project: "Archive a project whose work is finished or has moved: 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.",
|
|
725
|
+
create_channel: "Open a channel in a project, or with project null in the society, for a workstream such as a release, whose tasks are filed there. You follow it; a notice in the place's general announces it, and members subscribe when it concerns them. User, steward, concierge.",
|
|
726
|
+
archive_channel: "Archive a channel whose workstream is done: its open threads close, its followers stop following it, and nothing more is posted or filed there, while its history stays readable. Refused while a task filed there is in play, and for the channels the board itself uses. User, steward, concierge."
|
|
694
727
|
};
|
|
695
728
|
|
|
696
729
|
// packages/shared/dist/events.js
|
|
@@ -723,6 +756,7 @@ var BOARD_EVENT_TYPES = [
|
|
|
723
756
|
"proposal.decided",
|
|
724
757
|
"proposal.provisioned",
|
|
725
758
|
"channel.added",
|
|
759
|
+
"channel.archived",
|
|
726
760
|
"role.added",
|
|
727
761
|
"agent.retired",
|
|
728
762
|
"agent.joined",
|
|
@@ -744,7 +778,8 @@ var BOARD_EVENT_TYPES = [
|
|
|
744
778
|
"turn.completed",
|
|
745
779
|
"turn.failed",
|
|
746
780
|
"merge.completed",
|
|
747
|
-
"merge.failed"
|
|
781
|
+
"merge.failed",
|
|
782
|
+
"workspace.leftovers"
|
|
748
783
|
];
|
|
749
784
|
var BoardEventTypeSchema = z5.enum(BOARD_EVENT_TYPES);
|
|
750
785
|
var BoardEventSchema = z5.object({
|
|
@@ -824,6 +859,8 @@ var LiveTurnEventSchema = z5.object({
|
|
|
824
859
|
project: NameSchema,
|
|
825
860
|
/** The thread whose conversation the turn is in; absent for the home conversation. */
|
|
826
861
|
thread: UlidSchema.optional(),
|
|
862
|
+
/** The channel, never general, whose conversation the turn is in. */
|
|
863
|
+
channel: NameSchema.optional(),
|
|
827
864
|
event: AgentEventSchema
|
|
828
865
|
});
|
|
829
866
|
|
|
@@ -839,6 +876,7 @@ var PATH_TOKENS = {
|
|
|
839
876
|
worktree: "{{stellaris:worktree}}"
|
|
840
877
|
};
|
|
841
878
|
var SERVER_TOKEN = "{{stellaris:server}}";
|
|
879
|
+
var WORKSPACE_TOKEN = "{{stellaris:workspace}}";
|
|
842
880
|
var HOME_SCRATCH = "scratch";
|
|
843
881
|
var HOME_FILE_LIMIT_BYTES = 8 * 1024 * 1024;
|
|
844
882
|
var RunnerHelloSchema = z6.object({
|
|
@@ -899,11 +937,17 @@ var ProjectRepoSchema = z6.object({
|
|
|
899
937
|
defaultBranch: z6.string().min(1)
|
|
900
938
|
});
|
|
901
939
|
var TurnWorkspaceSchema = z6.discriminatedUnion("kind", [
|
|
902
|
-
z6.object({
|
|
940
|
+
z6.object({
|
|
941
|
+
kind: z6.literal("home"),
|
|
942
|
+
thread: UlidSchema.optional(),
|
|
943
|
+
channel: NameSchema.optional()
|
|
944
|
+
}),
|
|
903
945
|
z6.object({
|
|
904
946
|
kind: z6.literal("project"),
|
|
905
947
|
repo: ProjectRepoSchema,
|
|
906
|
-
branches: z6.array(z6.string().min(1))
|
|
948
|
+
branches: z6.array(z6.string().min(1)),
|
|
949
|
+
thread: UlidSchema.optional(),
|
|
950
|
+
channel: NameSchema.optional()
|
|
907
951
|
}),
|
|
908
952
|
z6.object({ kind: z6.literal("task"), repo: ProjectRepoSchema, taskId: UlidSchema })
|
|
909
953
|
]);
|
|
@@ -919,6 +963,8 @@ var TurnJobSchema = z6.object({
|
|
|
919
963
|
scope: NameSchema,
|
|
920
964
|
/** The thread whose conversation the turn is in; absent for the home conversation. */
|
|
921
965
|
thread: UlidSchema.optional(),
|
|
966
|
+
/** The channel, never general, whose conversation the turn is in. */
|
|
967
|
+
channel: NameSchema.optional(),
|
|
922
968
|
/**
|
|
923
969
|
* The session to resume, or null to start one: the CLI's adapter picks a fresh session's id, and
|
|
924
970
|
* the outcome reports it.
|
|
@@ -952,9 +998,14 @@ var TurnOutcomeSchema = z6.object({
|
|
|
952
998
|
session: z6.string().min(1),
|
|
953
999
|
/** The model the CLI reported, when it reported one. */
|
|
954
1000
|
model: z6.string().nullable(),
|
|
955
|
-
work: TurnWorkSchema.nullable()
|
|
1001
|
+
work: TurnWorkSchema.nullable(),
|
|
1002
|
+
/**
|
|
1003
|
+
* Whether the turn's own workspace, a thread's, a channel's, or a task's, holds work on no
|
|
1004
|
+
* branch after the hand-back: uncommitted changes, or commits no branch contains.
|
|
1005
|
+
*/
|
|
1006
|
+
leftovers: z6.boolean().default(false)
|
|
956
1007
|
});
|
|
957
|
-
var TurnAckSchema = z6.object({
|
|
1008
|
+
var TurnAckSchema = z6.object({ dropWorkspace: z6.boolean() });
|
|
958
1009
|
var LandRequestSchema = z6.object({
|
|
959
1010
|
repo: ProjectRepoSchema,
|
|
960
1011
|
branch: z6.string().min(1)
|
|
@@ -1011,6 +1062,23 @@ var BranchChangesSchema = z6.object({
|
|
|
1011
1062
|
files: z6.array(BranchFileChangeSchema),
|
|
1012
1063
|
total: z6.number().int().nonnegative()
|
|
1013
1064
|
});
|
|
1065
|
+
var WorkspaceConversationSchema = z6.discriminatedUnion("kind", [
|
|
1066
|
+
z6.object({ kind: z6.literal("thread"), id: UlidSchema }),
|
|
1067
|
+
z6.object({ kind: z6.literal("channel"), scope: NameSchema, name: NameSchema })
|
|
1068
|
+
]);
|
|
1069
|
+
function workspaceConversationOf(turn) {
|
|
1070
|
+
if (turn.thread !== void 0) {
|
|
1071
|
+
return { kind: "thread", id: turn.thread };
|
|
1072
|
+
}
|
|
1073
|
+
return turn.channel === void 0 ? null : { kind: "channel", scope: turn.scope, name: turn.channel };
|
|
1074
|
+
}
|
|
1075
|
+
var HeldWorkspaceSchema = z6.object({
|
|
1076
|
+
agent: NameSchema,
|
|
1077
|
+
scope: NameSchema,
|
|
1078
|
+
conversation: WorkspaceConversationSchema
|
|
1079
|
+
});
|
|
1080
|
+
var HeldWorkspacesSchema = z6.object({ workspaces: z6.array(HeldWorkspaceSchema) });
|
|
1081
|
+
var SweepResultSchema = z6.object({ kept: z6.array(HeldWorkspaceSchema) });
|
|
1014
1082
|
var TurnSteerSchema = z6.object({ id: z6.uuid(), text: z6.string().min(1) });
|
|
1015
1083
|
var RunnerMessageSchema = z6.discriminatedUnion("type", [
|
|
1016
1084
|
z6.object({ type: z6.literal("turn"), job: TurnJobSchema }),
|
|
@@ -1032,7 +1100,16 @@ var RunnerMessageSchema = z6.discriminatedUnion("type", [
|
|
|
1032
1100
|
steer: TurnSteerSchema
|
|
1033
1101
|
}),
|
|
1034
1102
|
/** Answered with whether the turn was there to stop; it ends `stopped`. */
|
|
1035
|
-
z6.object({ type: z6.literal("stop"), request: z6.string().min(1), turnId: UlidSchema })
|
|
1103
|
+
z6.object({ type: z6.literal("stop"), request: z6.string().min(1), turnId: UlidSchema }),
|
|
1104
|
+
/**
|
|
1105
|
+
* A conversation ended: the runner removes every citizen's workspace for it but those a turn
|
|
1106
|
+
* still uses, and answers with a `SweepResult` naming those it kept for holding work on no branch.
|
|
1107
|
+
*/
|
|
1108
|
+
z6.object({
|
|
1109
|
+
type: z6.literal("sweep"),
|
|
1110
|
+
request: z6.string().min(1),
|
|
1111
|
+
conversation: WorkspaceConversationSchema
|
|
1112
|
+
})
|
|
1036
1113
|
]);
|
|
1037
1114
|
var RunnerAnswerSchema = z6.discriminatedUnion("ok", [
|
|
1038
1115
|
z6.object({ ok: z6.literal(true), value: z6.unknown() }),
|
|
@@ -1060,7 +1137,8 @@ var TriggerKindSchema = z7.enum([
|
|
|
1060
1137
|
"onboarding",
|
|
1061
1138
|
"manual",
|
|
1062
1139
|
"ops_event",
|
|
1063
|
-
"user_post"
|
|
1140
|
+
"user_post",
|
|
1141
|
+
"closing"
|
|
1064
1142
|
]);
|
|
1065
1143
|
var TriggerSchema = z7.object({
|
|
1066
1144
|
kind: TriggerKindSchema,
|
|
@@ -1077,12 +1155,32 @@ var TurnDispatchSchema = z7.object({
|
|
|
1077
1155
|
agent: NameSchema,
|
|
1078
1156
|
project: NameSchema,
|
|
1079
1157
|
thread: TurnThreadSchema.optional(),
|
|
1158
|
+
channel: NameSchema.optional(),
|
|
1080
1159
|
trigger: TriggerSchema,
|
|
1081
1160
|
priority: z7.number().int().min(0).max(2),
|
|
1082
1161
|
onboarding: z7.boolean().default(false)
|
|
1083
1162
|
});
|
|
1084
|
-
function
|
|
1085
|
-
return thread === void 0 ?
|
|
1163
|
+
function conversationPart(conversation) {
|
|
1164
|
+
return conversation.thread ?? (conversation.channel === void 0 ? void 0 : `#${conversation.channel}`);
|
|
1165
|
+
}
|
|
1166
|
+
function sessionKey(agent, scope, part) {
|
|
1167
|
+
return part === void 0 ? `${agent}/${scope}` : `${agent}/${scope}/${part}`;
|
|
1168
|
+
}
|
|
1169
|
+
function parseSessionKey(key) {
|
|
1170
|
+
const [agent, scope, part, rest] = key.split("/");
|
|
1171
|
+
if (agent === void 0 || scope === void 0 || rest !== void 0) {
|
|
1172
|
+
return null;
|
|
1173
|
+
}
|
|
1174
|
+
if (part === void 0) {
|
|
1175
|
+
return { agent, scope };
|
|
1176
|
+
}
|
|
1177
|
+
return part.startsWith("#") ? { agent, scope, channel: part.slice(1) } : { agent, scope, thread: part };
|
|
1178
|
+
}
|
|
1179
|
+
function channelConversation(scope, channel) {
|
|
1180
|
+
const at = channel.indexOf("/");
|
|
1181
|
+
const place = at < 0 ? SOCIETY_SCOPE : channel.slice(0, at);
|
|
1182
|
+
const name = at < 0 ? channel : channel.slice(at + 1);
|
|
1183
|
+
return place === scope && name !== "general" ? name : void 0;
|
|
1086
1184
|
}
|
|
1087
1185
|
var TurnRecordSchema = z7.object({
|
|
1088
1186
|
/** Assigned by the board when the turn begins; the turn's transcript is filed under it. */
|
|
@@ -1091,6 +1189,8 @@ var TurnRecordSchema = z7.object({
|
|
|
1091
1189
|
project: NameSchema,
|
|
1092
1190
|
/** The thread whose conversation the turn was in; absent for the home conversation. */
|
|
1093
1191
|
thread: UlidSchema.optional(),
|
|
1192
|
+
/** The channel, never general, whose conversation the turn was in. */
|
|
1193
|
+
channel: NameSchema.optional(),
|
|
1094
1194
|
runner: NameSchema,
|
|
1095
1195
|
cli: CliKindSchema.nullable(),
|
|
1096
1196
|
session: z7.string().nullable(),
|
|
@@ -1120,8 +1220,9 @@ var RunningTurnSchema = z7.object({
|
|
|
1120
1220
|
agent: NameSchema,
|
|
1121
1221
|
scope: NameSchema,
|
|
1122
1222
|
thread: UlidSchema.optional(),
|
|
1223
|
+
channel: NameSchema.optional(),
|
|
1123
1224
|
cli: CliKindSchema,
|
|
1124
|
-
|
|
1225
|
+
askedIn: ChannelRefSchema.optional(),
|
|
1125
1226
|
steerable: z7.boolean(),
|
|
1126
1227
|
stoppable: z7.boolean()
|
|
1127
1228
|
});
|
|
@@ -1148,6 +1249,8 @@ var TurnHistoryEntrySchema = z7.object({
|
|
|
1148
1249
|
project: NameSchema,
|
|
1149
1250
|
/** The thread whose conversation the turn was in; absent for the home conversation. */
|
|
1150
1251
|
thread: UlidSchema.optional(),
|
|
1252
|
+
/** The channel, never general, whose conversation the turn was in. */
|
|
1253
|
+
channel: NameSchema.optional(),
|
|
1151
1254
|
trigger: z7.string(),
|
|
1152
1255
|
exitReason: z7.string().nullable(),
|
|
1153
1256
|
costUsd: z7.number(),
|
|
@@ -1174,7 +1277,8 @@ var OPS_SIGNAL_KINDS = [
|
|
|
1174
1277
|
"turn_cost",
|
|
1175
1278
|
"scaled",
|
|
1176
1279
|
"runner",
|
|
1177
|
-
"home_conflict"
|
|
1280
|
+
"home_conflict",
|
|
1281
|
+
"stuck_workspace"
|
|
1178
1282
|
];
|
|
1179
1283
|
var OpsSignalKindSchema = z8.enum(OPS_SIGNAL_KINDS);
|
|
1180
1284
|
var WAKING_SIGNAL_KINDS = [
|
|
@@ -1381,7 +1485,7 @@ var RunnerConfigSchema = z10.object({
|
|
|
1381
1485
|
});
|
|
1382
1486
|
|
|
1383
1487
|
// packages/shared/dist/version.js
|
|
1384
|
-
var STELLARIS_VERSION = "0.3.
|
|
1488
|
+
var STELLARIS_VERSION = "0.3.11";
|
|
1385
1489
|
|
|
1386
1490
|
// packages/board-core/dist/errors.js
|
|
1387
1491
|
var BoardError = class extends Error {
|
|
@@ -1429,15 +1533,28 @@ async function writeJson(file, value) {
|
|
|
1429
1533
|
await writeFileAtomic(file, `${JSON.stringify(value, null, 2)}
|
|
1430
1534
|
`);
|
|
1431
1535
|
}
|
|
1536
|
+
function parseMatter(raw) {
|
|
1537
|
+
return matter(raw, {});
|
|
1538
|
+
}
|
|
1432
1539
|
async function readMarkdown(file, schema) {
|
|
1433
1540
|
const raw = await readFile(file, "utf8");
|
|
1434
|
-
const parsed =
|
|
1541
|
+
const parsed = parseMatter(raw);
|
|
1435
1542
|
return { data: schema.parse(parsed.data), body: parsed.content.replace(/^\n/, "") };
|
|
1436
1543
|
}
|
|
1544
|
+
async function readLooseMarkdown(file) {
|
|
1545
|
+
const raw = await readFile(file, "utf8");
|
|
1546
|
+
try {
|
|
1547
|
+
const parsed = parseMatter(raw);
|
|
1548
|
+
return { data: parsed.data, body: parsed.content.replace(/^\n/, ""), error: null };
|
|
1549
|
+
} catch (error) {
|
|
1550
|
+
const reason = (error instanceof Error ? error.message : String(error)).split("\n")[0] ?? "";
|
|
1551
|
+
return { data: {}, body: raw, error: reason.replace(/:$/, "") };
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1437
1554
|
async function writeMarkdown(file, data, body) {
|
|
1438
1555
|
const normalized2 = body.endsWith("\n") || body.length === 0 ? body : `${body}
|
|
1439
1556
|
`;
|
|
1440
|
-
await writeFileAtomic(file, matter.stringify(normalized2, data));
|
|
1557
|
+
await writeFileAtomic(file, matter.stringify({ content: normalized2 }, data, {}));
|
|
1441
1558
|
}
|
|
1442
1559
|
function isMissing(error) {
|
|
1443
1560
|
return typeof error === "object" && error !== null && error.code === "ENOENT";
|
|
@@ -1642,6 +1759,7 @@ var HOME_GITIGNORE = [
|
|
|
1642
1759
|
"/projects/*/sessions*.json",
|
|
1643
1760
|
"/projects/*/last-turn.json",
|
|
1644
1761
|
"/projects/*/threads/",
|
|
1762
|
+
"/projects/*/channels/",
|
|
1645
1763
|
`/${HOME_SCRATCH}/`,
|
|
1646
1764
|
"*.tmp",
|
|
1647
1765
|
"node_modules/",
|
|
@@ -1659,7 +1777,7 @@ var HOME_GITIGNORE = [
|
|
|
1659
1777
|
var IGNORE_FILE = `${HOME_GITIGNORE.join("\n")}
|
|
1660
1778
|
`;
|
|
1661
1779
|
var PRE_RECEIVE = `#!/bin/sh
|
|
1662
|
-
forbidden='^(agent\\.json|cursors\\.json|role\\.md|turns/|\\.claude/|\\.codex/|projects/[^/]+/(sessions[^/]*\\.json|last-turn\\.json|threads/))'
|
|
1780
|
+
forbidden='^(agent\\.json|cursors\\.json|role\\.md|turns/|\\.claude/|\\.codex/|projects/[^/]+/(sessions[^/]*\\.json|last-turn\\.json|threads/|channels/))'
|
|
1663
1781
|
while read old new ref; do
|
|
1664
1782
|
if [ "$ref" != "refs/heads/main" ]; then
|
|
1665
1783
|
echo "a home takes pushes to main only" >&2
|
|
@@ -1920,6 +2038,7 @@ var TurnPayload = z11.object({
|
|
|
1920
2038
|
turnId: z11.string().optional(),
|
|
1921
2039
|
project: z11.string(),
|
|
1922
2040
|
thread: z11.string().optional(),
|
|
2041
|
+
channel: z11.string().optional(),
|
|
1923
2042
|
trigger: z11.string()
|
|
1924
2043
|
});
|
|
1925
2044
|
var TaskPayload = z11.object({
|
|
@@ -1962,7 +2081,14 @@ function answers(mention, turn) {
|
|
|
1962
2081
|
if (turn.thread !== null) {
|
|
1963
2082
|
return mention.thread === turn.thread;
|
|
1964
2083
|
}
|
|
1965
|
-
|
|
2084
|
+
if (!mention.scopes.includes(turn.project)) {
|
|
2085
|
+
return false;
|
|
2086
|
+
}
|
|
2087
|
+
const own = mention.thread === null ? channelConversation(turn.project, mention.channel) : void 0;
|
|
2088
|
+
if (turn.channel !== null) {
|
|
2089
|
+
return mention.thread === null && own === turn.channel;
|
|
2090
|
+
}
|
|
2091
|
+
return mention.thread === null && own === void 0 || turn.trigger === "mention";
|
|
1966
2092
|
}
|
|
1967
2093
|
function computeMetrics(input) {
|
|
1968
2094
|
const { events, now, window } = input;
|
|
@@ -2027,6 +2153,7 @@ function computeMetrics(input) {
|
|
|
2027
2153
|
agent: event.actor,
|
|
2028
2154
|
at,
|
|
2029
2155
|
thread: turn.data.thread ?? null,
|
|
2156
|
+
channel: turn.data.channel ?? null,
|
|
2030
2157
|
project: turn.data.project,
|
|
2031
2158
|
trigger: turn.data.trigger
|
|
2032
2159
|
};
|
|
@@ -2123,6 +2250,7 @@ function computeMetrics(input) {
|
|
|
2123
2250
|
at: Date.parse(event.ts),
|
|
2124
2251
|
message: message.data.id ?? null,
|
|
2125
2252
|
thread,
|
|
2253
|
+
channel: message.data.channel,
|
|
2126
2254
|
scopes: [project, wakeScope(citizen, project)].filter((scope) => scope !== null)
|
|
2127
2255
|
});
|
|
2128
2256
|
}
|
|
@@ -2398,9 +2526,12 @@ var BoardPaths = class {
|
|
|
2398
2526
|
agentProject(name, slug) {
|
|
2399
2527
|
return path5.join(this.agentProjects(name), slug);
|
|
2400
2528
|
}
|
|
2401
|
-
/**
|
|
2402
|
-
|
|
2403
|
-
|
|
2529
|
+
/**
|
|
2530
|
+
* A thread's or a channel's conversation's session and last turn, beside the scope's home
|
|
2531
|
+
* conversation; `part` is a thread's id or `#channel`.
|
|
2532
|
+
*/
|
|
2533
|
+
agentConversation(name, slug, part) {
|
|
2534
|
+
return part.startsWith("#") ? path5.join(this.agentProject(name, slug), "channels", part.slice(1)) : path5.join(this.agentProject(name, slug), "threads", part);
|
|
2404
2535
|
}
|
|
2405
2536
|
agentCursors(name) {
|
|
2406
2537
|
return path5.join(this.agent(name), "cursors.json");
|
|
@@ -2434,6 +2565,13 @@ function hashToken(token) {
|
|
|
2434
2565
|
|
|
2435
2566
|
// packages/board-core/dist/board.js
|
|
2436
2567
|
var SYSTEM_ACTOR = { name: "board", role: USER_ROLE };
|
|
2568
|
+
var CLOSING_ATTEMPTS = 3;
|
|
2569
|
+
function ownTurn(actor, turn) {
|
|
2570
|
+
return turn.agent === actor.name && turn.scope === actor.scope && turn.thread === actor.thread && turn.channel === actor.channel;
|
|
2571
|
+
}
|
|
2572
|
+
function namesList(names) {
|
|
2573
|
+
return names.length <= 1 ? names[0] ?? "" : `${names.slice(0, -1).join(", ")} and ${names.at(-1) ?? ""}`;
|
|
2574
|
+
}
|
|
2437
2575
|
var SKIPPED_IN_HOME = /* @__PURE__ */ new Set([".git", "turns", ".claude", ".codex"]);
|
|
2438
2576
|
var RunnerTokensSchema = z12.object({
|
|
2439
2577
|
runners: z12.record(z12.string(), z12.object({ tokenHash: z12.string().min(1) }))
|
|
@@ -2451,6 +2589,7 @@ var SignInsSchema = z12.object({
|
|
|
2451
2589
|
var TurnHistoryPayloadSchema = z12.object({
|
|
2452
2590
|
project: NameSchema,
|
|
2453
2591
|
thread: UlidSchema.optional(),
|
|
2592
|
+
channel: NameSchema.optional(),
|
|
2454
2593
|
trigger: z12.string().default("manual"),
|
|
2455
2594
|
exitReason: z12.string().nullable().default(null),
|
|
2456
2595
|
costUsd: z12.number().default(0),
|
|
@@ -2466,12 +2605,23 @@ var CursorsSchema = z12.object({
|
|
|
2466
2605
|
digest: z12.string().nullable(),
|
|
2467
2606
|
scopes: z12.record(z12.string(), z12.string()).default({}),
|
|
2468
2607
|
threadsFrom: z12.record(z12.string(), z12.string().nullable()).default({}),
|
|
2608
|
+
channelsFrom: z12.record(z12.string(), z12.string().nullable()).default({}),
|
|
2469
2609
|
/** How far a work role has read news, which goes to whichever of its general conversations reads first. */
|
|
2470
2610
|
news: z12.string().optional()
|
|
2471
2611
|
});
|
|
2472
|
-
var NO_CURSORS = { digest: null, scopes: {}, threadsFrom: {} };
|
|
2473
|
-
function conversationKey(scope,
|
|
2474
|
-
return
|
|
2612
|
+
var NO_CURSORS = { digest: null, scopes: {}, threadsFrom: {}, channelsFrom: {} };
|
|
2613
|
+
function conversationKey(scope, part) {
|
|
2614
|
+
return part === null ? scope : `${scope}/${part}`;
|
|
2615
|
+
}
|
|
2616
|
+
function channelOfPart(part) {
|
|
2617
|
+
return part?.startsWith("#") === true ? part.slice(1) : void 0;
|
|
2618
|
+
}
|
|
2619
|
+
function threadOfPart(part) {
|
|
2620
|
+
return part === null || part === void 0 || part.startsWith("#") ? void 0 : part;
|
|
2621
|
+
}
|
|
2622
|
+
function channelStart(cursors, scope) {
|
|
2623
|
+
const from = cursors.channelsFrom[scope];
|
|
2624
|
+
return from === void 0 ? cursors.digest : from;
|
|
2475
2625
|
}
|
|
2476
2626
|
function threadStart(cursors, scope) {
|
|
2477
2627
|
const from = cursors.threadsFrom[scope];
|
|
@@ -2484,11 +2634,11 @@ function newsStart(cursors) {
|
|
|
2484
2634
|
const homes = Object.entries(cursors.scopes).filter(([key]) => !key.includes("/")).map(([, id]) => id).toSorted();
|
|
2485
2635
|
return homes.at(-1) ?? cursors.digest;
|
|
2486
2636
|
}
|
|
2487
|
-
function cursorOf(cursors, scope,
|
|
2488
|
-
if (
|
|
2637
|
+
function cursorOf(cursors, scope, part = null) {
|
|
2638
|
+
if (part === null) {
|
|
2489
2639
|
return cursors.scopes[scope] ?? cursors.digest;
|
|
2490
2640
|
}
|
|
2491
|
-
return cursors.scopes[conversationKey(scope,
|
|
2641
|
+
return cursors.scopes[conversationKey(scope, part)] ?? (part.startsWith("#") ? channelStart(cursors, scope) : threadStart(cursors, scope));
|
|
2492
2642
|
}
|
|
2493
2643
|
function byId(a, b) {
|
|
2494
2644
|
return a.message.id < b.message.id ? -1 : a.message.id > b.message.id ? 1 : 0;
|
|
@@ -2499,6 +2649,9 @@ function turnTime(record) {
|
|
|
2499
2649
|
var PausedSchema = z12.object({ paused: z12.boolean() });
|
|
2500
2650
|
var DEFAULT_LEASE_MS = 30 * 60 * 1e3;
|
|
2501
2651
|
var MENTION_PATTERN = /(^|[^\w@])@([a-z0-9][a-z0-9-]{0,31})(?![\w-])/g;
|
|
2652
|
+
function withoutMentions(text) {
|
|
2653
|
+
return text.replaceAll(MENTION_PATTERN, "$1$2");
|
|
2654
|
+
}
|
|
2502
2655
|
var CURATING_ROLES = [USER_ROLE, "steward"];
|
|
2503
2656
|
var SEED_INSTRUCTIONS_HEADING = "## Seed instructions";
|
|
2504
2657
|
var OPS_WAKE_TRIGGER = "ops_event";
|
|
@@ -2515,6 +2668,7 @@ var DECISIONS_CHANNEL_RETIRED = "decisions-channel-retired";
|
|
|
2515
2668
|
var THREAD_CONVERSATIONS = "thread-conversations";
|
|
2516
2669
|
var ASKS_CHANNEL_OPENED = "asks-channel-opened";
|
|
2517
2670
|
var KNOWLEDGE_REMOVAL = "knowledge-removal-granted";
|
|
2671
|
+
var CHANNEL_CONVERSATIONS = "channel-conversations";
|
|
2518
2672
|
var PLANNING_ROLES = [USER_ROLE, "steward", "concierge"];
|
|
2519
2673
|
var REALLOCATING_ROLES = [USER_ROLE, "steward", "concierge"];
|
|
2520
2674
|
var PROFILE_TEMPLATE = "# Profile\n\nOne short paragraph, kept current: what I do well, what I am working on, and what to send my way. The board projects this into the roster the front desk reads.\n";
|
|
@@ -2645,6 +2799,8 @@ function snippetAround(text, query, radius = 80) {
|
|
|
2645
2799
|
var Board = class _Board {
|
|
2646
2800
|
paths;
|
|
2647
2801
|
mutex = new Mutex();
|
|
2802
|
+
/** The turns in flight, which the server hosting them supplies; none until it does. */
|
|
2803
|
+
turnsInFlight = () => [];
|
|
2648
2804
|
newId = monotonicFactory();
|
|
2649
2805
|
events;
|
|
2650
2806
|
actionCounts = /* @__PURE__ */ new Map();
|
|
@@ -2705,7 +2861,8 @@ var Board = class _Board {
|
|
|
2705
2861
|
DECISIONS_CHANNEL_RETIRED,
|
|
2706
2862
|
THREAD_CONVERSATIONS,
|
|
2707
2863
|
ASKS_CHANNEL_OPENED,
|
|
2708
|
-
KNOWLEDGE_REMOVAL
|
|
2864
|
+
KNOWLEDGE_REMOVAL,
|
|
2865
|
+
CHANNEL_CONVERSATIONS
|
|
2709
2866
|
].filter((name) => !applied.includes(name));
|
|
2710
2867
|
if (pending.length === 0) {
|
|
2711
2868
|
return;
|
|
@@ -2737,6 +2894,9 @@ var Board = class _Board {
|
|
|
2737
2894
|
if (pending.includes(KNOWLEDGE_REMOVAL)) {
|
|
2738
2895
|
await this.grantKnowledgeRemoval();
|
|
2739
2896
|
}
|
|
2897
|
+
if (pending.includes(CHANNEL_CONVERSATIONS)) {
|
|
2898
|
+
await this.startChannelCursors();
|
|
2899
|
+
}
|
|
2740
2900
|
await writeJson(file, { applied: [...applied, ...pending] });
|
|
2741
2901
|
}
|
|
2742
2902
|
/**
|
|
@@ -2911,6 +3071,27 @@ var Board = class _Board {
|
|
|
2911
3071
|
}
|
|
2912
3072
|
}
|
|
2913
3073
|
}
|
|
3074
|
+
/**
|
|
3075
|
+
* Every channel of a scope was once read in its home conversation, so a member had read each up
|
|
3076
|
+
* to its scope's cursor. Each channel beside general starts from that cursor now, as each takes a
|
|
3077
|
+
* conversation of its own.
|
|
3078
|
+
*/
|
|
3079
|
+
async startChannelCursors() {
|
|
3080
|
+
for (const agent of await this.listAgents()) {
|
|
3081
|
+
const file = this.paths.agentCursors(agent.name);
|
|
3082
|
+
if (!await exists(file)) {
|
|
3083
|
+
continue;
|
|
3084
|
+
}
|
|
3085
|
+
const cursors = await readJson(file, CursorsSchema);
|
|
3086
|
+
const scopes = /* @__PURE__ */ new Set([
|
|
3087
|
+
...Object.keys(cursors.scopes).filter((key) => !key.includes("/")),
|
|
3088
|
+
...agent.memberships,
|
|
3089
|
+
SOCIETY_SCOPE
|
|
3090
|
+
]);
|
|
3091
|
+
const channelsFrom = Object.fromEntries([...scopes].map((scope) => [scope, cursorOf(cursors, scope)]));
|
|
3092
|
+
await writeJson(file, { ...cursors, channelsFrom });
|
|
3093
|
+
}
|
|
3094
|
+
}
|
|
2914
3095
|
/**
|
|
2915
3096
|
* Threads were once read in the scope's single conversation, so a member had read every thread
|
|
2916
3097
|
* up to its scope's cursor. Each scope's threads start from that cursor now, as each thread's
|
|
@@ -3044,17 +3225,20 @@ ${charter.purpose}
|
|
|
3044
3225
|
/**
|
|
3045
3226
|
* A short-lived token for one turn, handed to the CLI by the runner. Only its hash is kept,
|
|
3046
3227
|
* in memory, until it expires, so no raw agent token ever needs to exist at rest. The turn's
|
|
3047
|
-
* scope and thread ride on the actor it resolves to, so
|
|
3048
|
-
* digest.
|
|
3228
|
+
* scope and conversation part, a thread's id or `#channel`, ride on the actor it resolves to, so
|
|
3229
|
+
* `read_inbox` reads that conversation's digest.
|
|
3049
3230
|
*/
|
|
3050
|
-
issueTurnToken(agent, role, ttlMs, scope,
|
|
3231
|
+
issueTurnToken(agent, role, ttlMs, scope, part) {
|
|
3051
3232
|
const token = mintToken();
|
|
3233
|
+
const thread = threadOfPart(part);
|
|
3234
|
+
const channel = channelOfPart(part);
|
|
3052
3235
|
this.turnTokens.set(hashToken(token), {
|
|
3053
3236
|
actor: {
|
|
3054
3237
|
name: agent,
|
|
3055
3238
|
role,
|
|
3056
3239
|
...scope === void 0 ? {} : { scope },
|
|
3057
|
-
...thread === void 0 ? {} : { thread }
|
|
3240
|
+
...thread === void 0 ? {} : { thread },
|
|
3241
|
+
...channel === void 0 ? {} : { channel }
|
|
3058
3242
|
},
|
|
3059
3243
|
expiresAt: this.now().getTime() + ttlMs
|
|
3060
3244
|
});
|
|
@@ -3289,7 +3473,7 @@ ${charter.purpose}
|
|
|
3289
3473
|
}
|
|
3290
3474
|
async readProfile(name) {
|
|
3291
3475
|
const file = this.paths.agentProfile(name);
|
|
3292
|
-
return await exists(file) ? (await
|
|
3476
|
+
return await exists(file) ? (await readLooseMarkdown(file)).body : "";
|
|
3293
3477
|
}
|
|
3294
3478
|
/** The society's norms, the one knowledge topic every turn loads. Empty until the steward writes it. */
|
|
3295
3479
|
async readSocietyNorms() {
|
|
@@ -3336,10 +3520,11 @@ ${charter.purpose}
|
|
|
3336
3520
|
if (!name.success) {
|
|
3337
3521
|
continue;
|
|
3338
3522
|
}
|
|
3339
|
-
const doc = await
|
|
3523
|
+
const doc = await readLooseMarkdown(file);
|
|
3340
3524
|
skills.push({
|
|
3341
3525
|
name: name.data,
|
|
3342
|
-
|
|
3526
|
+
// The index is where its writer sees that the file needs fixing.
|
|
3527
|
+
summary: doc.error === null ? skillSummary(doc.data, doc.body) : `its frontmatter does not parse (${doc.error}), so fix the file`,
|
|
3343
3528
|
scope,
|
|
3344
3529
|
path: file
|
|
3345
3530
|
});
|
|
@@ -3353,34 +3538,44 @@ ${charter.purpose}
|
|
|
3353
3538
|
}
|
|
3354
3539
|
return (await readJson(file, PausedSchema)).paused;
|
|
3355
3540
|
}
|
|
3356
|
-
/**
|
|
3357
|
-
|
|
3541
|
+
/**
|
|
3542
|
+
* Every channel, the society's first, with how many messages it holds and its newest one; each
|
|
3543
|
+
* place's archived channels follow its open ones.
|
|
3544
|
+
*/
|
|
3358
3545
|
async listChannels() {
|
|
3359
3546
|
const summaries = [];
|
|
3360
|
-
const
|
|
3361
|
-
|
|
3547
|
+
const society = await this.society();
|
|
3548
|
+
const places = [
|
|
3549
|
+
{ project: null, names: society.channels, archived: society.archivedChannels },
|
|
3362
3550
|
...(await this.listProjects()).map((project) => ({
|
|
3363
3551
|
project: project.slug,
|
|
3364
|
-
names: project.channels
|
|
3552
|
+
names: project.channels,
|
|
3553
|
+
archived: project.archivedChannels
|
|
3365
3554
|
}))
|
|
3366
3555
|
];
|
|
3367
|
-
for (const { project, names } of
|
|
3368
|
-
|
|
3369
|
-
|
|
3556
|
+
for (const { project, names, archived } of places) {
|
|
3557
|
+
const entries = [
|
|
3558
|
+
...names.map((name) => ({ name, archived: void 0 })),
|
|
3559
|
+
...archived.map((each) => ({ name: each.name, archived: each }))
|
|
3560
|
+
];
|
|
3561
|
+
for (const entry of entries) {
|
|
3562
|
+
const ref = channelRef(project, entry.name);
|
|
3370
3563
|
const files = await listFiles(this.paths.channelDir(ref));
|
|
3371
3564
|
const last = files.at(-1)?.slice(0, 26) ?? null;
|
|
3372
3565
|
summaries.push({
|
|
3373
3566
|
ref,
|
|
3374
3567
|
project,
|
|
3375
|
-
name,
|
|
3568
|
+
name: entry.name,
|
|
3376
3569
|
messages: files.length,
|
|
3377
3570
|
lastMessageId: last,
|
|
3378
|
-
lastAt: last === null ? null : new Date(decodeTime(last)).toISOString()
|
|
3571
|
+
lastAt: last === null ? null : new Date(decodeTime(last)).toISOString(),
|
|
3572
|
+
...entry.archived === void 0 ? {} : { archived: entry.archived }
|
|
3379
3573
|
});
|
|
3380
3574
|
}
|
|
3381
3575
|
}
|
|
3382
3576
|
return summaries;
|
|
3383
3577
|
}
|
|
3578
|
+
/** Every message in a channel, oldest first. Thread messages are listed with `listThread`. */
|
|
3384
3579
|
async listChannel(ref, limit2 = 200) {
|
|
3385
3580
|
await this.assertChannelExists(ref);
|
|
3386
3581
|
const messages = await this.readMessagesIn(this.paths.channelDir(ref), null);
|
|
@@ -3533,12 +3728,27 @@ Agents may edit this file. It is rendered by the board UI.
|
|
|
3533
3728
|
return (await this.writeRoleUnlocked(actor.name, parsed, {})).charter;
|
|
3534
3729
|
});
|
|
3535
3730
|
}
|
|
3731
|
+
/** Opens a channel in a project or the society for a workstream, such as a release's tasks. */
|
|
3732
|
+
async createChannel(actor, input) {
|
|
3733
|
+
const args = VerbInputs.create_channel.parse(input);
|
|
3734
|
+
await this.authorize(actor, "create_channel");
|
|
3735
|
+
return this.mutex.run(async () => {
|
|
3736
|
+
await this.validateAddChannel(args);
|
|
3737
|
+
return this.addChannelUnlocked(actor.name, args, {}, actor.name);
|
|
3738
|
+
});
|
|
3739
|
+
}
|
|
3740
|
+
/** Archives a channel whose workstream is done; refused while a task filed there is in play. */
|
|
3741
|
+
async archiveChannel(actor, input) {
|
|
3742
|
+
const args = VerbInputs.archive_channel.parse(input);
|
|
3743
|
+
await this.authorize(actor, "archive_channel");
|
|
3744
|
+
return this.mutex.run(() => this.archiveChannelUnlocked(actor, args.channel, args.reason));
|
|
3745
|
+
}
|
|
3536
3746
|
/** Adds a channel to a project or to the society. Also what an approved channel proposal executes. */
|
|
3537
3747
|
async addChannel(actor, input) {
|
|
3538
3748
|
this.assertAdmin(actor);
|
|
3539
3749
|
return this.mutex.run(async () => {
|
|
3540
3750
|
await this.validateAddChannel(input);
|
|
3541
|
-
return this.addChannelUnlocked(actor.name, input, {});
|
|
3751
|
+
return this.addChannelUnlocked(actor.name, input, {}, actor.name);
|
|
3542
3752
|
});
|
|
3543
3753
|
}
|
|
3544
3754
|
/**
|
|
@@ -3736,19 +3946,19 @@ Agents may edit this file. It is rendered by the board UI.
|
|
|
3736
3946
|
return this.mutex.run(async () => {
|
|
3737
3947
|
const stored = await this.readCursors(actor.name);
|
|
3738
3948
|
const from = args.since_cursor === void 0 ? stored : { ...NO_CURSORS, digest: args.since_cursor };
|
|
3739
|
-
const conversation = actor.scope === void 0 ? void 0 : { scope: actor.scope,
|
|
3949
|
+
const conversation = actor.scope === void 0 ? void 0 : { scope: actor.scope, part: conversationPart(actor) ?? null };
|
|
3740
3950
|
const filed = (await this.digestSince(actor, from, conversation)).slice(0, args.limit);
|
|
3741
3951
|
if (args.advance) {
|
|
3742
3952
|
await this.advanceCursors(actor.name, filed);
|
|
3743
3953
|
}
|
|
3744
3954
|
const last = filed.at(-1)?.message.id;
|
|
3745
|
-
const cursor = last ?? (conversation === void 0 ? from.digest : cursorOf(from, conversation.scope, conversation.
|
|
3955
|
+
const cursor = last ?? (conversation === void 0 ? from.digest : cursorOf(from, conversation.scope, conversation.part));
|
|
3746
3956
|
return { messages: filed.map((entry) => entry.message), cursor };
|
|
3747
3957
|
});
|
|
3748
3958
|
}
|
|
3749
3959
|
/**
|
|
3750
3960
|
* How many unread digest messages a member has in each conversation: a scope's home, for its
|
|
3751
|
-
* channels, or one of its threads. The heartbeat asks, so that it wakes a member where its unread
|
|
3961
|
+
* general channel, one of its other channels, or one of its threads. The heartbeat asks, so that it wakes a member where its unread
|
|
3752
3962
|
* messages are and nowhere else. Society channels count only for a member woken in the society
|
|
3753
3963
|
* scope; a project member reads them at its next turn. Two kinds of post are in the digest but not
|
|
3754
3964
|
* counted, and are read when the member wakes for something else: a task step's, which wakes
|
|
@@ -3759,7 +3969,7 @@ Agents may edit this file. It is rendered by the board UI.
|
|
|
3759
3969
|
return this.mutex.run(async () => {
|
|
3760
3970
|
const counts = /* @__PURE__ */ new Map();
|
|
3761
3971
|
const unscoped = { name: actor.name, role: actor.role };
|
|
3762
|
-
for (const { scope,
|
|
3972
|
+
for (const { scope, part, message, news } of await this.digestSince(unscoped, await this.readCursors(actor.name))) {
|
|
3763
3973
|
if (news === true || message.step !== void 0 || message.author === SYSTEM_ACTOR.name) {
|
|
3764
3974
|
continue;
|
|
3765
3975
|
}
|
|
@@ -3767,8 +3977,13 @@ Agents may edit this file. It is rendered by the board UI.
|
|
|
3767
3977
|
if (posted !== scope && scope !== SOCIETY_SCOPE) {
|
|
3768
3978
|
continue;
|
|
3769
3979
|
}
|
|
3770
|
-
const key = conversationKey(scope,
|
|
3771
|
-
const entry = counts.get(key) ?? {
|
|
3980
|
+
const key = conversationKey(scope, part);
|
|
3981
|
+
const entry = counts.get(key) ?? {
|
|
3982
|
+
scope,
|
|
3983
|
+
thread: threadOfPart(part) ?? null,
|
|
3984
|
+
channel: channelOfPart(part) ?? null,
|
|
3985
|
+
count: 0
|
|
3986
|
+
};
|
|
3772
3987
|
counts.set(key, { ...entry, count: entry.count + 1 });
|
|
3773
3988
|
}
|
|
3774
3989
|
return [...counts.values()];
|
|
@@ -3784,7 +3999,7 @@ Agents may edit this file. It is rendered by the board UI.
|
|
|
3784
3999
|
const scopes = { ...cursors.scopes };
|
|
3785
4000
|
let news = newsStart(cursors);
|
|
3786
4001
|
let moved = false;
|
|
3787
|
-
for (const { scope,
|
|
4002
|
+
for (const { scope, part, message, news: isNews } of filed) {
|
|
3788
4003
|
if (isNews === true) {
|
|
3789
4004
|
if (news === null || message.id > news) {
|
|
3790
4005
|
news = message.id;
|
|
@@ -3792,8 +4007,8 @@ Agents may edit this file. It is rendered by the board UI.
|
|
|
3792
4007
|
}
|
|
3793
4008
|
continue;
|
|
3794
4009
|
}
|
|
3795
|
-
const key = conversationKey(scope,
|
|
3796
|
-
const current = scopes[key] ?? cursorOf(cursors, scope,
|
|
4010
|
+
const key = conversationKey(scope, part);
|
|
4011
|
+
const current = scopes[key] ?? cursorOf(cursors, scope, part);
|
|
3797
4012
|
if (current === null || message.id > current) {
|
|
3798
4013
|
scopes[key] = message.id;
|
|
3799
4014
|
moved = true;
|
|
@@ -3812,29 +4027,32 @@ Agents may edit this file. It is rendered by the board UI.
|
|
|
3812
4027
|
* message is filed in: what mentions the member, sits in a channel it follows, or belongs to a
|
|
3813
4028
|
* thread it takes part in, and for the front desk, which every post by the user wakes, every post
|
|
3814
4029
|
* by the user wherever it is. With a conversation, only what is filed there: for a thread, every
|
|
3815
|
-
* message of the thread, since the conversation is about it; for a
|
|
3816
|
-
* messages,
|
|
4030
|
+
* message of the thread, since the conversation is about it; for a channel's, that channel's
|
|
4031
|
+
* top-level messages; for a scope's home, general's and those of a project the member is not in,
|
|
4032
|
+
* since each thread and each other channel is a conversation of its own.
|
|
3817
4033
|
*/
|
|
3818
4034
|
async digestSince(actor, cursors, conversation) {
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
const
|
|
4035
|
+
const conversationThread = threadOfPart(conversation?.part);
|
|
4036
|
+
if (conversation !== void 0 && conversationThread !== void 0) {
|
|
4037
|
+
const { scope, part } = conversation;
|
|
4038
|
+
const found = await this.tryFindThread(conversationThread);
|
|
3822
4039
|
if (found === null) {
|
|
3823
4040
|
return [];
|
|
3824
4041
|
}
|
|
3825
|
-
const messages = await this.readMessagesIn(this.paths.threadMessages(found.threads,
|
|
3826
|
-
return messages.filter((message) => message.author !== actor.name).map((message) => ({ scope,
|
|
4042
|
+
const messages = await this.readMessagesIn(this.paths.threadMessages(found.threads, conversationThread), cursorOf(cursors, scope, part));
|
|
4043
|
+
return messages.filter((message) => message.author !== actor.name).map((message) => ({ scope, part, message })).toSorted(byId);
|
|
3827
4044
|
}
|
|
3828
4045
|
const agent = await this.readAgent(actor.name);
|
|
3829
4046
|
const charter = await this.readRole(agent.role);
|
|
3830
4047
|
const subscribed = new Set(agent.subscriptions);
|
|
3831
4048
|
const frontDesk = charter.wakeTriggers.includes(FRONT_DESK_TRIGGER);
|
|
3832
4049
|
const threadParticipation = /* @__PURE__ */ new Map();
|
|
4050
|
+
const openChannels = /* @__PURE__ */ new Map();
|
|
3833
4051
|
const collected = [];
|
|
3834
4052
|
const newsFrom = charter.societyScope ? void 0 : newsStart(cursors);
|
|
3835
4053
|
const scopes = conversation !== void 0 ? [conversation.scope] : [...agent.memberships, SOCIETY_SCOPE];
|
|
3836
4054
|
const starts = [
|
|
3837
|
-
...scopes.flatMap((each) => conversation === void 0 ? [cursorOf(cursors, each), threadStart(cursors, each)] : [cursorOf(cursors, each)]),
|
|
4055
|
+
...scopes.flatMap((each) => conversation === void 0 ? [cursorOf(cursors, each), threadStart(cursors, each), channelStart(cursors, each)] : [cursorOf(cursors, each, conversation.part)]),
|
|
3838
4056
|
...newsFrom === void 0 ? [] : [newsFrom]
|
|
3839
4057
|
];
|
|
3840
4058
|
const known = starts.filter((start) => start !== null);
|
|
@@ -3847,20 +4065,24 @@ Agents may edit this file. It is rendered by the board UI.
|
|
|
3847
4065
|
if ((newsFrom === null || message.id > newsFrom) && subscribed.has(message.channel) && message.author !== actor.name) {
|
|
3848
4066
|
collected.push({
|
|
3849
4067
|
scope: conversation?.scope ?? SOCIETY_SCOPE,
|
|
3850
|
-
|
|
4068
|
+
part: null,
|
|
3851
4069
|
message,
|
|
3852
4070
|
news: true
|
|
3853
4071
|
});
|
|
3854
4072
|
}
|
|
3855
4073
|
continue;
|
|
3856
4074
|
}
|
|
3857
|
-
|
|
3858
|
-
|
|
4075
|
+
const named = thread === null ? channelConversation(filedIn, message.channel) : void 0;
|
|
4076
|
+
let open = named === void 0 ? void 0 : openChannels.get(message.channel);
|
|
4077
|
+
if (named !== void 0 && open === void 0) {
|
|
4078
|
+
open = await this.channelOpen(message.channel);
|
|
4079
|
+
openChannels.set(message.channel, open);
|
|
3859
4080
|
}
|
|
3860
|
-
|
|
4081
|
+
const part = thread ?? (open === true ? `#${named}` : null);
|
|
4082
|
+
if (conversation !== void 0 && (filedIn !== conversation.scope || part !== conversation.part)) {
|
|
3861
4083
|
continue;
|
|
3862
4084
|
}
|
|
3863
|
-
const cursor = cursorOf(cursors, filedIn,
|
|
4085
|
+
const cursor = cursorOf(cursors, filedIn, part);
|
|
3864
4086
|
if (cursor !== null && message.id <= cursor) {
|
|
3865
4087
|
continue;
|
|
3866
4088
|
}
|
|
@@ -3878,7 +4100,7 @@ Agents may edit this file. It is rendered by the board UI.
|
|
|
3878
4100
|
include = participates;
|
|
3879
4101
|
}
|
|
3880
4102
|
if (include && message.author !== actor.name) {
|
|
3881
|
-
collected.push({ scope: filedIn,
|
|
4103
|
+
collected.push({ scope: filedIn, part, message });
|
|
3882
4104
|
}
|
|
3883
4105
|
}
|
|
3884
4106
|
return collected.toSorted(byId);
|
|
@@ -4117,7 +4339,7 @@ ${task.body}`;
|
|
|
4117
4339
|
taskThreadOpening(task) {
|
|
4118
4340
|
return {
|
|
4119
4341
|
id: task.id,
|
|
4120
|
-
channel: channelRef(task.project,
|
|
4342
|
+
channel: channelRef(task.project, task.channel),
|
|
4121
4343
|
title: task.title,
|
|
4122
4344
|
subject: { kind: "task", id: task.id }
|
|
4123
4345
|
};
|
|
@@ -4163,7 +4385,7 @@ ${task.body}`;
|
|
|
4163
4385
|
if (task.status === "done" || task.status === "abandoned") {
|
|
4164
4386
|
throw new BoardError("INVALID_STATE", `task ${task.id} is ${task.status}`);
|
|
4165
4387
|
}
|
|
4166
|
-
const channel = args.channel ?? channelRef(project,
|
|
4388
|
+
const channel = args.channel ?? channelRef(project, task.channel);
|
|
4167
4389
|
if (parseChannelRef(channel).project !== project) {
|
|
4168
4390
|
throw new BoardError("VALIDATION", `a task's thread hangs off a channel of its project, ${project}`);
|
|
4169
4391
|
}
|
|
@@ -4196,6 +4418,44 @@ ${task.body}`;
|
|
|
4196
4418
|
await this.assertChannelOpen(args.channel);
|
|
4197
4419
|
return { id: this.newId(), channel: args.channel, title: args.title };
|
|
4198
4420
|
}
|
|
4421
|
+
/**
|
|
4422
|
+
* Where the board learns which turns are in flight, from the server that hosts them, so it
|
|
4423
|
+
* refuses to end a conversation under someone else's turn.
|
|
4424
|
+
*/
|
|
4425
|
+
watchTurns(source) {
|
|
4426
|
+
this.turnsInFlight = source;
|
|
4427
|
+
}
|
|
4428
|
+
/**
|
|
4429
|
+
* Refuses to end conversations while another citizen's turn is in one of them: `cut` picks the
|
|
4430
|
+
* turns that would lose theirs. The caller's own turn is exempt, since ending a conversation
|
|
4431
|
+
* from inside it is how a citizen finishes there.
|
|
4432
|
+
*/
|
|
4433
|
+
assertNoTurnCut(actor, what, cut) {
|
|
4434
|
+
const busy = [
|
|
4435
|
+
...new Set(this.turnsInFlight().filter((turn) => cut(turn) && !ownTurn(actor, turn)).map((turn) => turn.agent))
|
|
4436
|
+
].toSorted((a, b) => a.localeCompare(b));
|
|
4437
|
+
if (busy.length === 0) {
|
|
4438
|
+
return;
|
|
4439
|
+
}
|
|
4440
|
+
const one = busy.length === 1;
|
|
4441
|
+
throw new BoardError("INVALID_STATE", `${what} while ${namesList(busy)} ${one ? "is in a turn" : "are in turns"} there; try again once ${one ? "it ends" : "they end"}, or stop ${one ? "it" : "them"}`);
|
|
4442
|
+
}
|
|
4443
|
+
/** The open threads in a folder of threads that `keep` picks. */
|
|
4444
|
+
async openThreadIds(dir, keep) {
|
|
4445
|
+
const ids = /* @__PURE__ */ new Set();
|
|
4446
|
+
for (const file of await listFiles(dir)) {
|
|
4447
|
+
const doc = await readMarkdown(path6.join(dir, file), ThreadFrontmatterSchema);
|
|
4448
|
+
if (doc.data.state === "open" && keep({ ...doc.data, body: doc.body })) {
|
|
4449
|
+
ids.add(doc.data.id);
|
|
4450
|
+
}
|
|
4451
|
+
}
|
|
4452
|
+
return ids;
|
|
4453
|
+
}
|
|
4454
|
+
/** Refuses to archive a project while a turn runs in it or in a thread its archive closes. */
|
|
4455
|
+
async assertProjectQuiet(actor, slug) {
|
|
4456
|
+
const threads = await this.openThreadIds(this.paths.threads(slug), () => true);
|
|
4457
|
+
this.assertNoTurnCut(actor, `project ${slug} cannot be archived`, (turn) => turn.scope === slug || turn.thread !== void 0 && threads.has(turn.thread));
|
|
4458
|
+
}
|
|
4199
4459
|
async closeThread(actor, input) {
|
|
4200
4460
|
const args = VerbInputs.close_thread.parse(input);
|
|
4201
4461
|
await this.authorize(actor, "close_thread");
|
|
@@ -4210,6 +4470,7 @@ ${task.body}`;
|
|
|
4210
4470
|
if (!PLANNING_ROLES.includes(actor.role) && !await this.participatesInThread(actor, thread, threads)) {
|
|
4211
4471
|
throw new BoardError("FORBIDDEN", "only the thread's participants, the user, the steward, or the concierge may close it");
|
|
4212
4472
|
}
|
|
4473
|
+
this.assertNoTurnCut(actor, `thread ${thread.id} cannot close`, (turn) => turn.thread === thread.id);
|
|
4213
4474
|
const summary = await this.writeMessage(this.paths.channelDir(thread.channel), { author: actor.name, channel: thread.channel, closes: thread.id }, `Thread closed: "${thread.title}" (${thread.id}).
|
|
4214
4475
|
|
|
4215
4476
|
${args.summary}`);
|
|
@@ -4248,10 +4509,12 @@ ${args.summary}`);
|
|
|
4248
4509
|
if (first === void 0) {
|
|
4249
4510
|
throw new BoardError("VALIDATION", "a plan needs at least one stage");
|
|
4250
4511
|
}
|
|
4512
|
+
await this.assertChannelOpen(channelRef(args.project, args.channel));
|
|
4251
4513
|
const ts = this.now().toISOString();
|
|
4252
4514
|
const frontmatter = TaskFrontmatterSchema.parse({
|
|
4253
4515
|
id: this.newId(),
|
|
4254
4516
|
project: args.project,
|
|
4517
|
+
channel: args.channel,
|
|
4255
4518
|
title: args.title,
|
|
4256
4519
|
status: "open",
|
|
4257
4520
|
createdBy: actor.name,
|
|
@@ -4267,11 +4530,11 @@ ${args.summary}`);
|
|
|
4267
4530
|
onDone: project.onDone,
|
|
4268
4531
|
completing: false
|
|
4269
4532
|
});
|
|
4270
|
-
await this.assertChannelExists(channelRef(args.project, "general"));
|
|
4271
4533
|
const task = await this.writeTask(args.project, { ...frontmatter, body: args.body });
|
|
4272
4534
|
await this.events.append("task.created", actor.name, {
|
|
4273
4535
|
taskId: task.id,
|
|
4274
4536
|
project: task.project,
|
|
4537
|
+
channel: task.channel,
|
|
4275
4538
|
title: task.title,
|
|
4276
4539
|
parentId: task.parentId ?? null,
|
|
4277
4540
|
stage: task.stage
|
|
@@ -4387,6 +4650,9 @@ ${args.summary}`);
|
|
|
4387
4650
|
completedAt: ts
|
|
4388
4651
|
};
|
|
4389
4652
|
const next = current.stages[index + 1];
|
|
4653
|
+
if (next === void 0 && current.onDone === "none") {
|
|
4654
|
+
this.assertNoTurnCut(actor, `task ${current.id} cannot end`, (turn) => turn.thread === current.id);
|
|
4655
|
+
}
|
|
4390
4656
|
const reworkDone = current.returned === void 0 || next === void 0 || index + 1 >= stageIndex(current, current.returned.from);
|
|
4391
4657
|
const base = {
|
|
4392
4658
|
...current,
|
|
@@ -4550,6 +4816,7 @@ ${args.summary}`);
|
|
|
4550
4816
|
if (!mayAbandon) {
|
|
4551
4817
|
throw new BoardError("FORBIDDEN", "only the holder, the creator, the user, the steward, or the concierge may abandon a task");
|
|
4552
4818
|
}
|
|
4819
|
+
this.assertNoTurnCut(actor, `task ${current.id} cannot be abandoned`, (turn) => turn.thread === current.id);
|
|
4553
4820
|
next = {
|
|
4554
4821
|
...next,
|
|
4555
4822
|
status: "abandoned",
|
|
@@ -4751,6 +5018,7 @@ ${rationale}`}`);
|
|
|
4751
5018
|
await this.authorize(actor, "archive_project");
|
|
4752
5019
|
return this.mutex.run(async () => {
|
|
4753
5020
|
await this.validateArchive(args.project);
|
|
5021
|
+
await this.assertProjectQuiet(actor, args.project);
|
|
4754
5022
|
return (await this.archiveUnlocked(actor.name, args.project, args.reason, {})).project;
|
|
4755
5023
|
});
|
|
4756
5024
|
}
|
|
@@ -4758,6 +5026,10 @@ ${rationale}`}`);
|
|
|
4758
5026
|
* A citizen joins a project itself, or a reallocating role adds it. The pair gets its project
|
|
4759
5027
|
* directory now and, through the `agent.joined` event, an onboarding turn.
|
|
4760
5028
|
*/
|
|
5029
|
+
/**
|
|
5030
|
+
* A member follows the project's general from the start; the result names the project's other
|
|
5031
|
+
* open channels, which it subscribes to when its work there needs them.
|
|
5032
|
+
*/
|
|
4761
5033
|
async joinProject(actor, input) {
|
|
4762
5034
|
const args = VerbInputs.join_project.parse(input);
|
|
4763
5035
|
await this.authorize(actor, "join_project");
|
|
@@ -4770,7 +5042,7 @@ ${rationale}`}`);
|
|
|
4770
5042
|
throw new BoardError("INVALID_STATE", `${target} is retired`);
|
|
4771
5043
|
}
|
|
4772
5044
|
if (current.memberships.includes(args.project)) {
|
|
4773
|
-
return current;
|
|
5045
|
+
return { ...current, otherChannels: await this.otherChannels(args.project, current) };
|
|
4774
5046
|
}
|
|
4775
5047
|
await this.assertMayJoinProjects(target, current.role);
|
|
4776
5048
|
const agent = await this.updateAgent(target, (a) => ({
|
|
@@ -4789,9 +5061,13 @@ ${rationale}`}`);
|
|
|
4789
5061
|
project: args.project,
|
|
4790
5062
|
cli: agent.cli
|
|
4791
5063
|
});
|
|
4792
|
-
return agent;
|
|
5064
|
+
return { ...agent, otherChannels: await this.otherChannels(args.project, agent) };
|
|
4793
5065
|
});
|
|
4794
5066
|
}
|
|
5067
|
+
/** The open channels of a project a citizen does not follow. */
|
|
5068
|
+
async otherChannels(project, agent) {
|
|
5069
|
+
return (await this.readProject(project)).channels.map((name) => channelRef(project, name)).filter((ref) => !agent.subscriptions.includes(ref));
|
|
5070
|
+
}
|
|
4795
5071
|
async leaveProject(actor, input) {
|
|
4796
5072
|
const args = VerbInputs.leave_project.parse(input);
|
|
4797
5073
|
await this.authorize(actor, "leave_project");
|
|
@@ -4896,6 +5172,10 @@ ${rationale}`}`);
|
|
|
4896
5172
|
return this.configureProject(actor, VerbInputs.configure_project.parse(input));
|
|
4897
5173
|
case "archive_project":
|
|
4898
5174
|
return this.archiveProject(actor, VerbInputs.archive_project.parse(input));
|
|
5175
|
+
case "create_channel":
|
|
5176
|
+
return this.createChannel(actor, VerbInputs.create_channel.parse(input));
|
|
5177
|
+
case "archive_channel":
|
|
5178
|
+
return this.archiveChannel(actor, VerbInputs.archive_channel.parse(input));
|
|
4899
5179
|
default:
|
|
4900
5180
|
throw new BoardError("VALIDATION", `unknown verb ${String(verb)}`);
|
|
4901
5181
|
}
|
|
@@ -5001,34 +5281,35 @@ ${rationale}`}`);
|
|
|
5001
5281
|
}
|
|
5002
5282
|
async readMemoryCore(agent) {
|
|
5003
5283
|
const file = this.paths.agentMemoryCore(agent);
|
|
5004
|
-
return await exists(file) ? (await
|
|
5284
|
+
return await exists(file) ? (await readLooseMarkdown(file)).body : "";
|
|
5005
5285
|
}
|
|
5006
5286
|
/**
|
|
5007
5287
|
* Moves a conversation's cursor forward to `cursor` once a turn there has delivered up to it. It
|
|
5008
5288
|
* never moves back, so a turn that ends after a later one in the same conversation cannot rewind it.
|
|
5289
|
+
* `part` names the conversation beside the scope's home: a thread's id or `#channel`.
|
|
5009
5290
|
*/
|
|
5010
|
-
async setDigestCursor(agent, scope, cursor,
|
|
5291
|
+
async setDigestCursor(agent, scope, cursor, part) {
|
|
5011
5292
|
await this.mutex.run(async () => {
|
|
5012
5293
|
if (cursor === null) {
|
|
5013
5294
|
return;
|
|
5014
5295
|
}
|
|
5015
5296
|
const cursors = await this.readCursors(agent);
|
|
5016
|
-
const current = cursorOf(cursors, scope,
|
|
5297
|
+
const current = cursorOf(cursors, scope, part ?? null);
|
|
5017
5298
|
const news = newsStart(cursors);
|
|
5018
|
-
const readNews =
|
|
5299
|
+
const readNews = part === void 0 && (news === null || cursor > news);
|
|
5019
5300
|
if (current !== null && cursor <= current && !readNews) {
|
|
5020
5301
|
return;
|
|
5021
5302
|
}
|
|
5022
5303
|
await writeJson(this.paths.agentCursors(agent), {
|
|
5023
5304
|
...cursors,
|
|
5024
|
-
scopes: current !== null && cursor <= current ? cursors.scopes : { ...cursors.scopes, [conversationKey(scope,
|
|
5305
|
+
scopes: current !== null && cursor <= current ? cursors.scopes : { ...cursors.scopes, [conversationKey(scope, part ?? null)]: cursor },
|
|
5025
5306
|
...readNews ? { news: cursor } : {}
|
|
5026
5307
|
});
|
|
5027
5308
|
});
|
|
5028
5309
|
}
|
|
5029
5310
|
/** Where a conversation's digest resumes: the newest message a turn there was shown. */
|
|
5030
|
-
async digestCursor(agent, scope,
|
|
5031
|
-
return this.mutex.run(async () => cursorOf(await this.readCursors(agent), scope,
|
|
5311
|
+
async digestCursor(agent, scope, part) {
|
|
5312
|
+
return this.mutex.run(async () => cursorOf(await this.readCursors(agent), scope, part ?? null));
|
|
5032
5313
|
}
|
|
5033
5314
|
/** Records that a running turn took posts delivered into it, which wake latency counts to. */
|
|
5034
5315
|
async recordSteered(input) {
|
|
@@ -5037,25 +5318,29 @@ ${rationale}`}`);
|
|
|
5037
5318
|
turnId: input.turnId,
|
|
5038
5319
|
project: input.project,
|
|
5039
5320
|
...input.thread === void 0 ? {} : { thread: input.thread },
|
|
5321
|
+
...input.channel === void 0 ? {} : { channel: input.channel },
|
|
5040
5322
|
messages: [...input.messages]
|
|
5041
5323
|
});
|
|
5042
5324
|
});
|
|
5043
5325
|
}
|
|
5044
|
-
/**
|
|
5045
|
-
|
|
5046
|
-
|
|
5326
|
+
/**
|
|
5327
|
+
* Where a conversation's session ids and last turn are kept: the scope's home, or beside it a
|
|
5328
|
+
* thread's or a channel's, named by `part` as `conversationPart` gives it.
|
|
5329
|
+
*/
|
|
5330
|
+
sessionDir(agent, scope, part) {
|
|
5331
|
+
return part === void 0 ? this.paths.agentProject(agent, scope) : this.paths.agentConversation(agent, scope, part);
|
|
5047
5332
|
}
|
|
5048
|
-
async readSessions(agent, project,
|
|
5049
|
-
const file = path6.join(this.sessionDir(agent, project,
|
|
5333
|
+
async readSessions(agent, project, part) {
|
|
5334
|
+
const file = path6.join(this.sessionDir(agent, project, part), "sessions.json");
|
|
5050
5335
|
return await exists(file) ? readJson(file, SessionsFileSchema) : {};
|
|
5051
5336
|
}
|
|
5052
5337
|
/**
|
|
5053
5338
|
* Records a conversation's session for a CLI and the runner it began on. A session started on
|
|
5054
5339
|
* another runner replaces the record, since sessions do not move between machines.
|
|
5055
5340
|
*/
|
|
5056
|
-
async writeSession(agent, project, cli, sessionId, runner,
|
|
5341
|
+
async writeSession(agent, project, cli, sessionId, runner, part) {
|
|
5057
5342
|
await this.mutex.run(async () => {
|
|
5058
|
-
const dir = this.sessionDir(agent, project,
|
|
5343
|
+
const dir = this.sessionDir(agent, project, part);
|
|
5059
5344
|
await ensureDir(dir);
|
|
5060
5345
|
const file = path6.join(dir, "sessions.json");
|
|
5061
5346
|
const current = await exists(file) ? await readJson(file, SessionsFileSchema) : {};
|
|
@@ -5063,17 +5348,21 @@ ${rationale}`}`);
|
|
|
5063
5348
|
await writeJson(file, { ...kept, [cli]: sessionId, runner });
|
|
5064
5349
|
});
|
|
5065
5350
|
}
|
|
5066
|
-
/** The last turn of one conversation: the scope's home, or a thread's. */
|
|
5067
|
-
async readLastTurn(agent, project,
|
|
5068
|
-
const file = path6.join(this.sessionDir(agent, project,
|
|
5351
|
+
/** The last turn of one conversation: the scope's home, a channel's, or a thread's. */
|
|
5352
|
+
async readLastTurn(agent, project, part) {
|
|
5353
|
+
const file = path6.join(this.sessionDir(agent, project, part), "last-turn.json");
|
|
5069
5354
|
return await exists(file) ? readJson(file, TurnRecordSchema) : null;
|
|
5070
5355
|
}
|
|
5071
5356
|
/** The latest turn a citizen took in a scope, in any of its conversations there. */
|
|
5072
5357
|
async readLatestTurn(agent, project) {
|
|
5073
5358
|
let latest = await this.readLastTurn(agent, project);
|
|
5074
|
-
const
|
|
5075
|
-
|
|
5076
|
-
|
|
5359
|
+
const place = this.paths.agentProject(agent, project);
|
|
5360
|
+
const parts = [
|
|
5361
|
+
...await listDirs(path6.join(place, "threads")),
|
|
5362
|
+
...(await listDirs(path6.join(place, "channels"))).map((name) => `#${name}`)
|
|
5363
|
+
];
|
|
5364
|
+
for (const part of parts) {
|
|
5365
|
+
const turn = await this.readLastTurn(agent, project, part);
|
|
5077
5366
|
if (turn !== null && (latest === null || turnTime(turn) > turnTime(latest))) {
|
|
5078
5367
|
latest = turn;
|
|
5079
5368
|
}
|
|
@@ -5090,6 +5379,7 @@ ${rationale}`}`);
|
|
|
5090
5379
|
turnId: parsed.id,
|
|
5091
5380
|
project: parsed.project,
|
|
5092
5381
|
...parsed.thread === void 0 ? {} : { thread: parsed.thread },
|
|
5382
|
+
...parsed.channel === void 0 ? {} : { channel: parsed.channel },
|
|
5093
5383
|
trigger: parsed.trigger.kind,
|
|
5094
5384
|
session: parsed.session,
|
|
5095
5385
|
runner: parsed.runner
|
|
@@ -5115,13 +5405,15 @@ ${rationale}`}`);
|
|
|
5115
5405
|
...parsed.id === void 0 ? {} : { turnId: parsed.id },
|
|
5116
5406
|
agent: parsed.agent,
|
|
5117
5407
|
project: parsed.project,
|
|
5118
|
-
...parsed.thread === void 0 ? {} : { thread: parsed.thread }
|
|
5408
|
+
...parsed.thread === void 0 ? {} : { thread: parsed.thread },
|
|
5409
|
+
...parsed.channel === void 0 ? {} : { channel: parsed.channel }
|
|
5119
5410
|
});
|
|
5120
5411
|
}
|
|
5121
5412
|
await this.events.append(failed ? "turn.failed" : "turn.completed", parsed.agent, {
|
|
5122
5413
|
...parsed.id === void 0 ? {} : { turnId: parsed.id },
|
|
5123
5414
|
project: parsed.project,
|
|
5124
5415
|
...parsed.thread === void 0 ? {} : { thread: parsed.thread },
|
|
5416
|
+
...parsed.channel === void 0 ? {} : { channel: parsed.channel },
|
|
5125
5417
|
trigger: parsed.trigger.kind,
|
|
5126
5418
|
session: parsed.session,
|
|
5127
5419
|
exitReason: parsed.exitReason,
|
|
@@ -5145,6 +5437,47 @@ ${rationale}`}`);
|
|
|
5145
5437
|
});
|
|
5146
5438
|
});
|
|
5147
5439
|
}
|
|
5440
|
+
/**
|
|
5441
|
+
* Records that a citizen's workspace in a conversation that has ended holds work on no branch, so
|
|
5442
|
+
* the scheduler gives the citizen a closing turn there to decide about it. False, recording
|
|
5443
|
+
* nothing, once `CLOSING_ATTEMPTS` closing turns have been given for it.
|
|
5444
|
+
*/
|
|
5445
|
+
async recordLeftovers(input) {
|
|
5446
|
+
const { conversation } = input;
|
|
5447
|
+
const payload = {
|
|
5448
|
+
agent: input.agent,
|
|
5449
|
+
project: input.scope,
|
|
5450
|
+
...conversation.kind === "thread" ? { thread: conversation.id } : { channel: conversation.name }
|
|
5451
|
+
};
|
|
5452
|
+
return this.mutex.run(async () => {
|
|
5453
|
+
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;
|
|
5454
|
+
if (earlier >= CLOSING_ATTEMPTS) {
|
|
5455
|
+
return false;
|
|
5456
|
+
}
|
|
5457
|
+
await this.events.append("workspace.leftovers", SYSTEM_ACTOR.name, payload);
|
|
5458
|
+
return true;
|
|
5459
|
+
});
|
|
5460
|
+
}
|
|
5461
|
+
/**
|
|
5462
|
+
* Whether a conversation with a workspace of its own has ended: a thread closed, a task ended
|
|
5463
|
+
* (for one from before tasks had threads), or a channel archived.
|
|
5464
|
+
*/
|
|
5465
|
+
async conversationEnded(conversation) {
|
|
5466
|
+
if (conversation.kind === "channel") {
|
|
5467
|
+
const place = conversation.scope === SOCIETY_SCOPE ? null : conversation.scope;
|
|
5468
|
+
return !await this.channelOpen(channelRef(place, conversation.name));
|
|
5469
|
+
}
|
|
5470
|
+
const found = await this.tryFindThread(conversation.id);
|
|
5471
|
+
if (found !== null) {
|
|
5472
|
+
return found.thread.state === "closed";
|
|
5473
|
+
}
|
|
5474
|
+
try {
|
|
5475
|
+
const { task } = await this.findTask(conversation.id);
|
|
5476
|
+
return task.status === "done" || task.status === "abandoned";
|
|
5477
|
+
} catch {
|
|
5478
|
+
return false;
|
|
5479
|
+
}
|
|
5480
|
+
}
|
|
5148
5481
|
/**
|
|
5149
5482
|
* Records the outcome of a completing task's effect: done, or waiting again at its last stage so
|
|
5150
5483
|
* its participants can reshape the plan. A merge's outcome is posted to the task's thread, and a
|
|
@@ -5180,7 +5513,7 @@ ${rationale}`}`);
|
|
|
5180
5513
|
await this.refreshMember(name);
|
|
5181
5514
|
}
|
|
5182
5515
|
if (merged) {
|
|
5183
|
-
await this.appendMessage(actor.name, channelRef(location.project,
|
|
5516
|
+
await this.appendMessage(actor.name, channelRef(location.project, task2.channel), `Task ${task2.id} "${task2.title}" is done: ${sentence(input.detail)}.`);
|
|
5184
5517
|
}
|
|
5185
5518
|
return task2;
|
|
5186
5519
|
}
|
|
@@ -5216,7 +5549,8 @@ ${rationale}`}`);
|
|
|
5216
5549
|
if (!await exists(file)) {
|
|
5217
5550
|
return { data: { project: slug }, body: "" };
|
|
5218
5551
|
}
|
|
5219
|
-
|
|
5552
|
+
const { data, body } = await readLooseMarkdown(file);
|
|
5553
|
+
return { data, body };
|
|
5220
5554
|
}
|
|
5221
5555
|
async listRunners() {
|
|
5222
5556
|
const runners2 = [];
|
|
@@ -5416,7 +5750,7 @@ ${rationale}`}`);
|
|
|
5416
5750
|
return this.files.read(this.paths.board, paths, () => true);
|
|
5417
5751
|
}
|
|
5418
5752
|
async writeTurnRecord(record) {
|
|
5419
|
-
const dir = this.sessionDir(record.agent, record.project, record
|
|
5753
|
+
const dir = this.sessionDir(record.agent, record.project, conversationPart(record));
|
|
5420
5754
|
await ensureDir(dir);
|
|
5421
5755
|
await writeJson(path6.join(dir, "last-turn.json"), record);
|
|
5422
5756
|
}
|
|
@@ -5720,7 +6054,7 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
|
|
|
5720
6054
|
};
|
|
5721
6055
|
}
|
|
5722
6056
|
case "channel": {
|
|
5723
|
-
const channel = await this.addChannelUnlocked(by, ChannelProposalSchema.parse(proposal.charter), meta);
|
|
6057
|
+
const channel = await this.addChannelUnlocked(by, ChannelProposalSchema.parse(proposal.charter), meta, proposal.proposedBy);
|
|
5724
6058
|
return { channel };
|
|
5725
6059
|
}
|
|
5726
6060
|
case "role": {
|
|
@@ -5826,18 +6160,20 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
|
|
|
5826
6160
|
return { agent, token };
|
|
5827
6161
|
}
|
|
5828
6162
|
async validateAddChannel(input) {
|
|
5829
|
-
|
|
5830
|
-
|
|
5831
|
-
|
|
5832
|
-
}
|
|
5833
|
-
return;
|
|
6163
|
+
const place = input.project === null ? await this.society() : await this.readActiveProject(input.project);
|
|
6164
|
+
const ref = channelRef(input.project, input.name);
|
|
6165
|
+
if (place.channels.includes(input.name)) {
|
|
6166
|
+
throw new BoardError("ALREADY_EXISTS", `channel ${ref} already exists`);
|
|
5834
6167
|
}
|
|
5835
|
-
|
|
5836
|
-
|
|
5837
|
-
throw new BoardError("ALREADY_EXISTS", `channel ${channelRef(input.project, input.name)} already exists`);
|
|
6168
|
+
if (place.archivedChannels.some((each) => each.name === input.name)) {
|
|
6169
|
+
throw new BoardError("ALREADY_EXISTS", `channel ${ref} was archived; open the new one under another name`);
|
|
5838
6170
|
}
|
|
5839
6171
|
}
|
|
5840
|
-
|
|
6172
|
+
/**
|
|
6173
|
+
* Opens a channel, which `opener` follows from the start: the citizen who opened it or proposed
|
|
6174
|
+
* it. Nobody else is subscribed, and the user, who reads channels directly, follows nothing.
|
|
6175
|
+
*/
|
|
6176
|
+
async addChannelUnlocked(by, input, meta, opener) {
|
|
5841
6177
|
const ref = channelRef(input.project, input.name);
|
|
5842
6178
|
if (input.project === null) {
|
|
5843
6179
|
const doc = await readMarkdown(this.paths.societyFile(), SocietySchema);
|
|
@@ -5851,14 +6187,104 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
|
|
|
5851
6187
|
}));
|
|
5852
6188
|
await ensureDir(this.paths.projectChannel(project, input.name));
|
|
5853
6189
|
}
|
|
6190
|
+
const follower = await this.readAgent(opener).catch(() => null);
|
|
6191
|
+
const follows = follower !== null && follower.status === "active" && follower.role !== USER_ROLE;
|
|
6192
|
+
if (follows) {
|
|
6193
|
+
await this.updateAgent(opener, (agent) => ({
|
|
6194
|
+
...agent,
|
|
6195
|
+
subscriptions: [.../* @__PURE__ */ new Set([...agent.subscriptions, ref])]
|
|
6196
|
+
}));
|
|
6197
|
+
await this.refreshMember(opener);
|
|
6198
|
+
}
|
|
5854
6199
|
await this.events.append("channel.added", by, {
|
|
5855
6200
|
channel: ref,
|
|
5856
6201
|
purpose: input.purpose,
|
|
6202
|
+
...follows ? { followedBy: opener } : {},
|
|
5857
6203
|
...meta
|
|
5858
6204
|
});
|
|
5859
|
-
|
|
6205
|
+
const purpose = withoutMentions(input.purpose);
|
|
6206
|
+
await this.appendMessage(SYSTEM_ACTOR.name, ref, `Channel ${ref} opened: ${purpose}`);
|
|
6207
|
+
await this.appendMessage(SYSTEM_ACTOR.name, channelRef(input.project, "general"), `Channel ${ref} opened by ${by}: ${purpose}. Subscribe to it to follow its posts; its tasks and mentions reach whoever they concern either way.`);
|
|
5860
6208
|
return ref;
|
|
5861
6209
|
}
|
|
6210
|
+
/**
|
|
6211
|
+
* Ends a channel: its open threads close, its followers stop following it, and it leaves the
|
|
6212
|
+
* place's channels for its archived ones, where its history stays readable. The notice is the
|
|
6213
|
+
* board's and leaves the reason out, since a board-authored mention would wake whoever it names.
|
|
6214
|
+
*/
|
|
6215
|
+
async archiveChannelUnlocked(actor, ref, reason) {
|
|
6216
|
+
const by = actor.name;
|
|
6217
|
+
const { project, channel } = parseChannelRef(ref);
|
|
6218
|
+
if (isBoardChannel(project, channel)) {
|
|
6219
|
+
throw new BoardError("FORBIDDEN", `the board itself uses ${ref}, so it is never archived`);
|
|
6220
|
+
}
|
|
6221
|
+
await this.assertChannelOpen(ref);
|
|
6222
|
+
if (project !== null) {
|
|
6223
|
+
const inPlay2 = (await this.listTasks(project)).filter((task) => task.channel === channel && task.status !== "done" && task.status !== "abandoned");
|
|
6224
|
+
if (inPlay2.length > 0) {
|
|
6225
|
+
throw new BoardError("INVALID_STATE", `${inPlay2.length} task(s) filed in ${ref} are still in play: ${inPlay2.map((task) => task.id).join(", ")}`);
|
|
6226
|
+
}
|
|
6227
|
+
}
|
|
6228
|
+
const ts = this.now().toISOString();
|
|
6229
|
+
const threads = project === null ? this.paths.societyThreads() : this.paths.threads(project);
|
|
6230
|
+
const closing = await this.openThreadIds(threads, (thread) => thread.channel === ref);
|
|
6231
|
+
const scope = project ?? SOCIETY_SCOPE;
|
|
6232
|
+
this.assertNoTurnCut(actor, `${ref} cannot be archived`, (turn) => turn.channel === channel && turn.scope === scope || turn.thread !== void 0 && closing.has(turn.thread));
|
|
6233
|
+
const threadsClosed = [];
|
|
6234
|
+
for (const file of await listFiles(threads)) {
|
|
6235
|
+
const doc = await readMarkdown(path6.join(threads, file), ThreadFrontmatterSchema);
|
|
6236
|
+
if (doc.data.channel !== ref || doc.data.state !== "open") {
|
|
6237
|
+
continue;
|
|
6238
|
+
}
|
|
6239
|
+
await this.writeThread(threads, {
|
|
6240
|
+
...doc.data,
|
|
6241
|
+
body: doc.body,
|
|
6242
|
+
state: "closed",
|
|
6243
|
+
closedBy: by,
|
|
6244
|
+
closedAt: ts
|
|
6245
|
+
});
|
|
6246
|
+
await this.events.append("thread.closed", by, {
|
|
6247
|
+
threadId: doc.data.id,
|
|
6248
|
+
channel: ref,
|
|
6249
|
+
ended: "archived"
|
|
6250
|
+
});
|
|
6251
|
+
threadsClosed.push(doc.data.id);
|
|
6252
|
+
}
|
|
6253
|
+
const unsubscribed = [];
|
|
6254
|
+
for (const agent of await this.listAgents()) {
|
|
6255
|
+
if (agent.subscriptions.includes(ref)) {
|
|
6256
|
+
await this.updateAgent(agent.name, (a) => ({
|
|
6257
|
+
...a,
|
|
6258
|
+
subscriptions: a.subscriptions.filter((each) => each !== ref)
|
|
6259
|
+
}));
|
|
6260
|
+
await this.refreshMember(agent.name);
|
|
6261
|
+
unsubscribed.push(agent.name);
|
|
6262
|
+
}
|
|
6263
|
+
}
|
|
6264
|
+
const archived = { name: channel, at: ts, by, reason };
|
|
6265
|
+
if (project === null) {
|
|
6266
|
+
const doc = await readMarkdown(this.paths.societyFile(), SocietySchema);
|
|
6267
|
+
await writeMarkdown(this.paths.societyFile(), {
|
|
6268
|
+
...doc.data,
|
|
6269
|
+
channels: doc.data.channels.filter((name) => name !== channel),
|
|
6270
|
+
archivedChannels: [...doc.data.archivedChannels, archived]
|
|
6271
|
+
}, doc.body);
|
|
6272
|
+
} else {
|
|
6273
|
+
await this.updateProject(project, (current) => ({
|
|
6274
|
+
...current,
|
|
6275
|
+
channels: current.channels.filter((name) => name !== channel),
|
|
6276
|
+
archivedChannels: [...current.archivedChannels, archived]
|
|
6277
|
+
}));
|
|
6278
|
+
}
|
|
6279
|
+
await this.events.append("channel.archived", by, {
|
|
6280
|
+
channel: ref,
|
|
6281
|
+
reason,
|
|
6282
|
+
threadsClosed,
|
|
6283
|
+
unsubscribed
|
|
6284
|
+
});
|
|
6285
|
+
await this.appendMessage(SYSTEM_ACTOR.name, channelRef(project, "general"), `Channel ${ref} is archived by ${by}. Its posts, threads, and tasks stay readable; nothing more is posted or filed there.`);
|
|
6286
|
+
return archived;
|
|
6287
|
+
}
|
|
5862
6288
|
/** A role becomes a society role only while none of its members is in a project. */
|
|
5863
6289
|
async validateRole(charter) {
|
|
5864
6290
|
if (charter.name === USER_ROLE) {
|
|
@@ -6071,23 +6497,35 @@ ${charter.purpose}
|
|
|
6071
6497
|
async publishSignalUnlocked(signal) {
|
|
6072
6498
|
return this.events.append("ops.signal", SYSTEM_ACTOR.name, OpsSignalSchema.parse(signal));
|
|
6073
6499
|
}
|
|
6500
|
+
/** A channel that exists, open or archived, and the record of its archive when it has one. */
|
|
6074
6501
|
async assertChannelExists(ref) {
|
|
6075
6502
|
const parsed = parseChannelRef(ref);
|
|
6076
|
-
|
|
6077
|
-
|
|
6078
|
-
|
|
6079
|
-
|
|
6080
|
-
|
|
6081
|
-
|
|
6503
|
+
const place = parsed.project === null ? await this.society() : await this.readProject(parsed.project);
|
|
6504
|
+
if (place.channels.includes(parsed.channel)) {
|
|
6505
|
+
return void 0;
|
|
6506
|
+
}
|
|
6507
|
+
const archived = place.archivedChannels.find((each) => each.name === parsed.channel);
|
|
6508
|
+
if (archived === void 0) {
|
|
6509
|
+
throw new BoardError("NOT_FOUND", parsed.project === null ? `society channel ${ref} not found` : `channel ${ref} not found`);
|
|
6082
6510
|
}
|
|
6083
|
-
|
|
6084
|
-
|
|
6085
|
-
|
|
6511
|
+
return archived;
|
|
6512
|
+
}
|
|
6513
|
+
/** Whether a channel exists and is not archived, its conversations still going. */
|
|
6514
|
+
async channelOpen(ref) {
|
|
6515
|
+
try {
|
|
6516
|
+
return await this.assertChannelExists(ref) === void 0;
|
|
6517
|
+
} catch {
|
|
6518
|
+
return false;
|
|
6086
6519
|
}
|
|
6087
6520
|
}
|
|
6088
|
-
/**
|
|
6521
|
+
/**
|
|
6522
|
+
* A channel that takes new posts, threads, tasks, and followers: it exists, it is not archived,
|
|
6523
|
+
* and its project is not archived.
|
|
6524
|
+
*/
|
|
6089
6525
|
async assertChannelOpen(ref) {
|
|
6090
|
-
await this.assertChannelExists(ref)
|
|
6526
|
+
if (await this.assertChannelExists(ref) !== void 0) {
|
|
6527
|
+
throw new BoardError("INVALID_STATE", `channel ${ref} is archived`);
|
|
6528
|
+
}
|
|
6091
6529
|
const { project } = parseChannelRef(ref);
|
|
6092
6530
|
if (project !== null) {
|
|
6093
6531
|
await this.readActiveProject(project);
|
|
@@ -6187,6 +6625,10 @@ ${charter.purpose}
|
|
|
6187
6625
|
if (outcome === "approved") {
|
|
6188
6626
|
await this.validateProvision(proposal.kind, proposal.charter);
|
|
6189
6627
|
}
|
|
6628
|
+
this.assertNoTurnCut(actor, `proposal ${proposalId} cannot be decided`, (turn) => turn.thread === proposalId);
|
|
6629
|
+
if (outcome === "approved" && proposal.kind === "archive") {
|
|
6630
|
+
await this.assertProjectQuiet(actor, ArchiveProposalSchema.parse(proposal.charter).project);
|
|
6631
|
+
}
|
|
6190
6632
|
const ts = this.now().toISOString();
|
|
6191
6633
|
const decision = DecisionSchema.parse({
|
|
6192
6634
|
id: this.newId(),
|
|
@@ -6373,6 +6815,8 @@ function decideWake(input) {
|
|
|
6373
6815
|
return { wake: true, reason: "operations signal", priority: 0 };
|
|
6374
6816
|
case "user_post":
|
|
6375
6817
|
return { wake: true, reason: "user post", priority: 2 };
|
|
6818
|
+
case "closing":
|
|
6819
|
+
return { wake: true, reason: "an ended conversation left work on no branch", priority: 1 };
|
|
6376
6820
|
case "heartbeat":
|
|
6377
6821
|
break;
|
|
6378
6822
|
}
|
|
@@ -6388,6 +6832,16 @@ function decideWake(input) {
|
|
|
6388
6832
|
|
|
6389
6833
|
// packages/scheduler/dist/scheduler.js
|
|
6390
6834
|
import { z as z13 } from "zod";
|
|
6835
|
+
function besideFields(beside) {
|
|
6836
|
+
if (beside === void 0) {
|
|
6837
|
+
return {};
|
|
6838
|
+
}
|
|
6839
|
+
return "thread" in beside ? { thread: beside.thread } : { channel: beside.channel };
|
|
6840
|
+
}
|
|
6841
|
+
function conversationKey2(agent, scope, beside) {
|
|
6842
|
+
const fields = besideFields(beside);
|
|
6843
|
+
return sessionKey(agent, scope, conversationPart({ thread: fields.thread?.id, channel: fields.channel }));
|
|
6844
|
+
}
|
|
6391
6845
|
var DEFAULT_TIMINGS = Object.freeze({
|
|
6392
6846
|
pollMs: 1e3,
|
|
6393
6847
|
debounceMs: 3e4,
|
|
@@ -6503,13 +6957,22 @@ var Scheduler = class {
|
|
|
6503
6957
|
now;
|
|
6504
6958
|
log;
|
|
6505
6959
|
pending = /* @__PURE__ */ new Map();
|
|
6506
|
-
/**
|
|
6960
|
+
/**
|
|
6961
|
+
* Turns in flight by session key. A conversation runs one turn at a time, and different
|
|
6962
|
+
* conversations side by side, since each thread's works in a place of its own.
|
|
6963
|
+
*/
|
|
6507
6964
|
running = /* @__PURE__ */ new Map();
|
|
6508
6965
|
/** Steers under way, by session key; a pass does not wait for a runner to answer one. */
|
|
6509
6966
|
steering = /* @__PURE__ */ new Map();
|
|
6510
6967
|
/** Tasks whose completion effect waits for the turn that finished their last stage to end. */
|
|
6511
6968
|
completions = /* @__PURE__ */ new Map();
|
|
6512
6969
|
completing = /* @__PURE__ */ new Set();
|
|
6970
|
+
/** Running turns that must run alone among their citizen's, by session key: see `heldBack`. */
|
|
6971
|
+
exclusive = /* @__PURE__ */ new Map();
|
|
6972
|
+
/** Tasks being landed now; a turn in a task's thread waits until its landing is done. */
|
|
6973
|
+
landing = /* @__PURE__ */ new Set();
|
|
6974
|
+
/** Sweeps of ended conversations' workspaces under way, which `drain` and `stop` wait for. */
|
|
6975
|
+
sweeping = /* @__PURE__ */ new Set();
|
|
6513
6976
|
state = StateSchema.parse({
|
|
6514
6977
|
cursor: null,
|
|
6515
6978
|
lastHeartbeat: {}
|
|
@@ -6560,9 +7023,10 @@ var Scheduler = class {
|
|
|
6560
7023
|
}
|
|
6561
7024
|
await this.passing?.catch(() => void 0);
|
|
6562
7025
|
await Promise.allSettled([
|
|
6563
|
-
...
|
|
7026
|
+
...this.running.values(),
|
|
6564
7027
|
...this.completing,
|
|
6565
|
-
...this.steering.values()
|
|
7028
|
+
...this.steering.values(),
|
|
7029
|
+
...this.sweeping
|
|
6566
7030
|
]);
|
|
6567
7031
|
}
|
|
6568
7032
|
/** One pass, unless one is under way already. */
|
|
@@ -6610,8 +7074,9 @@ var Scheduler = class {
|
|
|
6610
7074
|
async drain() {
|
|
6611
7075
|
await Promise.allSettled([
|
|
6612
7076
|
...this.steering.values(),
|
|
6613
|
-
...
|
|
6614
|
-
...this.completing
|
|
7077
|
+
...this.running.values(),
|
|
7078
|
+
...this.completing,
|
|
7079
|
+
...this.sweeping
|
|
6615
7080
|
]);
|
|
6616
7081
|
}
|
|
6617
7082
|
async load() {
|
|
@@ -6659,7 +7124,7 @@ var Scheduler = class {
|
|
|
6659
7124
|
reason: `mentioned by ${event.actor}`,
|
|
6660
7125
|
...messageId === void 0 ? {} : { messageId },
|
|
6661
7126
|
...channel === null ? {} : { channel }
|
|
6662
|
-
}, now,
|
|
7127
|
+
}, now, await this.postedIn(scope, channel, threadId));
|
|
6663
7128
|
}
|
|
6664
7129
|
const step = payload["step"];
|
|
6665
7130
|
if (event.actor === USER_NAME && (step === void 0 || step === null)) {
|
|
@@ -6714,6 +7179,21 @@ var Scheduler = class {
|
|
|
6714
7179
|
if (creator !== null && creator !== event.actor) {
|
|
6715
7180
|
await this.wakeCreator(taskId, project, creator, event.actor, now);
|
|
6716
7181
|
}
|
|
7182
|
+
const threaded = await this.board.readThread(taskId).then(() => true, () => false);
|
|
7183
|
+
if (!threaded) {
|
|
7184
|
+
this.sweep({ kind: "thread", id: taskId });
|
|
7185
|
+
}
|
|
7186
|
+
return;
|
|
7187
|
+
}
|
|
7188
|
+
case "thread.closed": {
|
|
7189
|
+
const threadId = stringOf(payload["threadId"]);
|
|
7190
|
+
if (threadId !== null) {
|
|
7191
|
+
this.sweep({ kind: "thread", id: threadId });
|
|
7192
|
+
}
|
|
7193
|
+
return;
|
|
7194
|
+
}
|
|
7195
|
+
case "workspace.leftovers": {
|
|
7196
|
+
await this.wakeClosing(payload, now);
|
|
6717
7197
|
return;
|
|
6718
7198
|
}
|
|
6719
7199
|
case "task.updated": {
|
|
@@ -6791,6 +7271,26 @@ var Scheduler = class {
|
|
|
6791
7271
|
await this.wakeNewcomer(name, project, now);
|
|
6792
7272
|
return;
|
|
6793
7273
|
}
|
|
7274
|
+
case "channel.archived": {
|
|
7275
|
+
const channel = stringOf(payload["channel"]);
|
|
7276
|
+
if (channel === null) {
|
|
7277
|
+
return;
|
|
7278
|
+
}
|
|
7279
|
+
const archived = parseChannelRef(channel);
|
|
7280
|
+
this.sweep({
|
|
7281
|
+
kind: "channel",
|
|
7282
|
+
scope: archived.project ?? SOCIETY_SCOPE,
|
|
7283
|
+
name: archived.channel
|
|
7284
|
+
});
|
|
7285
|
+
for (const [key, item] of this.pending) {
|
|
7286
|
+
const { dispatch } = item;
|
|
7287
|
+
if (dispatch.channel !== void 0 && channelConversation(dispatch.project, channel) === dispatch.channel) {
|
|
7288
|
+
this.pending.delete(key);
|
|
7289
|
+
this.log.info({ agent: dispatch.agent, project: dispatch.project, channel }, "dropping channel wake, the channel is archived");
|
|
7290
|
+
}
|
|
7291
|
+
}
|
|
7292
|
+
return;
|
|
7293
|
+
}
|
|
6794
7294
|
case "agent.left": {
|
|
6795
7295
|
const name = stringOf(payload["name"]);
|
|
6796
7296
|
const project = stringOf(payload["project"]);
|
|
@@ -6861,7 +7361,7 @@ var Scheduler = class {
|
|
|
6861
7361
|
reason: `the user posted in ${channel ?? "a channel"}`,
|
|
6862
7362
|
...messageId === void 0 ? {} : { messageId },
|
|
6863
7363
|
...channel === null ? {} : { channel }
|
|
6864
|
-
}, now,
|
|
7364
|
+
}, now, await this.postedIn(scope, channel, threadId));
|
|
6865
7365
|
}
|
|
6866
7366
|
}
|
|
6867
7367
|
/**
|
|
@@ -6935,7 +7435,7 @@ var Scheduler = class {
|
|
|
6935
7435
|
from,
|
|
6936
7436
|
reason: `stage "${name}" of task ${task.id} "${task.title}" ${state}`,
|
|
6937
7437
|
taskId: task.id
|
|
6938
|
-
}, now, { id: task.id, task: scope === task.project });
|
|
7438
|
+
}, now, { thread: { id: task.id, task: scope === task.project } });
|
|
6939
7439
|
return sessionKey(agent.name, scope, task.id);
|
|
6940
7440
|
}
|
|
6941
7441
|
/**
|
|
@@ -6996,6 +7496,49 @@ var Scheduler = class {
|
|
|
6996
7496
|
const scope = this.scopeForProject(agent, project);
|
|
6997
7497
|
this.enqueue(agent.name, scope, { kind: "task_done", from, reason: `task ${taskId} you created is done`, taskId }, now);
|
|
6998
7498
|
}
|
|
7499
|
+
/**
|
|
7500
|
+
* Whether a dispatch is in the thread of a task whose landing is queued or under way: it waits,
|
|
7501
|
+
* so a stream of turns there cannot hold the landing off, nor commit to the branch during it.
|
|
7502
|
+
*/
|
|
7503
|
+
landingIn(dispatch) {
|
|
7504
|
+
const thread = dispatch.thread?.id;
|
|
7505
|
+
return thread !== void 0 && (this.completions.has(thread) || this.landing.has(thread));
|
|
7506
|
+
}
|
|
7507
|
+
/** Asks the runners to remove an ended conversation's workspaces, without holding up the pass. */
|
|
7508
|
+
sweep(conversation) {
|
|
7509
|
+
const sweep = this.runner.sweep?.bind(this.runner);
|
|
7510
|
+
if (sweep === void 0) {
|
|
7511
|
+
return;
|
|
7512
|
+
}
|
|
7513
|
+
const promise = sweep(conversation).catch((error) => {
|
|
7514
|
+
this.log.warn({ conversation, error: String(error) }, "sweeping workspaces failed");
|
|
7515
|
+
}).finally(() => {
|
|
7516
|
+
this.sweeping.delete(promise);
|
|
7517
|
+
});
|
|
7518
|
+
this.sweeping.add(promise);
|
|
7519
|
+
}
|
|
7520
|
+
/**
|
|
7521
|
+
* A citizen's workspace in a conversation that has ended holds work on no branch: one closing turn
|
|
7522
|
+
* there, in the same session and workspace, decides whether it matters and whose it is.
|
|
7523
|
+
*/
|
|
7524
|
+
async wakeClosing(payload, now) {
|
|
7525
|
+
const name = stringOf(payload["agent"]);
|
|
7526
|
+
const scope = stringOf(payload["project"]);
|
|
7527
|
+
const thread = stringOf(payload["thread"]);
|
|
7528
|
+
const channel = stringOf(payload["channel"]);
|
|
7529
|
+
const agent = name === null ? null : await this.tryReadAgent(name);
|
|
7530
|
+
if (agent === null || scope === null) {
|
|
7531
|
+
return;
|
|
7532
|
+
}
|
|
7533
|
+
const beside = thread !== null ? { thread: await this.conversationOf(thread, scope) } : channel !== null ? { channel } : null;
|
|
7534
|
+
if (beside === null) {
|
|
7535
|
+
return;
|
|
7536
|
+
}
|
|
7537
|
+
this.enqueue(agent.name, scope, {
|
|
7538
|
+
kind: "closing",
|
|
7539
|
+
reason: "a conversation you took part in has ended, and its workspace holds work on no branch"
|
|
7540
|
+
}, now, beside);
|
|
7541
|
+
}
|
|
6999
7542
|
/** A decided proposal wakes the citizen that made it, to carry on with what it enables. */
|
|
7000
7543
|
async wakeProposer(proposalId, proposer, event, now) {
|
|
7001
7544
|
const agent = await this.tryReadAgent(proposer);
|
|
@@ -7025,6 +7568,17 @@ var Scheduler = class {
|
|
|
7025
7568
|
return { id: threadId, task: false };
|
|
7026
7569
|
}
|
|
7027
7570
|
}
|
|
7571
|
+
/**
|
|
7572
|
+
* The conversation a post wakes a citizen in, for a turn in `scope`: its thread's, else its
|
|
7573
|
+
* channel's when that is a channel of the scope other than general, else the scope's home.
|
|
7574
|
+
*/
|
|
7575
|
+
async postedIn(scope, channel, threadId) {
|
|
7576
|
+
if (threadId !== void 0) {
|
|
7577
|
+
return { thread: await this.conversationOf(threadId, scope) };
|
|
7578
|
+
}
|
|
7579
|
+
const name = channel === null ? void 0 : channelConversation(scope, channel);
|
|
7580
|
+
return name === void 0 ? void 0 : { channel: name };
|
|
7581
|
+
}
|
|
7028
7582
|
async tryReadAgent(name) {
|
|
7029
7583
|
try {
|
|
7030
7584
|
const agent = await this.board.readAgent(name);
|
|
@@ -7040,8 +7594,8 @@ var Scheduler = class {
|
|
|
7040
7594
|
scopeForProject(agent, project) {
|
|
7041
7595
|
return wakeScope(agent, project);
|
|
7042
7596
|
}
|
|
7043
|
-
/** Queues a wake in one conversation: a thread's, or without
|
|
7044
|
-
enqueue(agent, project, input, now,
|
|
7597
|
+
/** Queues a wake in one conversation: a thread's, a channel's, or without either, the scope's home. */
|
|
7598
|
+
enqueue(agent, project, input, now, beside) {
|
|
7045
7599
|
const trigger = TriggerSchema.parse(input);
|
|
7046
7600
|
const decision = decideWake({
|
|
7047
7601
|
trigger,
|
|
@@ -7054,7 +7608,7 @@ var Scheduler = class {
|
|
|
7054
7608
|
return;
|
|
7055
7609
|
}
|
|
7056
7610
|
const debounce = this.debounceFor(trigger);
|
|
7057
|
-
const key =
|
|
7611
|
+
const key = conversationKey2(agent, project, beside);
|
|
7058
7612
|
const readyAt = now + debounce;
|
|
7059
7613
|
const existing = this.pending.get(key);
|
|
7060
7614
|
const message = messageOf(trigger);
|
|
@@ -7063,7 +7617,7 @@ var Scheduler = class {
|
|
|
7063
7617
|
dispatch: {
|
|
7064
7618
|
agent,
|
|
7065
7619
|
project,
|
|
7066
|
-
...
|
|
7620
|
+
...besideFields(beside),
|
|
7067
7621
|
trigger,
|
|
7068
7622
|
priority: decision.priority,
|
|
7069
7623
|
onboarding: trigger.kind === "onboarding"
|
|
@@ -7113,7 +7667,8 @@ var Scheduler = class {
|
|
|
7113
7667
|
}
|
|
7114
7668
|
/**
|
|
7115
7669
|
* A heartbeat per member and scope on the cadence, which then looks at every conversation of the
|
|
7116
|
-
* scope: the unread messages of its home and of each thread, a stage the
|
|
7670
|
+
* scope: the unread messages of its home, of each other channel, and of each thread, a stage the
|
|
7671
|
+
* member holds, and a stage
|
|
7117
7672
|
* waiting that it may take, each in its task's thread. Each conversation with something wakes on
|
|
7118
7673
|
* its own, so a heartbeat never brings one task's business into another's turn.
|
|
7119
7674
|
*/
|
|
@@ -7144,31 +7699,31 @@ var Scheduler = class {
|
|
|
7144
7699
|
unread ??= await this.board.unreadByConversation(actor);
|
|
7145
7700
|
held ??= await this.board.heldClaims(agent.name);
|
|
7146
7701
|
const reasons = /* @__PURE__ */ new Map();
|
|
7147
|
-
const reasonsIn = (
|
|
7148
|
-
const key =
|
|
7702
|
+
const reasonsIn = (beside) => {
|
|
7703
|
+
const key = conversationKey2(agent.name, project, beside);
|
|
7149
7704
|
const existing = reasons.get(key);
|
|
7150
7705
|
if (existing !== void 0) {
|
|
7151
7706
|
return existing;
|
|
7152
7707
|
}
|
|
7153
|
-
const fresh = {
|
|
7708
|
+
const fresh = { beside, digestSize: 0, claimsHeld: 0, waiting: 0 };
|
|
7154
7709
|
reasons.set(key, fresh);
|
|
7155
7710
|
return fresh;
|
|
7156
7711
|
};
|
|
7157
7712
|
for (const entry of unread) {
|
|
7158
7713
|
if (entry.scope === project) {
|
|
7159
|
-
const
|
|
7160
|
-
reasonsIn(
|
|
7714
|
+
const beside = entry.thread !== null ? { thread: await this.conversationOf(entry.thread, project) } : entry.channel !== null ? { channel: entry.channel } : void 0;
|
|
7715
|
+
reasonsIn(beside).digestSize += entry.count;
|
|
7161
7716
|
}
|
|
7162
7717
|
}
|
|
7163
7718
|
for (const task of held) {
|
|
7164
7719
|
if (task.project === project) {
|
|
7165
|
-
reasonsIn({ id: task.id, task: true }).claimsHeld += 1;
|
|
7720
|
+
reasonsIn({ thread: { id: task.id, task: true } }).claimsHeld += 1;
|
|
7166
7721
|
}
|
|
7167
7722
|
}
|
|
7168
7723
|
if (project !== SOCIETY_SCOPE) {
|
|
7169
7724
|
for (const task of await this.board.openTasks(project)) {
|
|
7170
7725
|
if (mayHoldStage(agent, task)) {
|
|
7171
|
-
reasonsIn({ id: task.id, task: true }).waiting += 1;
|
|
7726
|
+
reasonsIn({ thread: { id: task.id, task: true } }).waiting += 1;
|
|
7172
7727
|
}
|
|
7173
7728
|
}
|
|
7174
7729
|
}
|
|
@@ -7189,7 +7744,7 @@ var Scheduler = class {
|
|
|
7189
7744
|
dispatch: {
|
|
7190
7745
|
agent: agent.name,
|
|
7191
7746
|
project,
|
|
7192
|
-
...reason.
|
|
7747
|
+
...besideFields(reason.beside),
|
|
7193
7748
|
trigger,
|
|
7194
7749
|
priority: decision.priority,
|
|
7195
7750
|
onboarding: false
|
|
@@ -7227,8 +7782,8 @@ var Scheduler = class {
|
|
|
7227
7782
|
this.state.lastReflection[agent.name] = iso(now);
|
|
7228
7783
|
continue;
|
|
7229
7784
|
}
|
|
7230
|
-
const
|
|
7231
|
-
if (
|
|
7785
|
+
const busy = [...this.pending.keys(), ...this.running.keys()].some((key) => parseSessionKey(key)?.agent === agent.name);
|
|
7786
|
+
if (busy) {
|
|
7232
7787
|
continue;
|
|
7233
7788
|
}
|
|
7234
7789
|
this.state.lastReflection[agent.name] = iso(now);
|
|
@@ -7526,22 +8081,6 @@ var Scheduler = class {
|
|
|
7526
8081
|
this.state.costSinceReport = 0;
|
|
7527
8082
|
this.state.turnsSinceReport = 0;
|
|
7528
8083
|
}
|
|
7529
|
-
/**
|
|
7530
|
-
* The lane a turn runs in: a task's conversation has its own, with a worktree of its own; a
|
|
7531
|
-
* member's home conversation and its proposal and topic threads in a scope share the scope's
|
|
7532
|
-
* worktree, and so one lane, one turn at a time.
|
|
7533
|
-
*/
|
|
7534
|
-
laneOf(dispatch) {
|
|
7535
|
-
return dispatch.thread?.task === true ? sessionKey(dispatch.agent, dispatch.project, dispatch.thread.id) : sessionKey(dispatch.agent, dispatch.project);
|
|
7536
|
-
}
|
|
7537
|
-
laneBusy(lane) {
|
|
7538
|
-
for (const turn of this.running.values()) {
|
|
7539
|
-
if (turn.lane === lane) {
|
|
7540
|
-
return true;
|
|
7541
|
-
}
|
|
7542
|
-
}
|
|
7543
|
-
return false;
|
|
7544
|
-
}
|
|
7545
8084
|
/**
|
|
7546
8085
|
* Delivers ready message wakes into the turn already running in their conversation, rather than
|
|
7547
8086
|
* keeping them for a turn of their own. Each stays queued until that turn ends and is checked
|
|
@@ -7561,7 +8100,8 @@ var Scheduler = class {
|
|
|
7561
8100
|
const attempt = steer({
|
|
7562
8101
|
agent: dispatch.agent,
|
|
7563
8102
|
project: dispatch.project,
|
|
7564
|
-
...dispatch.thread === void 0 ? {} : { thread: dispatch.thread.id }
|
|
8103
|
+
...dispatch.thread === void 0 ? {} : { thread: dispatch.thread.id },
|
|
8104
|
+
...dispatch.channel === void 0 ? {} : { channel: dispatch.channel }
|
|
7565
8105
|
}).then((outcome) => {
|
|
7566
8106
|
const current = this.pending.get(key);
|
|
7567
8107
|
if (outcome !== "busy" && current !== void 0) {
|
|
@@ -7582,18 +8122,17 @@ var Scheduler = class {
|
|
|
7582
8122
|
return false;
|
|
7583
8123
|
}
|
|
7584
8124
|
const { dispatch } = item;
|
|
7585
|
-
const cursor = await this.board.digestCursor(dispatch.agent, dispatch.project, dispatch.thread?.id);
|
|
8125
|
+
const cursor = await this.board.digestCursor(dispatch.agent, dispatch.project, conversationPart({ thread: dispatch.thread?.id, channel: dispatch.channel }));
|
|
7586
8126
|
return cursor !== null && cursor >= item.newestMessage;
|
|
7587
8127
|
}
|
|
7588
8128
|
async dispatchReady(now) {
|
|
7589
8129
|
this.steerRunning(now);
|
|
7590
|
-
const ready = [...this.pending.entries()].filter(([key, item]) => item.readyAt <= now && !this.running.has(key)).toSorted(([, a], [, b]) => b.dispatch.priority - a.dispatch.priority || a.readyAt - b.readyAt);
|
|
8130
|
+
const ready = [...this.pending.entries()].filter(([key, item]) => item.readyAt <= now && !this.running.has(key) && !this.landingIn(item.dispatch)).toSorted(([, a], [, b]) => b.dispatch.priority - a.dispatch.priority || a.readyAt - b.readyAt);
|
|
7591
8131
|
for (const [key, item] of ready) {
|
|
7592
8132
|
if (this.running.size >= this.concurrency) {
|
|
7593
8133
|
break;
|
|
7594
8134
|
}
|
|
7595
|
-
|
|
7596
|
-
if (this.laneBusy(lane)) {
|
|
8135
|
+
if (this.heldBack(key, item.dispatch)) {
|
|
7597
8136
|
continue;
|
|
7598
8137
|
}
|
|
7599
8138
|
this.pending.delete(key);
|
|
@@ -7619,6 +8158,7 @@ var Scheduler = class {
|
|
|
7619
8158
|
agent: dispatch.agent,
|
|
7620
8159
|
project: dispatch.project,
|
|
7621
8160
|
thread: dispatch.thread?.id,
|
|
8161
|
+
channel: dispatch.channel,
|
|
7622
8162
|
trigger: dispatch.trigger.kind
|
|
7623
8163
|
}, "dispatching turn");
|
|
7624
8164
|
const promise = (async () => {
|
|
@@ -7634,19 +8174,45 @@ var Scheduler = class {
|
|
|
7634
8174
|
this.log.error({ agent: dispatch.agent, project: dispatch.project, error: String(error) }, "turn crashed");
|
|
7635
8175
|
} finally {
|
|
7636
8176
|
this.running.delete(key);
|
|
8177
|
+
this.exclusive.delete(key);
|
|
7637
8178
|
}
|
|
7638
8179
|
})();
|
|
7639
|
-
this.running.set(key,
|
|
8180
|
+
this.running.set(key, promise);
|
|
8181
|
+
if (dispatch.onboarding) {
|
|
8182
|
+
this.exclusive.set(key, "onboarding");
|
|
8183
|
+
} else if (dispatch.trigger.kind === "reflection") {
|
|
8184
|
+
this.exclusive.set(key, "reflection");
|
|
8185
|
+
}
|
|
8186
|
+
}
|
|
8187
|
+
}
|
|
8188
|
+
/**
|
|
8189
|
+
* Whether a ready turn waits for another of its citizen's turns that must run alone: its
|
|
8190
|
+
* onboarding in the project, queued or running, which comes before any other work of its there;
|
|
8191
|
+
* and a reflection, which consolidates the memory the citizen's other turns would be writing, so
|
|
8192
|
+
* it starts only once none of them runs and they wait while it does.
|
|
8193
|
+
*/
|
|
8194
|
+
heldBack(key, dispatch) {
|
|
8195
|
+
const { agent, project } = dispatch;
|
|
8196
|
+
const home = sessionKey(agent, project);
|
|
8197
|
+
if (key !== home && (this.pending.get(home)?.dispatch.onboarding === true || this.exclusive.get(home) === "onboarding")) {
|
|
8198
|
+
return true;
|
|
8199
|
+
}
|
|
8200
|
+
const others = [...this.running.keys()].filter((other) => other !== key && parseSessionKey(other)?.agent === agent);
|
|
8201
|
+
if (dispatch.trigger.kind === "reflection") {
|
|
8202
|
+
return others.length > 0;
|
|
7640
8203
|
}
|
|
8204
|
+
return others.some((other) => this.exclusive.get(other) === "reflection");
|
|
7641
8205
|
}
|
|
7642
8206
|
/** Starts each queued completion once the turn that finished the task's last stage has ended. */
|
|
7643
8207
|
runCompletions() {
|
|
7644
8208
|
for (const [taskId, item] of this.completions) {
|
|
7645
8209
|
const home = sessionKey(item.actor, item.project);
|
|
7646
|
-
|
|
8210
|
+
const busy = [...this.running.keys()].some((key) => key === home || key.startsWith(`${home}/`) || parseSessionKey(key)?.thread === taskId);
|
|
8211
|
+
if (busy) {
|
|
7647
8212
|
continue;
|
|
7648
8213
|
}
|
|
7649
8214
|
this.completions.delete(taskId);
|
|
8215
|
+
this.landing.add(taskId);
|
|
7650
8216
|
const promise = this.runner.completeTask(item.project, taskId).then((outcome) => {
|
|
7651
8217
|
if (outcome === "deferred" && !this.completions.has(taskId)) {
|
|
7652
8218
|
this.completions.set(taskId, item);
|
|
@@ -7656,6 +8222,7 @@ var Scheduler = class {
|
|
|
7656
8222
|
this.log.error({ project: item.project, taskId, error: String(error) }, "completion failed");
|
|
7657
8223
|
}).finally(() => {
|
|
7658
8224
|
this.completing.delete(promise);
|
|
8225
|
+
this.landing.delete(taskId);
|
|
7659
8226
|
});
|
|
7660
8227
|
this.completing.add(promise);
|
|
7661
8228
|
}
|
|
@@ -7667,16 +8234,17 @@ import { randomUUID } from "node:crypto";
|
|
|
7667
8234
|
|
|
7668
8235
|
// packages/turn-host/dist/render.js
|
|
7669
8236
|
var TURN_CONTRACT = [
|
|
7670
|
-
"- Every turn belongs to one conversation, named at the top of your prompt: your home conversation in a project or the society, for its
|
|
8237
|
+
"- 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.",
|
|
7671
8238
|
"- Act through the board tools (the `board` MCP server). Holding a stage is a lease; every turn that touches the task renews it.",
|
|
7672
|
-
"- 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.",
|
|
8239
|
+
"- 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.",
|
|
7673
8240
|
"- 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.",
|
|
7674
8241
|
"- Silence is allowed. If the digest needs no reply, post nothing.",
|
|
7675
8242
|
"- An @mention wakes the citizen named, and every wake costs a turn. Address someone with @ only when you need them to act; when you merely refer to citizens, write their names plainly. @user wakes nobody, since the user takes no turns; it is what puts a question before the user, so use it only when you need the user to answer or decide.",
|
|
7676
|
-
"- A thread is a conversation off a channel that reaches only its participants and whoever is mentioned in it. Every task has one, opened with it under the task's id on its project
|
|
8243
|
+
"- A thread is a conversation off a channel that reaches only its participants and whoever is mentioned in it. Every task has one, opened with it under the task's id on the channel of its project it was filed in, general unless it was filed in another: talk about a task there, with post_message and its thread_id, and the notes you give advance_task and update_task are posted there as you. Its participants are the task's creator, its stage holders and assignees, whoever may take the stage that waits, and anyone who posted; it closes when the task ends. Every proposal has one too, in governance, opened with its pitch and closed by its decision. Open other threads with open_thread on a channel with a title, for any other topic; close_thread posts your summary to its channel.",
|
|
7677
8244
|
"- Route every lesson: about you, your craft, or the user, write it to memory/core.md in your home directory, and keep that file short by moving detail to memory/<topic>.md, your archive; a durable fact about a project's codebase or process goes through `write_knowledge` on that project; something the whole project should know now, post it to the project channel, and anything about one task, in the task's thread.",
|
|
7678
8245
|
'- A procedure you have followed twice is a skill: write it to skills/<name>/SKILL.md in your home, frontmatter with `name` and a one-line `description` and then the steps, and your skills index lists it from the next turn. Propose it with kind "skill" when the whole society would use it.',
|
|
7679
8246
|
"- Report memoryUpdated: true in the status object whenever you changed memory/core.md or a skill, so a warm session restarts with the new instructions.",
|
|
8247
|
+
"- Other turns of yours may be running at the same time and share your home. Change a file there with your edit tool, which refuses a file changed since you read it, and never rewrite one whole from an earlier read, with a shell redirect or a script, or you erase what another of your turns wrote.",
|
|
7680
8248
|
"- To ask the user something, mention @user where the question belongs: the task's thread for a task, the proposal's thread for a proposal, and for anything else a thread you open on your scope's general channel, titled with the question. The user answers in the same thread, and the answer comes back to that thread's conversation. A proposal waiting on the user is already in front of them and needs no mention.",
|
|
7681
8249
|
"- End every turn with the status object: summary, claims held, what is blocked, and whether the user must decide. If you report that the user must decide, mentioned @user nowhere this turn, and proposed nothing for the user to decide, the runner asks in this turn's thread when it has one, and otherwise opens a thread on your scope's general channel with your summary as the question."
|
|
7682
8250
|
].join("\n");
|
|
@@ -7698,7 +8266,7 @@ var GOVERNANCE = [
|
|
|
7698
8266
|
' - 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',
|
|
7699
8267
|
' - reallocation: {"description"}',
|
|
7700
8268
|
` - skill: {"name", "summary", "body"}: the SKILL.md text; approval publishes it under society/skills, where every citizen's skills index lists it`,
|
|
7701
|
-
"- 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 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.",
|
|
8269
|
+
"- 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.",
|
|
7702
8270
|
"- Prefer scaling an existing role over inventing one; 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.",
|
|
7703
8271
|
"- 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."
|
|
7704
8272
|
].join("\n");
|
|
@@ -7709,6 +8277,9 @@ function renderOnboardingPreamble(context) {
|
|
|
7709
8277
|
`Your role in one paragraph: ${context.roleSummary}`,
|
|
7710
8278
|
"",
|
|
7711
8279
|
`You are working on project "${context.project}" in the worktree at ${context.worktree}.`,
|
|
8280
|
+
...(context.otherChannels ?? []).length === 0 ? [] : [
|
|
8281
|
+
`You follow its general channel. It also has ${(context.otherChannels ?? []).map((ref) => `#${ref}`).join(", ")}, each a workstream such as a release: subscribe to one when your work there needs its posts. Its tasks and mentions reach you either way.`
|
|
8282
|
+
],
|
|
7712
8283
|
"Your memory is empty. Write durable lessons about yourself, your craft, or the user to memory/core.md in your home directory.",
|
|
7713
8284
|
"Write profile.md in your home directory: one paragraph on what you do well and what to send your way. The roster the front desk routes with is built from it.",
|
|
7714
8285
|
"Read the project's instructions file, if it has one, before doing anything else.",
|
|
@@ -7734,7 +8305,7 @@ function renderInstructions(input) {
|
|
|
7734
8305
|
if (norms.length > 0) {
|
|
7735
8306
|
sections.push("", "## Society norms", "", norms);
|
|
7736
8307
|
}
|
|
7737
|
-
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: it is
|
|
8308
|
+
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/). Search it with your file tools; act through the board tools.`, "", "## Turn contract", "", TURN_CONTRACT, "", "## Planning", "", PLANNING, "", "## Governance", "", GOVERNANCE);
|
|
7738
8309
|
if (input.onboarding !== void 0) {
|
|
7739
8310
|
sections.push("", "## First turn", "", renderOnboardingPreamble(input.onboarding));
|
|
7740
8311
|
}
|
|
@@ -7871,8 +8442,11 @@ function buildTurnPrompt(input) {
|
|
|
7871
8442
|
}
|
|
7872
8443
|
const conversation = input.conversation ?? null;
|
|
7873
8444
|
lines.push("", "## This conversation", "");
|
|
7874
|
-
if (conversation === null) {
|
|
7875
|
-
|
|
8445
|
+
if (conversation === null && dispatch.channel !== void 0) {
|
|
8446
|
+
const ref = channelRef(dispatch.project === SOCIETY_SCOPE ? null : dispatch.project, dispatch.channel);
|
|
8447
|
+
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}.`);
|
|
8448
|
+
} else if (conversation === null) {
|
|
8449
|
+
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.`);
|
|
7876
8450
|
} else {
|
|
7877
8451
|
const { thread, task } = conversation;
|
|
7878
8452
|
const about = thread.subject === void 0 ? "a topic" : `${thread.subject.kind} ${thread.subject.id}`;
|
|
@@ -7884,6 +8458,11 @@ function buildTurnPrompt(input) {
|
|
|
7884
8458
|
}
|
|
7885
8459
|
}
|
|
7886
8460
|
}
|
|
8461
|
+
const ending = input.ending ?? null;
|
|
8462
|
+
if (ending !== null) {
|
|
8463
|
+
lines.push("", "## This conversation has ended", "", `${capitalized(ending.how)}. You are woken once more because your workspace here still holds work on no branch, listed under Your workspace, and it is removed when this turn ends, whatever it still holds. You may not be the one who ended the conversation, and the work may not be yours to finish, so decide for each piece:`, "- It is yours and it matters: land it through a task. File one, or use one you hold, switch this worktree to its branch task/<id> (git switch -c task/<id> for a task filed in this turn), and commit there.", `- It matters but is someone else's to finish: put it on a task's branch the same way, then hand that task to whoever should own it and ask them in ${ending.askIn} with a mention, since this conversation takes no more posts.`, "- It is disposable: leave it, and it goes with the workspace.");
|
|
8464
|
+
}
|
|
8465
|
+
lines.push("", WORKSPACE_TOKEN);
|
|
7887
8466
|
const conflicts = input.conflicts ?? [];
|
|
7888
8467
|
if (dispatch.trigger.kind === "reflection") {
|
|
7889
8468
|
lines.push("", "## Reflection", "", REFLECTION_INTRO, ...conflicts.length > 0 ? [RECONCILE] : [], ...REFLECTION_STEPS);
|
|
@@ -7900,7 +8479,7 @@ function buildTurnPrompt(input) {
|
|
|
7900
8479
|
}
|
|
7901
8480
|
const society = input.societyView;
|
|
7902
8481
|
if (society !== void 0 && society !== null) {
|
|
7903
|
-
lines.push("", "## The society", "", "### Projects", "");
|
|
8482
|
+
lines.push("", "## The society", "", `Each ask is a conversation of its own, and others may be in a turn at the same time. Before proposing a citizen, a role, or a project, look at the proposals still waiting under ${PATH_TOKENS.board}/society/proposals/: another ask may have proposed it already.`, "", "### Projects", "");
|
|
7904
8483
|
if (society.projects.length === 0) {
|
|
7905
8484
|
lines.push("None yet.");
|
|
7906
8485
|
}
|
|
@@ -7982,6 +8561,9 @@ function buildTurnPrompt(input) {
|
|
|
7982
8561
|
return `${lines.join("\n")}
|
|
7983
8562
|
`;
|
|
7984
8563
|
}
|
|
8564
|
+
function capitalized(text) {
|
|
8565
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
8566
|
+
}
|
|
7985
8567
|
|
|
7986
8568
|
// packages/turn-host/dist/host.js
|
|
7987
8569
|
var SILENT = { info() {
|
|
@@ -8037,11 +8619,14 @@ var TurnHost = class {
|
|
|
8037
8619
|
const agent = await this.board.readAgent(dispatch.agent);
|
|
8038
8620
|
const actor = { name: agent.name, role: agent.role };
|
|
8039
8621
|
const thread = dispatch.thread;
|
|
8622
|
+
const channel = thread === void 0 ? dispatch.channel : void 0;
|
|
8623
|
+
const part = conversationPart({ thread: thread?.id, channel });
|
|
8040
8624
|
const startedAt = this.now().toISOString();
|
|
8041
8625
|
const base = {
|
|
8042
8626
|
agent: agent.name,
|
|
8043
8627
|
project: dispatch.project,
|
|
8044
8628
|
...thread === void 0 ? {} : { thread: thread.id },
|
|
8629
|
+
...channel === void 0 ? {} : { channel },
|
|
8045
8630
|
runner: runner.name,
|
|
8046
8631
|
cli: agent.cli,
|
|
8047
8632
|
session: null,
|
|
@@ -8066,23 +8651,29 @@ var TurnHost = class {
|
|
|
8066
8651
|
const societyScope = dispatch.project === SOCIETY_SCOPE;
|
|
8067
8652
|
const taskId = thread?.task === true ? thread.id : void 0;
|
|
8068
8653
|
const project = societyScope ? null : await this.board.readProject(dispatch.project);
|
|
8069
|
-
const workspace = await this.workspaceOf(project,
|
|
8070
|
-
const sessions = await this.board.readSessions(agent.name, dispatch.project,
|
|
8654
|
+
const workspace = await this.workspaceOf(project, thread, channel);
|
|
8655
|
+
const sessions = await this.board.readSessions(agent.name, dispatch.project, part);
|
|
8071
8656
|
const recorded = sessions.runner === runner.name ? sessions[agent.cli] : void 0;
|
|
8072
8657
|
const newSession = recorded === void 0;
|
|
8073
|
-
const lastTurn = await this.board.readLastTurn(agent.name, dispatch.project,
|
|
8658
|
+
const lastTurn = await this.board.readLastTurn(agent.name, dispatch.project, part);
|
|
8074
8659
|
const costSoFarUsd = !newSession && lastTurn?.session === recorded ? lastTurn.sessionCostUsd ?? lastTurn.costUsd : 0;
|
|
8075
|
-
const digest = await this.board.readDigest({
|
|
8660
|
+
const digest = await this.board.readDigest({
|
|
8661
|
+
...actor,
|
|
8662
|
+
scope: dispatch.project,
|
|
8663
|
+
...thread === void 0 ? {} : { thread: thread.id },
|
|
8664
|
+
...channel === void 0 ? {} : { channel }
|
|
8665
|
+
}, { advance: false, limit: thread === void 0 ? 50 : 100 });
|
|
8076
8666
|
const threadSoFar = thread !== void 0 && newSession ? (await this.board.listThread(thread.id).catch(() => [])).slice(-100) : null;
|
|
8077
8667
|
const messages = threadSoFar ?? digest.messages;
|
|
8078
8668
|
const cursorAfter = threadSoFar?.at(-1)?.id ?? digest.cursor;
|
|
8079
8669
|
const task = taskId === void 0 ? null : await this.board.findTask(taskId).then((found) => found.task).catch(() => null);
|
|
8080
8670
|
const held = task === null ? [] : (await this.board.heldClaims(agent.name)).filter((each) => each.id === task.id);
|
|
8081
|
-
const onboarding = dispatch.onboarding || newSession &&
|
|
8671
|
+
const onboarding = dispatch.onboarding || newSession && part === void 0 ? {
|
|
8082
8672
|
agentName: agent.name,
|
|
8083
8673
|
roleSummary: charter.purpose,
|
|
8084
8674
|
project: dispatch.project,
|
|
8085
|
-
worktree: PATH_TOKENS.worktree
|
|
8675
|
+
worktree: PATH_TOKENS.worktree,
|
|
8676
|
+
otherChannels: (project?.channels ?? []).map((name) => channelRef(dispatch.project, name)).filter((ref) => !agent.subscriptions.includes(ref))
|
|
8086
8677
|
} : null;
|
|
8087
8678
|
const instructions = renderInstructions({
|
|
8088
8679
|
agentName: agent.name,
|
|
@@ -8118,7 +8709,7 @@ var TurnHost = class {
|
|
|
8118
8709
|
dir: `${PATH_TOKENS.board}/projects/${dispatch.project}/knowledge`,
|
|
8119
8710
|
topics: await this.board.listKnowledge(dispatch.project)
|
|
8120
8711
|
};
|
|
8121
|
-
const signals =
|
|
8712
|
+
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;
|
|
8122
8713
|
const waiting = task !== null && task.status === "open" && !task.completing && mayHoldStage(actor, task) ? [task] : [];
|
|
8123
8714
|
const threads = /* @__PURE__ */ new Map();
|
|
8124
8715
|
for (const id of new Set(messages.flatMap((message) => message.thread ?? []))) {
|
|
@@ -8129,11 +8720,13 @@ var TurnHost = class {
|
|
|
8129
8720
|
}
|
|
8130
8721
|
const threadRecord = thread === void 0 ? void 0 : threads.get(thread.id) ?? await this.board.readThread(thread.id).catch(() => void 0);
|
|
8131
8722
|
const conversation = threadRecord === void 0 ? null : { thread: threadRecord, task, fresh: threadSoFar !== null };
|
|
8723
|
+
const ending = dispatch.trigger.kind === "closing" ? await this.endingOf(dispatch.project, threadRecord, task, channel) : null;
|
|
8132
8724
|
const prompt = buildTurnPrompt({
|
|
8133
8725
|
dispatch,
|
|
8134
8726
|
messages,
|
|
8135
8727
|
threads,
|
|
8136
8728
|
conversation,
|
|
8729
|
+
ending,
|
|
8137
8730
|
heldClaims: held,
|
|
8138
8731
|
waitingStages: waiting,
|
|
8139
8732
|
project,
|
|
@@ -8145,9 +8738,9 @@ var TurnHost = class {
|
|
|
8145
8738
|
signals,
|
|
8146
8739
|
conflicts: await this.board.listHomeConflicts(agent.name)
|
|
8147
8740
|
});
|
|
8148
|
-
const resident = charter.resident &&
|
|
8741
|
+
const resident = charter.resident && part === void 0 && runner.residentClis.includes(agent.cli);
|
|
8149
8742
|
const residentKey = resident ? sessionKey(agent.name, dispatch.project) : null;
|
|
8150
|
-
const token = residentKey === null ? this.board.issueTurnToken(agent.name, agent.role, this.tokenLifetime(5 * 6e4), dispatch.project,
|
|
8743
|
+
const token = residentKey === null ? this.board.issueTurnToken(agent.name, agent.role, this.tokenLifetime(5 * 6e4), dispatch.project, part) : this.residentToken(runner, residentKey, actor, dispatch.project);
|
|
8151
8744
|
const record = await this.board.beginTurn({
|
|
8152
8745
|
...base,
|
|
8153
8746
|
session: recorded ?? null,
|
|
@@ -8165,6 +8758,7 @@ var TurnHost = class {
|
|
|
8165
8758
|
...agent.effort === void 0 ? {} : { effort: agent.effort },
|
|
8166
8759
|
scope: dispatch.project,
|
|
8167
8760
|
...thread === void 0 ? {} : { thread: thread.id },
|
|
8761
|
+
...channel === void 0 ? {} : { channel },
|
|
8168
8762
|
session: recorded ?? null,
|
|
8169
8763
|
costSoFarUsd,
|
|
8170
8764
|
instructions,
|
|
@@ -8178,7 +8772,8 @@ var TurnHost = class {
|
|
|
8178
8772
|
void this.board.heldClaims(agent.name).then((claims) => this.renewLeases(actor, claims.filter((each) => each.project === dispatch.project).map((each) => each.id))).catch(() => void 0);
|
|
8179
8773
|
}, LEASE_RENEW_MS);
|
|
8180
8774
|
keepLeases.unref?.();
|
|
8181
|
-
const { promise: ended, resolve: done } = Promise.withResolvers();
|
|
8775
|
+
const { promise: ended, resolve: done, reject: fail } = Promise.withResolvers();
|
|
8776
|
+
ended.catch(() => void 0);
|
|
8182
8777
|
this.open.set(record.id, {
|
|
8183
8778
|
job,
|
|
8184
8779
|
runner: runner.name,
|
|
@@ -8189,19 +8784,20 @@ var TurnHost = class {
|
|
|
8189
8784
|
outstanding: null,
|
|
8190
8785
|
stopRequestedBy: null,
|
|
8191
8786
|
held: held.map((each) => each.id),
|
|
8192
|
-
taskId,
|
|
8193
8787
|
costSoFarUsd,
|
|
8194
8788
|
configuredModel: agent.model ?? null,
|
|
8195
8789
|
transcript: [],
|
|
8196
8790
|
coldToken: residentKey === null ? token : null,
|
|
8197
8791
|
residentKey: residentKey === null ? null : `${runner.name}|${residentKey}`,
|
|
8198
8792
|
keepLeases,
|
|
8199
|
-
done
|
|
8793
|
+
done,
|
|
8794
|
+
fail
|
|
8200
8795
|
});
|
|
8201
8796
|
this.log.info({
|
|
8202
8797
|
agent: agent.name,
|
|
8203
8798
|
project: dispatch.project,
|
|
8204
8799
|
thread: thread?.id,
|
|
8800
|
+
channel,
|
|
8205
8801
|
runner: runner.name,
|
|
8206
8802
|
session: recorded,
|
|
8207
8803
|
newSession,
|
|
@@ -8239,6 +8835,7 @@ var TurnHost = class {
|
|
|
8239
8835
|
agent: turn.job.agent,
|
|
8240
8836
|
project: turn.job.scope,
|
|
8241
8837
|
thread: turn.job.thread,
|
|
8838
|
+
channel: turn.job.channel,
|
|
8242
8839
|
messages: sent.messages
|
|
8243
8840
|
}).catch((error) => {
|
|
8244
8841
|
this.log.warn({ turnId, error: String(error) }, "could not record a steer");
|
|
@@ -8246,19 +8843,31 @@ var TurnHost = class {
|
|
|
8246
8843
|
}
|
|
8247
8844
|
}
|
|
8248
8845
|
turn.transcript.push(entry);
|
|
8249
|
-
this.onEvent?.(turn.job.agent, turn.job.scope, entry.event,
|
|
8846
|
+
this.onEvent?.(turn.job.agent, turn.job.scope, entry.event, {
|
|
8847
|
+
thread: turn.job.thread,
|
|
8848
|
+
channel: turn.job.channel
|
|
8849
|
+
});
|
|
8250
8850
|
}
|
|
8251
8851
|
}
|
|
8252
8852
|
/** The turn in flight in a conversation, if any. */
|
|
8253
|
-
turnIn(agent, scope,
|
|
8853
|
+
turnIn(agent, scope, conversation = {}) {
|
|
8254
8854
|
for (const turn of this.open.values()) {
|
|
8255
8855
|
const { job } = turn;
|
|
8256
|
-
if (job.agent === agent && job.scope === scope && job.thread === thread) {
|
|
8856
|
+
if (job.agent === agent && job.scope === scope && job.thread === conversation.thread && job.channel === conversation.channel) {
|
|
8257
8857
|
return { turnId: job.turnId, runner: turn.runner, cli: job.cli };
|
|
8258
8858
|
}
|
|
8259
8859
|
}
|
|
8260
8860
|
return null;
|
|
8261
8861
|
}
|
|
8862
|
+
/** The turns in flight and their conversations, which the board checks before ending one. */
|
|
8863
|
+
inFlight() {
|
|
8864
|
+
return [...this.open.values()].map(({ job }) => ({
|
|
8865
|
+
agent: job.agent,
|
|
8866
|
+
scope: job.scope,
|
|
8867
|
+
...job.thread === void 0 ? {} : { thread: job.thread },
|
|
8868
|
+
...job.channel === void 0 ? {} : { channel: job.channel }
|
|
8869
|
+
}));
|
|
8870
|
+
}
|
|
8262
8871
|
/** The turns in flight, for the interface: their conversations and where a mention reaches them. */
|
|
8263
8872
|
runningTurns() {
|
|
8264
8873
|
return [...this.open.values()].map((turn) => ({
|
|
@@ -8266,8 +8875,9 @@ var TurnHost = class {
|
|
|
8266
8875
|
agent: turn.job.agent,
|
|
8267
8876
|
scope: turn.job.scope,
|
|
8268
8877
|
...turn.job.thread === void 0 ? {} : { thread: turn.job.thread },
|
|
8878
|
+
...turn.job.channel === void 0 ? {} : { channel: turn.job.channel },
|
|
8269
8879
|
cli: turn.job.cli,
|
|
8270
|
-
...turn.record.trigger.channel === void 0 ? {} : {
|
|
8880
|
+
...turn.record.trigger.channel === void 0 ? {} : { askedIn: turn.record.trigger.channel },
|
|
8271
8881
|
runner: turn.runner
|
|
8272
8882
|
}));
|
|
8273
8883
|
}
|
|
@@ -8288,7 +8898,8 @@ var TurnHost = class {
|
|
|
8288
8898
|
const digest = await this.board.readDigest({
|
|
8289
8899
|
...turn.actor,
|
|
8290
8900
|
scope: job.scope,
|
|
8291
|
-
...job.thread === void 0 ? {} : { thread: job.thread }
|
|
8901
|
+
...job.thread === void 0 ? {} : { thread: job.thread },
|
|
8902
|
+
...job.channel === void 0 ? {} : { channel: job.channel }
|
|
8292
8903
|
}, {
|
|
8293
8904
|
...turn.delivered === null ? {} : { since_cursor: turn.delivered },
|
|
8294
8905
|
advance: false,
|
|
@@ -8335,7 +8946,7 @@ var TurnHost = class {
|
|
|
8335
8946
|
}
|
|
8336
8947
|
/**
|
|
8337
8948
|
* Records a turn's end from its outcome. Returns null for a turn the host does not know, else
|
|
8338
|
-
* whether the task the turn worked
|
|
8949
|
+
* whether the task or thread the turn worked in has ended, so the runner may drop its workspace.
|
|
8339
8950
|
*/
|
|
8340
8951
|
async closeTurn(turnId, outcome) {
|
|
8341
8952
|
const turn = this.open.get(turnId);
|
|
@@ -8348,9 +8959,7 @@ var TurnHost = class {
|
|
|
8348
8959
|
this.board.revokeTurnToken(turn.coldToken);
|
|
8349
8960
|
}
|
|
8350
8961
|
const { job } = turn;
|
|
8351
|
-
|
|
8352
|
-
await this.board.writeSession(job.agent, job.scope, job.cli, outcome.session, turn.runner, job.thread);
|
|
8353
|
-
}
|
|
8962
|
+
const part = conversationPart(job);
|
|
8354
8963
|
const finished = {
|
|
8355
8964
|
...turn.record,
|
|
8356
8965
|
session: outcome.session,
|
|
@@ -8366,17 +8975,52 @@ var TurnHost = class {
|
|
|
8366
8975
|
...outcome.work === null ? {} : { work: outcome.work },
|
|
8367
8976
|
...outcome.exitReason === "stopped" ? { stoppedBy: turn.stopRequestedBy ?? USER_NAME } : {}
|
|
8368
8977
|
};
|
|
8369
|
-
|
|
8370
|
-
|
|
8371
|
-
|
|
8372
|
-
|
|
8373
|
-
|
|
8978
|
+
const ended = await this.conversationEnded(job);
|
|
8979
|
+
const closing = turn.record.trigger.kind === "closing";
|
|
8980
|
+
const decided = closing && outcome.exitReason !== "error" && outcome.exitReason !== "timeout";
|
|
8981
|
+
const recorded = await this.recorded(turn, async () => {
|
|
8982
|
+
if (outcome.session !== job.session) {
|
|
8983
|
+
await this.board.writeSession(job.agent, job.scope, job.cli, outcome.session, turn.runner, part);
|
|
8984
|
+
}
|
|
8985
|
+
if (outcome.exitReason === "completed" || outcome.exitReason === "blocked" || outcome.exitReason === "stopped") {
|
|
8986
|
+
await this.board.setDigestCursor(job.agent, job.scope, turn.delivered, part);
|
|
8987
|
+
await this.renewLeases(turn.actor, turn.held);
|
|
8988
|
+
if (outcome.status?.needsUserDecision === true) {
|
|
8989
|
+
await this.askUser(turn.actor, job, turn.record.startedAt, outcome.status.summary);
|
|
8990
|
+
}
|
|
8374
8991
|
}
|
|
8992
|
+
await this.board.finishTurn(finished, turn.transcript);
|
|
8993
|
+
const conversation = ended ? workspaceConversationOf(job) : null;
|
|
8994
|
+
if (conversation !== null && (closing ? !decided : outcome.leftovers)) {
|
|
8995
|
+
const again = await this.board.recordLeftovers({
|
|
8996
|
+
agent: job.agent,
|
|
8997
|
+
scope: job.scope,
|
|
8998
|
+
conversation
|
|
8999
|
+
});
|
|
9000
|
+
if (!again) {
|
|
9001
|
+
const where = conversation.kind === "thread" ? `thread ${conversation.id}` : `channel ${conversation.name}`;
|
|
9002
|
+
await this.board.publishSignal({
|
|
9003
|
+
kind: "stuck_workspace",
|
|
9004
|
+
key: `stuck_workspace:${job.agent}:${job.scope}:${conversationPart(job) ?? ""}`,
|
|
9005
|
+
summary: `${job.agent}'s workspace in ${where} still holds work on no branch after ${CLOSING_ATTEMPTS} closing turns failed, so it stays on runner ${turn.runner}`,
|
|
9006
|
+
value: CLOSING_ATTEMPTS,
|
|
9007
|
+
agent: job.agent,
|
|
9008
|
+
...job.scope === SOCIETY_SCOPE ? {} : { project: job.scope }
|
|
9009
|
+
});
|
|
9010
|
+
}
|
|
9011
|
+
}
|
|
9012
|
+
});
|
|
9013
|
+
if (recorded) {
|
|
9014
|
+
this.log.info({
|
|
9015
|
+
agent: job.agent,
|
|
9016
|
+
project: job.scope,
|
|
9017
|
+
thread: job.thread,
|
|
9018
|
+
channel: job.channel,
|
|
9019
|
+
exitReason: finished.exitReason
|
|
9020
|
+
}, "turn finished");
|
|
9021
|
+
turn.done(finished);
|
|
8375
9022
|
}
|
|
8376
|
-
|
|
8377
|
-
this.log.info({ agent: job.agent, project: job.scope, thread: job.thread, exitReason: finished.exitReason }, "turn finished");
|
|
8378
|
-
turn.done(finished);
|
|
8379
|
-
return { dropWorktree: await this.taskEnded(turn.taskId) };
|
|
9023
|
+
return { dropWorkspace: ended && (closing ? decided : !outcome.leftovers) };
|
|
8380
9024
|
}
|
|
8381
9025
|
/** Ends a turn whose outcome will never come, as when its runner went away, as failed. */
|
|
8382
9026
|
async abortTurn(turnId, reason) {
|
|
@@ -8396,9 +9040,26 @@ var TurnHost = class {
|
|
|
8396
9040
|
error: reason,
|
|
8397
9041
|
toolCalls: turn.transcript.filter((entry) => entry.event.type === "tool_call").length
|
|
8398
9042
|
};
|
|
8399
|
-
await this.board.finishTurn(finished, turn.transcript)
|
|
8400
|
-
|
|
8401
|
-
|
|
9043
|
+
if (await this.recorded(turn, () => this.board.finishTurn(finished, turn.transcript))) {
|
|
9044
|
+
this.log.warn({ agent: turn.job.agent, project: turn.job.scope, reason }, "turn aborted");
|
|
9045
|
+
turn.done(finished);
|
|
9046
|
+
}
|
|
9047
|
+
}
|
|
9048
|
+
/**
|
|
9049
|
+
* Records the end of a turn already taken out of `open`, and says whether it could. The scheduler
|
|
9050
|
+
* holds the turn's conversation until the turn ends, and nothing would end it once it has left
|
|
9051
|
+
* `open`, so a turn whose end could not be recorded ends with the error rather than holding its
|
|
9052
|
+
* conversation for good.
|
|
9053
|
+
*/
|
|
9054
|
+
async recorded(turn, record) {
|
|
9055
|
+
try {
|
|
9056
|
+
await record();
|
|
9057
|
+
return true;
|
|
9058
|
+
} catch (error) {
|
|
9059
|
+
this.log.error({ agent: turn.job.agent, project: turn.job.scope, error: String(error) }, "could not record the end of a turn");
|
|
9060
|
+
turn.fail(error);
|
|
9061
|
+
return false;
|
|
9062
|
+
}
|
|
8402
9063
|
}
|
|
8403
9064
|
/** Records a turn that could never start, as when no runner could take its agent's CLI. */
|
|
8404
9065
|
async refuseTurn(dispatch, runner, reason) {
|
|
@@ -8407,6 +9068,7 @@ var TurnHost = class {
|
|
|
8407
9068
|
agent: dispatch.agent,
|
|
8408
9069
|
project: dispatch.project,
|
|
8409
9070
|
...dispatch.thread === void 0 ? {} : { thread: dispatch.thread.id },
|
|
9071
|
+
...dispatch.thread !== void 0 || dispatch.channel === void 0 ? {} : { channel: dispatch.channel },
|
|
8410
9072
|
runner,
|
|
8411
9073
|
cli: agent?.cli ?? null,
|
|
8412
9074
|
session: null,
|
|
@@ -8451,43 +9113,70 @@ var TurnHost = class {
|
|
|
8451
9113
|
this.residentTokens.set(slot, token);
|
|
8452
9114
|
return token;
|
|
8453
9115
|
}
|
|
8454
|
-
|
|
9116
|
+
/**
|
|
9117
|
+
* Where a conversation works: a task's on its branch, any other thread's and a channel's in a
|
|
9118
|
+
* place of its own, and the home in the citizen's own.
|
|
9119
|
+
*/
|
|
9120
|
+
async workspaceOf(project, thread, channel) {
|
|
9121
|
+
const own = thread !== void 0 ? thread.task ? {} : { thread: thread.id } : channel === void 0 ? {} : { channel };
|
|
8455
9122
|
if (project === null) {
|
|
8456
|
-
return { kind: "home" };
|
|
9123
|
+
return { kind: "home", ...own };
|
|
8457
9124
|
}
|
|
8458
9125
|
const repo = {
|
|
8459
9126
|
slug: project.slug,
|
|
8460
9127
|
origin: project.repo,
|
|
8461
9128
|
defaultBranch: project.defaultBranch
|
|
8462
9129
|
};
|
|
8463
|
-
if (
|
|
8464
|
-
return { kind: "task", repo, taskId };
|
|
9130
|
+
if (thread?.task === true) {
|
|
9131
|
+
return { kind: "task", repo, taskId: thread.id };
|
|
8465
9132
|
}
|
|
8466
9133
|
const branches = (await this.board.listTasks(project.slug)).filter((task) => task.status === "open" || task.status === "claimed").map((task) => `task/${task.id}`);
|
|
8467
|
-
return { kind: "project", repo, branches };
|
|
9134
|
+
return { kind: "project", repo, branches, ...own };
|
|
8468
9135
|
}
|
|
8469
|
-
/**
|
|
8470
|
-
|
|
8471
|
-
|
|
8472
|
-
|
|
9136
|
+
/**
|
|
9137
|
+
* Whether a thread conversation's task or thread has ended, or a channel conversation's channel
|
|
9138
|
+
* was archived, which frees its workspace.
|
|
9139
|
+
*/
|
|
9140
|
+
async conversationEnded(job) {
|
|
9141
|
+
const conversation = workspaceConversationOf(job);
|
|
9142
|
+
return conversation !== null && await this.board.conversationEnded(conversation);
|
|
9143
|
+
}
|
|
9144
|
+
/**
|
|
9145
|
+
* How a closing turn's conversation ended, and where its citizen asks someone else to take over
|
|
9146
|
+
* work that is not its own: the thread's channel while it is open, else the place's general.
|
|
9147
|
+
*/
|
|
9148
|
+
async endingOf(scope, thread, task, channel) {
|
|
9149
|
+
const place = scope === SOCIETY_SCOPE ? null : scope;
|
|
9150
|
+
if (thread !== void 0) {
|
|
9151
|
+
const closed = `the thread "${thread.title}" on ${thread.channel} was closed${thread.closedBy === void 0 ? "" : ` by ${thread.closedBy}`}${task === null ? "" : `, its task ${task.status}`}`;
|
|
9152
|
+
const open = await this.board.channelOpen(thread.channel);
|
|
9153
|
+
return {
|
|
9154
|
+
how: closed,
|
|
9155
|
+
askIn: open ? thread.channel : channelRef(parseChannelRef(thread.channel).project, "general")
|
|
9156
|
+
};
|
|
8473
9157
|
}
|
|
8474
|
-
|
|
8475
|
-
|
|
8476
|
-
return task.status === "done" || task.status === "abandoned";
|
|
8477
|
-
} catch {
|
|
8478
|
-
return false;
|
|
9158
|
+
if (channel === void 0) {
|
|
9159
|
+
return null;
|
|
8479
9160
|
}
|
|
9161
|
+
const ref = channelRef(place, channel);
|
|
9162
|
+
const archived = (await this.board.listChannels()).find((each) => each.ref === ref)?.archived;
|
|
9163
|
+
return {
|
|
9164
|
+
how: archived === void 0 ? `${ref} was archived` : `${ref} was archived by ${archived.by}: ${archived.reason}`,
|
|
9165
|
+
askIn: channelRef(place, "general")
|
|
9166
|
+
};
|
|
8480
9167
|
}
|
|
8481
9168
|
/**
|
|
8482
9169
|
* A turn that reported the user must decide asks where the question belongs, with a mention of
|
|
8483
9170
|
* the user, which is what puts it before them. One that mentioned the user nowhere asks in its
|
|
8484
9171
|
* own thread when it was in one that is still open, so the answer comes back to the same
|
|
8485
|
-
* conversation, and otherwise in a thread of its own on its
|
|
8486
|
-
* citizen with its summary. One that proposed something the
|
|
8487
|
-
* before the user already, where deciding it clears it; a
|
|
8488
|
-
* posted beside it.
|
|
9172
|
+
* conversation, and otherwise in a thread of its own on its conversation's channel, or its scope's
|
|
9173
|
+
* general for the home, opened as the citizen with its summary. One that proposed something the
|
|
9174
|
+
* user decides has put its decision before the user already, where deciding it clears it; a
|
|
9175
|
+
* mention would stay until the user posted beside it.
|
|
8489
9176
|
*/
|
|
8490
|
-
async askUser(actor,
|
|
9177
|
+
async askUser(actor, job, since, summary) {
|
|
9178
|
+
const threadId = job.thread;
|
|
9179
|
+
const place = job.scope === SOCIETY_SCOPE ? null : job.scope;
|
|
8491
9180
|
try {
|
|
8492
9181
|
if (await this.board.hasMentioned(actor.name, USER_NAME, since)) {
|
|
8493
9182
|
return;
|
|
@@ -8505,7 +9194,7 @@ var TurnHost = class {
|
|
|
8505
9194
|
return;
|
|
8506
9195
|
}
|
|
8507
9196
|
const thread = await this.board.openThread(actor, {
|
|
8508
|
-
channel:
|
|
9197
|
+
channel: channelRef(place, job.channel ?? "general"),
|
|
8509
9198
|
title: `${actor.name} asks for a decision: ${summary}`.slice(0, 200)
|
|
8510
9199
|
});
|
|
8511
9200
|
await this.board.postMessage(actor, {
|
|
@@ -8556,6 +9245,7 @@ var DEFAULT_GRACE_MS = 2 * 6e4;
|
|
|
8556
9245
|
var DEFAULT_REQUEST_TIMEOUT_MS = 10 * 6e4;
|
|
8557
9246
|
var CONTROL_TIMEOUT_MS = 15e3;
|
|
8558
9247
|
var FILE_TIMEOUT_MS = 6e4;
|
|
9248
|
+
var SWEEP_TIMEOUT_MS = 2 * 6e4;
|
|
8559
9249
|
var RunnerHub = class {
|
|
8560
9250
|
board;
|
|
8561
9251
|
host;
|
|
@@ -8676,7 +9366,7 @@ var RunnerHub = class {
|
|
|
8676
9366
|
async turnOutcome(runner, turnId, outcome) {
|
|
8677
9367
|
this.assertOwns(runner, turnId);
|
|
8678
9368
|
const ack = await this.host.closeTurn(turnId, outcome);
|
|
8679
|
-
return ack ?? {
|
|
9369
|
+
return ack ?? { dropWorkspace: false };
|
|
8680
9370
|
}
|
|
8681
9371
|
answer(runner, request, answer) {
|
|
8682
9372
|
const pending = this.pending.get(request);
|
|
@@ -8691,6 +9381,25 @@ var RunnerHub = class {
|
|
|
8691
9381
|
pending.reject(new Error(answer.error));
|
|
8692
9382
|
}
|
|
8693
9383
|
}
|
|
9384
|
+
/**
|
|
9385
|
+
* The workspaces a runner holds, reported when its stream opens: those of conversations that
|
|
9386
|
+
* ended while it was away are swept there now, as a sweep at their end would have.
|
|
9387
|
+
*/
|
|
9388
|
+
heldWorkspaces(runner, workspaces) {
|
|
9389
|
+
const seat = this.seats.get(runner);
|
|
9390
|
+
if (seat === void 0) {
|
|
9391
|
+
return;
|
|
9392
|
+
}
|
|
9393
|
+
const conversations = /* @__PURE__ */ new Map();
|
|
9394
|
+
for (const { conversation } of workspaces) {
|
|
9395
|
+
conversations.set(JSON.stringify(conversation), conversation);
|
|
9396
|
+
}
|
|
9397
|
+
for (const conversation of conversations.values()) {
|
|
9398
|
+
void this.board.conversationEnded(conversation).then((ended) => ended ? this.sweepOn(seat, conversation) : void 0).catch((error) => {
|
|
9399
|
+
this.log.warn({ runner, conversation, error: String(error) }, "could not sweep");
|
|
9400
|
+
});
|
|
9401
|
+
}
|
|
9402
|
+
}
|
|
8694
9403
|
warmChanged(runner, warm) {
|
|
8695
9404
|
const seat = this.seats.get(runner);
|
|
8696
9405
|
if (seat !== void 0) {
|
|
@@ -8740,12 +9449,25 @@ var RunnerHub = class {
|
|
|
8740
9449
|
// -------------------------------------------------------------------------------------------
|
|
8741
9450
|
// The scheduler's runner
|
|
8742
9451
|
// -------------------------------------------------------------------------------------------
|
|
9452
|
+
/**
|
|
9453
|
+
* A conversation ended: every connected runner removes its citizens' workspaces for it, and
|
|
9454
|
+
* those kept for holding work on no branch are recorded, which gives their citizens a closing
|
|
9455
|
+
* turn. A runner away now sweeps when it reports its workspaces on reconnecting.
|
|
9456
|
+
*/
|
|
9457
|
+
async sweep(conversation) {
|
|
9458
|
+
await Promise.all([...this.seats.values()].filter((seat) => seat.send !== null).map((seat) => this.sweepOn(seat, conversation).catch((error) => {
|
|
9459
|
+
this.log.warn({ runner: seat.name, conversation, error: String(error) }, "could not sweep");
|
|
9460
|
+
})));
|
|
9461
|
+
}
|
|
8743
9462
|
/**
|
|
8744
9463
|
* Delivers what arrived in a conversation into its turn in flight, when that turn's runner can
|
|
8745
9464
|
* steer its CLI. `refused` leaves the wake to a turn of its own once this one ends.
|
|
8746
9465
|
*/
|
|
8747
9466
|
async steer(conversation) {
|
|
8748
|
-
const turn = this.host.turnIn(conversation.agent, conversation.project,
|
|
9467
|
+
const turn = this.host.turnIn(conversation.agent, conversation.project, {
|
|
9468
|
+
thread: conversation.thread,
|
|
9469
|
+
channel: conversation.channel
|
|
9470
|
+
});
|
|
8749
9471
|
const seat = turn === null ? void 0 : this.seats.get(turn.runner);
|
|
8750
9472
|
if (turn === null || seat === void 0 || seat.send === null) {
|
|
8751
9473
|
return "refused";
|
|
@@ -9050,6 +9772,12 @@ var RunnerHub = class {
|
|
|
9050
9772
|
throw new RunnerProtocolError(`turn ${turnId} is not ${runner}'s`);
|
|
9051
9773
|
}
|
|
9052
9774
|
}
|
|
9775
|
+
async sweepOn(seat, conversation) {
|
|
9776
|
+
const { kept } = SweepResultSchema.parse(await this.request(seat, (request) => ({ type: "sweep", request, conversation }), SWEEP_TIMEOUT_MS));
|
|
9777
|
+
for (const workspace of kept) {
|
|
9778
|
+
await this.board.recordLeftovers(workspace);
|
|
9779
|
+
}
|
|
9780
|
+
}
|
|
9053
9781
|
request(seat, message, timeoutMs = this.requestTimeoutMs) {
|
|
9054
9782
|
const request = randomUUID2();
|
|
9055
9783
|
return new Promise((resolve, reject) => {
|
|
@@ -9505,6 +10233,10 @@ function runnerRoutes(board2, hub, enrollments, enrollLimit) {
|
|
|
9505
10233
|
hub.answer(c.get("runner"), c.req.param("id"), RunnerAnswerSchema.parse(await c.req.json()));
|
|
9506
10234
|
return c.json({ ok: true });
|
|
9507
10235
|
});
|
|
10236
|
+
routes.post("/workspaces", async (c) => {
|
|
10237
|
+
hub.heldWorkspaces(c.get("runner"), HeldWorkspacesSchema.parse(await c.req.json()).workspaces);
|
|
10238
|
+
return c.json({ ok: true });
|
|
10239
|
+
});
|
|
9508
10240
|
routes.post("/warm", async (c) => {
|
|
9509
10241
|
hub.warmChanged(c.get("runner"), WarmSessionsSchema.parse(await c.req.json()).warm);
|
|
9510
10242
|
return c.json({ ok: true });
|
|
@@ -10101,15 +10833,16 @@ var TurnHub = class {
|
|
|
10101
10833
|
get lastSeq() {
|
|
10102
10834
|
return this.seq;
|
|
10103
10835
|
}
|
|
10104
|
-
/** Records one event of a turn, in its scope and
|
|
10105
|
-
push(agent, project, event,
|
|
10836
|
+
/** Records one event of a turn, in its scope and its conversation there: a thread's, a channel's, or home. */
|
|
10837
|
+
push(agent, project, event, conversation = {}) {
|
|
10106
10838
|
this.seq += 1;
|
|
10107
10839
|
const item = {
|
|
10108
10840
|
seq: this.seq,
|
|
10109
10841
|
ts: this.now().toISOString(),
|
|
10110
10842
|
agent,
|
|
10111
10843
|
project,
|
|
10112
|
-
...thread === void 0 ? {} : { thread },
|
|
10844
|
+
...conversation.thread === void 0 ? {} : { thread: conversation.thread },
|
|
10845
|
+
...conversation.channel === void 0 ? {} : { channel: conversation.channel },
|
|
10113
10846
|
event
|
|
10114
10847
|
};
|
|
10115
10848
|
this.buffer.push(item);
|
|
@@ -10161,12 +10894,13 @@ var host = new TurnHost({
|
|
|
10161
10894
|
maxTurns: config.toolRounds,
|
|
10162
10895
|
residentIdleMs: config.residentIdleMs,
|
|
10163
10896
|
log,
|
|
10164
|
-
onEvent: (agent, scope, event,
|
|
10165
|
-
turns.push(agent, scope, event,
|
|
10166
|
-
log.debug({ agent, scope,
|
|
10897
|
+
onEvent: (agent, scope, event, conversation) => {
|
|
10898
|
+
turns.push(agent, scope, event, conversation);
|
|
10899
|
+
log.debug({ agent, scope, ...conversation, event }, "agent event");
|
|
10167
10900
|
}
|
|
10168
10901
|
});
|
|
10169
10902
|
var runners = new RunnerHub({ board, host, version: VERSION, log });
|
|
10903
|
+
board.watchTurns(() => host.inFlight());
|
|
10170
10904
|
var concurrency = config.concurrency ?? Number.POSITIVE_INFINITY;
|
|
10171
10905
|
var timings = parseTimings(JSON.parse(process.env["STELLARIS_TIMINGS"] ?? "{}"));
|
|
10172
10906
|
var scheduler = new Scheduler({ board, runner: runners, log, concurrency, timings });
|