@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/cli/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(project2, name) {
|
|
166
|
+
const used = project2 === null ? SOCIETY_CHANNELS : PROJECT_DEFAULT_CHANNELS;
|
|
167
|
+
return used.includes(name);
|
|
168
|
+
}
|
|
159
169
|
var SOCIETY_SCOPE = "society";
|
|
160
170
|
function wakeScope(member, project2) {
|
|
161
171
|
return project2 !== null && member.memberships.includes(project2) ? project2 : 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
|
|
|
670
701
|
// packages/shared/dist/events.js
|
|
@@ -697,6 +728,7 @@ var BOARD_EVENT_TYPES = [
|
|
|
697
728
|
"proposal.decided",
|
|
698
729
|
"proposal.provisioned",
|
|
699
730
|
"channel.added",
|
|
731
|
+
"channel.archived",
|
|
700
732
|
"role.added",
|
|
701
733
|
"agent.retired",
|
|
702
734
|
"agent.joined",
|
|
@@ -718,7 +750,8 @@ var BOARD_EVENT_TYPES = [
|
|
|
718
750
|
"turn.completed",
|
|
719
751
|
"turn.failed",
|
|
720
752
|
"merge.completed",
|
|
721
|
-
"merge.failed"
|
|
753
|
+
"merge.failed",
|
|
754
|
+
"workspace.leftovers"
|
|
722
755
|
];
|
|
723
756
|
var BoardEventTypeSchema = z5.enum(BOARD_EVENT_TYPES);
|
|
724
757
|
var BoardEventSchema = z5.object({
|
|
@@ -798,6 +831,8 @@ var LiveTurnEventSchema = z5.object({
|
|
|
798
831
|
project: NameSchema,
|
|
799
832
|
/** The thread whose conversation the turn is in; absent for the home conversation. */
|
|
800
833
|
thread: UlidSchema.optional(),
|
|
834
|
+
/** The channel, never general, whose conversation the turn is in. */
|
|
835
|
+
channel: NameSchema.optional(),
|
|
801
836
|
event: AgentEventSchema
|
|
802
837
|
});
|
|
803
838
|
|
|
@@ -866,11 +901,17 @@ var ProjectRepoSchema = z6.object({
|
|
|
866
901
|
defaultBranch: z6.string().min(1)
|
|
867
902
|
});
|
|
868
903
|
var TurnWorkspaceSchema = z6.discriminatedUnion("kind", [
|
|
869
|
-
z6.object({
|
|
904
|
+
z6.object({
|
|
905
|
+
kind: z6.literal("home"),
|
|
906
|
+
thread: UlidSchema.optional(),
|
|
907
|
+
channel: NameSchema.optional()
|
|
908
|
+
}),
|
|
870
909
|
z6.object({
|
|
871
910
|
kind: z6.literal("project"),
|
|
872
911
|
repo: ProjectRepoSchema,
|
|
873
|
-
branches: z6.array(z6.string().min(1))
|
|
912
|
+
branches: z6.array(z6.string().min(1)),
|
|
913
|
+
thread: UlidSchema.optional(),
|
|
914
|
+
channel: NameSchema.optional()
|
|
874
915
|
}),
|
|
875
916
|
z6.object({ kind: z6.literal("task"), repo: ProjectRepoSchema, taskId: UlidSchema })
|
|
876
917
|
]);
|
|
@@ -886,6 +927,8 @@ var TurnJobSchema = z6.object({
|
|
|
886
927
|
scope: NameSchema,
|
|
887
928
|
/** The thread whose conversation the turn is in; absent for the home conversation. */
|
|
888
929
|
thread: UlidSchema.optional(),
|
|
930
|
+
/** The channel, never general, whose conversation the turn is in. */
|
|
931
|
+
channel: NameSchema.optional(),
|
|
889
932
|
/**
|
|
890
933
|
* The session to resume, or null to start one: the CLI's adapter picks a fresh session's id, and
|
|
891
934
|
* the outcome reports it.
|
|
@@ -919,9 +962,14 @@ var TurnOutcomeSchema = z6.object({
|
|
|
919
962
|
session: z6.string().min(1),
|
|
920
963
|
/** The model the CLI reported, when it reported one. */
|
|
921
964
|
model: z6.string().nullable(),
|
|
922
|
-
work: TurnWorkSchema.nullable()
|
|
965
|
+
work: TurnWorkSchema.nullable(),
|
|
966
|
+
/**
|
|
967
|
+
* Whether the turn's own workspace, a thread's, a channel's, or a task's, holds work on no
|
|
968
|
+
* branch after the hand-back: uncommitted changes, or commits no branch contains.
|
|
969
|
+
*/
|
|
970
|
+
leftovers: z6.boolean().default(false)
|
|
923
971
|
});
|
|
924
|
-
var TurnAckSchema = z6.object({
|
|
972
|
+
var TurnAckSchema = z6.object({ dropWorkspace: z6.boolean() });
|
|
925
973
|
var LandRequestSchema = z6.object({
|
|
926
974
|
repo: ProjectRepoSchema,
|
|
927
975
|
branch: z6.string().min(1)
|
|
@@ -978,6 +1026,17 @@ var BranchChangesSchema = z6.object({
|
|
|
978
1026
|
files: z6.array(BranchFileChangeSchema),
|
|
979
1027
|
total: z6.number().int().nonnegative()
|
|
980
1028
|
});
|
|
1029
|
+
var WorkspaceConversationSchema = z6.discriminatedUnion("kind", [
|
|
1030
|
+
z6.object({ kind: z6.literal("thread"), id: UlidSchema }),
|
|
1031
|
+
z6.object({ kind: z6.literal("channel"), scope: NameSchema, name: NameSchema })
|
|
1032
|
+
]);
|
|
1033
|
+
var HeldWorkspaceSchema = z6.object({
|
|
1034
|
+
agent: NameSchema,
|
|
1035
|
+
scope: NameSchema,
|
|
1036
|
+
conversation: WorkspaceConversationSchema
|
|
1037
|
+
});
|
|
1038
|
+
var HeldWorkspacesSchema = z6.object({ workspaces: z6.array(HeldWorkspaceSchema) });
|
|
1039
|
+
var SweepResultSchema = z6.object({ kept: z6.array(HeldWorkspaceSchema) });
|
|
981
1040
|
var TurnSteerSchema = z6.object({ id: z6.uuid(), text: z6.string().min(1) });
|
|
982
1041
|
var RunnerMessageSchema = z6.discriminatedUnion("type", [
|
|
983
1042
|
z6.object({ type: z6.literal("turn"), job: TurnJobSchema }),
|
|
@@ -999,7 +1058,16 @@ var RunnerMessageSchema = z6.discriminatedUnion("type", [
|
|
|
999
1058
|
steer: TurnSteerSchema
|
|
1000
1059
|
}),
|
|
1001
1060
|
/** Answered with whether the turn was there to stop; it ends `stopped`. */
|
|
1002
|
-
z6.object({ type: z6.literal("stop"), request: z6.string().min(1), turnId: UlidSchema })
|
|
1061
|
+
z6.object({ type: z6.literal("stop"), request: z6.string().min(1), turnId: UlidSchema }),
|
|
1062
|
+
/**
|
|
1063
|
+
* A conversation ended: the runner removes every citizen's workspace for it but those a turn
|
|
1064
|
+
* still uses, and answers with a `SweepResult` naming those it kept for holding work on no branch.
|
|
1065
|
+
*/
|
|
1066
|
+
z6.object({
|
|
1067
|
+
type: z6.literal("sweep"),
|
|
1068
|
+
request: z6.string().min(1),
|
|
1069
|
+
conversation: WorkspaceConversationSchema
|
|
1070
|
+
})
|
|
1003
1071
|
]);
|
|
1004
1072
|
var RunnerAnswerSchema = z6.discriminatedUnion("ok", [
|
|
1005
1073
|
z6.object({ ok: z6.literal(true), value: z6.unknown() }),
|
|
@@ -1027,7 +1095,8 @@ var TriggerKindSchema = z7.enum([
|
|
|
1027
1095
|
"onboarding",
|
|
1028
1096
|
"manual",
|
|
1029
1097
|
"ops_event",
|
|
1030
|
-
"user_post"
|
|
1098
|
+
"user_post",
|
|
1099
|
+
"closing"
|
|
1031
1100
|
]);
|
|
1032
1101
|
var TriggerSchema = z7.object({
|
|
1033
1102
|
kind: TriggerKindSchema,
|
|
@@ -1044,10 +1113,20 @@ var TurnDispatchSchema = z7.object({
|
|
|
1044
1113
|
agent: NameSchema,
|
|
1045
1114
|
project: NameSchema,
|
|
1046
1115
|
thread: TurnThreadSchema.optional(),
|
|
1116
|
+
channel: NameSchema.optional(),
|
|
1047
1117
|
trigger: TriggerSchema,
|
|
1048
1118
|
priority: z7.number().int().min(0).max(2),
|
|
1049
1119
|
onboarding: z7.boolean().default(false)
|
|
1050
1120
|
});
|
|
1121
|
+
function conversationPart(conversation) {
|
|
1122
|
+
return conversation.thread ?? (conversation.channel === void 0 ? void 0 : `#${conversation.channel}`);
|
|
1123
|
+
}
|
|
1124
|
+
function channelConversation(scope, channel) {
|
|
1125
|
+
const at = channel.indexOf("/");
|
|
1126
|
+
const place = at < 0 ? SOCIETY_SCOPE : channel.slice(0, at);
|
|
1127
|
+
const name = at < 0 ? channel : channel.slice(at + 1);
|
|
1128
|
+
return place === scope && name !== "general" ? name : void 0;
|
|
1129
|
+
}
|
|
1051
1130
|
var TurnRecordSchema = z7.object({
|
|
1052
1131
|
/** Assigned by the board when the turn begins; the turn's transcript is filed under it. */
|
|
1053
1132
|
id: UlidSchema.optional(),
|
|
@@ -1055,6 +1134,8 @@ var TurnRecordSchema = z7.object({
|
|
|
1055
1134
|
project: NameSchema,
|
|
1056
1135
|
/** The thread whose conversation the turn was in; absent for the home conversation. */
|
|
1057
1136
|
thread: UlidSchema.optional(),
|
|
1137
|
+
/** The channel, never general, whose conversation the turn was in. */
|
|
1138
|
+
channel: NameSchema.optional(),
|
|
1058
1139
|
runner: NameSchema,
|
|
1059
1140
|
cli: CliKindSchema.nullable(),
|
|
1060
1141
|
session: z7.string().nullable(),
|
|
@@ -1084,8 +1165,9 @@ var RunningTurnSchema = z7.object({
|
|
|
1084
1165
|
agent: NameSchema,
|
|
1085
1166
|
scope: NameSchema,
|
|
1086
1167
|
thread: UlidSchema.optional(),
|
|
1168
|
+
channel: NameSchema.optional(),
|
|
1087
1169
|
cli: CliKindSchema,
|
|
1088
|
-
|
|
1170
|
+
askedIn: ChannelRefSchema.optional(),
|
|
1089
1171
|
steerable: z7.boolean(),
|
|
1090
1172
|
stoppable: z7.boolean()
|
|
1091
1173
|
});
|
|
@@ -1112,6 +1194,8 @@ var TurnHistoryEntrySchema = z7.object({
|
|
|
1112
1194
|
project: NameSchema,
|
|
1113
1195
|
/** The thread whose conversation the turn was in; absent for the home conversation. */
|
|
1114
1196
|
thread: UlidSchema.optional(),
|
|
1197
|
+
/** The channel, never general, whose conversation the turn was in. */
|
|
1198
|
+
channel: NameSchema.optional(),
|
|
1115
1199
|
trigger: z7.string(),
|
|
1116
1200
|
exitReason: z7.string().nullable(),
|
|
1117
1201
|
costUsd: z7.number(),
|
|
@@ -1138,7 +1222,8 @@ var OPS_SIGNAL_KINDS = [
|
|
|
1138
1222
|
"turn_cost",
|
|
1139
1223
|
"scaled",
|
|
1140
1224
|
"runner",
|
|
1141
|
-
"home_conflict"
|
|
1225
|
+
"home_conflict",
|
|
1226
|
+
"stuck_workspace"
|
|
1142
1227
|
];
|
|
1143
1228
|
var OpsSignalKindSchema = z8.enum(OPS_SIGNAL_KINDS);
|
|
1144
1229
|
var OpsSignalSchema = z8.object({
|
|
@@ -1300,7 +1385,7 @@ var RunnerConfigSchema = z10.object({
|
|
|
1300
1385
|
});
|
|
1301
1386
|
|
|
1302
1387
|
// packages/shared/dist/version.js
|
|
1303
|
-
var STELLARIS_VERSION = "0.3.
|
|
1388
|
+
var STELLARIS_VERSION = "0.3.11";
|
|
1304
1389
|
|
|
1305
1390
|
// packages/board-core/dist/errors.js
|
|
1306
1391
|
var BoardError = class extends Error {
|
|
@@ -1345,15 +1430,28 @@ async function writeJson(file, value) {
|
|
|
1345
1430
|
await writeFileAtomic(file, `${JSON.stringify(value, null, 2)}
|
|
1346
1431
|
`);
|
|
1347
1432
|
}
|
|
1433
|
+
function parseMatter(raw) {
|
|
1434
|
+
return matter(raw, {});
|
|
1435
|
+
}
|
|
1348
1436
|
async function readMarkdown(file, schema) {
|
|
1349
1437
|
const raw = await readFile(file, "utf8");
|
|
1350
|
-
const parsed =
|
|
1438
|
+
const parsed = parseMatter(raw);
|
|
1351
1439
|
return { data: schema.parse(parsed.data), body: parsed.content.replace(/^\n/, "") };
|
|
1352
1440
|
}
|
|
1441
|
+
async function readLooseMarkdown(file) {
|
|
1442
|
+
const raw = await readFile(file, "utf8");
|
|
1443
|
+
try {
|
|
1444
|
+
const parsed = parseMatter(raw);
|
|
1445
|
+
return { data: parsed.data, body: parsed.content.replace(/^\n/, ""), error: null };
|
|
1446
|
+
} catch (error) {
|
|
1447
|
+
const reason = (error instanceof Error ? error.message : String(error)).split("\n")[0] ?? "";
|
|
1448
|
+
return { data: {}, body: raw, error: reason.replace(/:$/, "") };
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1353
1451
|
async function writeMarkdown(file, data, body) {
|
|
1354
1452
|
const normalized = body.endsWith("\n") || body.length === 0 ? body : `${body}
|
|
1355
1453
|
`;
|
|
1356
|
-
await writeFileAtomic(file, matter.stringify(normalized, data));
|
|
1454
|
+
await writeFileAtomic(file, matter.stringify({ content: normalized }, data, {}));
|
|
1357
1455
|
}
|
|
1358
1456
|
function isMissing(error) {
|
|
1359
1457
|
return typeof error === "object" && error !== null && error.code === "ENOENT";
|
|
@@ -1558,6 +1656,7 @@ var HOME_GITIGNORE = [
|
|
|
1558
1656
|
"/projects/*/sessions*.json",
|
|
1559
1657
|
"/projects/*/last-turn.json",
|
|
1560
1658
|
"/projects/*/threads/",
|
|
1659
|
+
"/projects/*/channels/",
|
|
1561
1660
|
`/${HOME_SCRATCH}/`,
|
|
1562
1661
|
"*.tmp",
|
|
1563
1662
|
"node_modules/",
|
|
@@ -1575,7 +1674,7 @@ var HOME_GITIGNORE = [
|
|
|
1575
1674
|
var IGNORE_FILE = `${HOME_GITIGNORE.join("\n")}
|
|
1576
1675
|
`;
|
|
1577
1676
|
var PRE_RECEIVE = `#!/bin/sh
|
|
1578
|
-
forbidden='^(agent\\.json|cursors\\.json|role\\.md|turns/|\\.claude/|\\.codex/|projects/[^/]+/(sessions[^/]*\\.json|last-turn\\.json|threads/))'
|
|
1677
|
+
forbidden='^(agent\\.json|cursors\\.json|role\\.md|turns/|\\.claude/|\\.codex/|projects/[^/]+/(sessions[^/]*\\.json|last-turn\\.json|threads/|channels/))'
|
|
1579
1678
|
while read old new ref; do
|
|
1580
1679
|
if [ "$ref" != "refs/heads/main" ]; then
|
|
1581
1680
|
echo "a home takes pushes to main only" >&2
|
|
@@ -1836,6 +1935,7 @@ var TurnPayload = z11.object({
|
|
|
1836
1935
|
turnId: z11.string().optional(),
|
|
1837
1936
|
project: z11.string(),
|
|
1838
1937
|
thread: z11.string().optional(),
|
|
1938
|
+
channel: z11.string().optional(),
|
|
1839
1939
|
trigger: z11.string()
|
|
1840
1940
|
});
|
|
1841
1941
|
var TaskPayload = z11.object({
|
|
@@ -1878,7 +1978,14 @@ function answers(mention, turn2) {
|
|
|
1878
1978
|
if (turn2.thread !== null) {
|
|
1879
1979
|
return mention.thread === turn2.thread;
|
|
1880
1980
|
}
|
|
1881
|
-
|
|
1981
|
+
if (!mention.scopes.includes(turn2.project)) {
|
|
1982
|
+
return false;
|
|
1983
|
+
}
|
|
1984
|
+
const own = mention.thread === null ? channelConversation(turn2.project, mention.channel) : void 0;
|
|
1985
|
+
if (turn2.channel !== null) {
|
|
1986
|
+
return mention.thread === null && own === turn2.channel;
|
|
1987
|
+
}
|
|
1988
|
+
return mention.thread === null && own === void 0 || turn2.trigger === "mention";
|
|
1882
1989
|
}
|
|
1883
1990
|
function computeMetrics(input) {
|
|
1884
1991
|
const { events, now, window } = input;
|
|
@@ -1943,6 +2050,7 @@ function computeMetrics(input) {
|
|
|
1943
2050
|
agent: event.actor,
|
|
1944
2051
|
at,
|
|
1945
2052
|
thread: turn2.data.thread ?? null,
|
|
2053
|
+
channel: turn2.data.channel ?? null,
|
|
1946
2054
|
project: turn2.data.project,
|
|
1947
2055
|
trigger: turn2.data.trigger
|
|
1948
2056
|
};
|
|
@@ -2039,6 +2147,7 @@ function computeMetrics(input) {
|
|
|
2039
2147
|
at: Date.parse(event.ts),
|
|
2040
2148
|
message: message.data.id ?? null,
|
|
2041
2149
|
thread: thread2,
|
|
2150
|
+
channel: message.data.channel,
|
|
2042
2151
|
scopes: [project2, wakeScope(citizen, project2)].filter((scope) => scope !== null)
|
|
2043
2152
|
});
|
|
2044
2153
|
}
|
|
@@ -2314,9 +2423,12 @@ var BoardPaths = class {
|
|
|
2314
2423
|
agentProject(name, slug) {
|
|
2315
2424
|
return path5.join(this.agentProjects(name), slug);
|
|
2316
2425
|
}
|
|
2317
|
-
/**
|
|
2318
|
-
|
|
2319
|
-
|
|
2426
|
+
/**
|
|
2427
|
+
* A thread's or a channel's conversation's session and last turn, beside the scope's home
|
|
2428
|
+
* conversation; `part` is a thread's id or `#channel`.
|
|
2429
|
+
*/
|
|
2430
|
+
agentConversation(name, slug, part) {
|
|
2431
|
+
return part.startsWith("#") ? path5.join(this.agentProject(name, slug), "channels", part.slice(1)) : path5.join(this.agentProject(name, slug), "threads", part);
|
|
2320
2432
|
}
|
|
2321
2433
|
agentCursors(name) {
|
|
2322
2434
|
return path5.join(this.agent(name), "cursors.json");
|
|
@@ -2350,6 +2462,13 @@ function hashToken(token) {
|
|
|
2350
2462
|
|
|
2351
2463
|
// packages/board-core/dist/board.js
|
|
2352
2464
|
var SYSTEM_ACTOR = { name: "board", role: USER_ROLE };
|
|
2465
|
+
var CLOSING_ATTEMPTS = 3;
|
|
2466
|
+
function ownTurn(actor, turn2) {
|
|
2467
|
+
return turn2.agent === actor.name && turn2.scope === actor.scope && turn2.thread === actor.thread && turn2.channel === actor.channel;
|
|
2468
|
+
}
|
|
2469
|
+
function namesList(names) {
|
|
2470
|
+
return names.length <= 1 ? names[0] ?? "" : `${names.slice(0, -1).join(", ")} and ${names.at(-1) ?? ""}`;
|
|
2471
|
+
}
|
|
2353
2472
|
var SKIPPED_IN_HOME = /* @__PURE__ */ new Set([".git", "turns", ".claude", ".codex"]);
|
|
2354
2473
|
var RunnerTokensSchema = z12.object({
|
|
2355
2474
|
runners: z12.record(z12.string(), z12.object({ tokenHash: z12.string().min(1) }))
|
|
@@ -2367,6 +2486,7 @@ var SignInsSchema = z12.object({
|
|
|
2367
2486
|
var TurnHistoryPayloadSchema = z12.object({
|
|
2368
2487
|
project: NameSchema,
|
|
2369
2488
|
thread: UlidSchema.optional(),
|
|
2489
|
+
channel: NameSchema.optional(),
|
|
2370
2490
|
trigger: z12.string().default("manual"),
|
|
2371
2491
|
exitReason: z12.string().nullable().default(null),
|
|
2372
2492
|
costUsd: z12.number().default(0),
|
|
@@ -2382,12 +2502,23 @@ var CursorsSchema = z12.object({
|
|
|
2382
2502
|
digest: z12.string().nullable(),
|
|
2383
2503
|
scopes: z12.record(z12.string(), z12.string()).default({}),
|
|
2384
2504
|
threadsFrom: z12.record(z12.string(), z12.string().nullable()).default({}),
|
|
2505
|
+
channelsFrom: z12.record(z12.string(), z12.string().nullable()).default({}),
|
|
2385
2506
|
/** How far a work role has read news, which goes to whichever of its general conversations reads first. */
|
|
2386
2507
|
news: z12.string().optional()
|
|
2387
2508
|
});
|
|
2388
|
-
var NO_CURSORS = { digest: null, scopes: {}, threadsFrom: {} };
|
|
2389
|
-
function conversationKey(scope,
|
|
2390
|
-
return
|
|
2509
|
+
var NO_CURSORS = { digest: null, scopes: {}, threadsFrom: {}, channelsFrom: {} };
|
|
2510
|
+
function conversationKey(scope, part) {
|
|
2511
|
+
return part === null ? scope : `${scope}/${part}`;
|
|
2512
|
+
}
|
|
2513
|
+
function channelOfPart(part) {
|
|
2514
|
+
return part?.startsWith("#") === true ? part.slice(1) : void 0;
|
|
2515
|
+
}
|
|
2516
|
+
function threadOfPart(part) {
|
|
2517
|
+
return part === null || part === void 0 || part.startsWith("#") ? void 0 : part;
|
|
2518
|
+
}
|
|
2519
|
+
function channelStart(cursors, scope) {
|
|
2520
|
+
const from = cursors.channelsFrom[scope];
|
|
2521
|
+
return from === void 0 ? cursors.digest : from;
|
|
2391
2522
|
}
|
|
2392
2523
|
function threadStart(cursors, scope) {
|
|
2393
2524
|
const from = cursors.threadsFrom[scope];
|
|
@@ -2400,11 +2531,11 @@ function newsStart(cursors) {
|
|
|
2400
2531
|
const homes = Object.entries(cursors.scopes).filter(([key]) => !key.includes("/")).map(([, id]) => id).toSorted();
|
|
2401
2532
|
return homes.at(-1) ?? cursors.digest;
|
|
2402
2533
|
}
|
|
2403
|
-
function cursorOf(cursors, scope,
|
|
2404
|
-
if (
|
|
2534
|
+
function cursorOf(cursors, scope, part = null) {
|
|
2535
|
+
if (part === null) {
|
|
2405
2536
|
return cursors.scopes[scope] ?? cursors.digest;
|
|
2406
2537
|
}
|
|
2407
|
-
return cursors.scopes[conversationKey(scope,
|
|
2538
|
+
return cursors.scopes[conversationKey(scope, part)] ?? (part.startsWith("#") ? channelStart(cursors, scope) : threadStart(cursors, scope));
|
|
2408
2539
|
}
|
|
2409
2540
|
function byId(a, b) {
|
|
2410
2541
|
return a.message.id < b.message.id ? -1 : a.message.id > b.message.id ? 1 : 0;
|
|
@@ -2415,6 +2546,9 @@ function turnTime(record) {
|
|
|
2415
2546
|
var PausedSchema = z12.object({ paused: z12.boolean() });
|
|
2416
2547
|
var DEFAULT_LEASE_MS = 30 * 60 * 1e3;
|
|
2417
2548
|
var MENTION_PATTERN = /(^|[^\w@])@([a-z0-9][a-z0-9-]{0,31})(?![\w-])/g;
|
|
2549
|
+
function withoutMentions(text) {
|
|
2550
|
+
return text.replaceAll(MENTION_PATTERN, "$1$2");
|
|
2551
|
+
}
|
|
2418
2552
|
var CURATING_ROLES = [USER_ROLE, "steward"];
|
|
2419
2553
|
var SEED_INSTRUCTIONS_HEADING = "## Seed instructions";
|
|
2420
2554
|
var OPS_WAKE_TRIGGER = "ops_event";
|
|
@@ -2431,6 +2565,7 @@ var DECISIONS_CHANNEL_RETIRED = "decisions-channel-retired";
|
|
|
2431
2565
|
var THREAD_CONVERSATIONS = "thread-conversations";
|
|
2432
2566
|
var ASKS_CHANNEL_OPENED = "asks-channel-opened";
|
|
2433
2567
|
var KNOWLEDGE_REMOVAL = "knowledge-removal-granted";
|
|
2568
|
+
var CHANNEL_CONVERSATIONS = "channel-conversations";
|
|
2434
2569
|
var PLANNING_ROLES = [USER_ROLE, "steward", "concierge"];
|
|
2435
2570
|
var REALLOCATING_ROLES = [USER_ROLE, "steward", "concierge"];
|
|
2436
2571
|
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";
|
|
@@ -2561,6 +2696,8 @@ function snippetAround(text, query, radius = 80) {
|
|
|
2561
2696
|
var Board = class _Board {
|
|
2562
2697
|
paths;
|
|
2563
2698
|
mutex = new Mutex();
|
|
2699
|
+
/** The turns in flight, which the server hosting them supplies; none until it does. */
|
|
2700
|
+
turnsInFlight = () => [];
|
|
2564
2701
|
newId = monotonicFactory();
|
|
2565
2702
|
events;
|
|
2566
2703
|
actionCounts = /* @__PURE__ */ new Map();
|
|
@@ -2621,7 +2758,8 @@ var Board = class _Board {
|
|
|
2621
2758
|
DECISIONS_CHANNEL_RETIRED,
|
|
2622
2759
|
THREAD_CONVERSATIONS,
|
|
2623
2760
|
ASKS_CHANNEL_OPENED,
|
|
2624
|
-
KNOWLEDGE_REMOVAL
|
|
2761
|
+
KNOWLEDGE_REMOVAL,
|
|
2762
|
+
CHANNEL_CONVERSATIONS
|
|
2625
2763
|
].filter((name) => !applied.includes(name));
|
|
2626
2764
|
if (pending.length === 0) {
|
|
2627
2765
|
return;
|
|
@@ -2653,6 +2791,9 @@ var Board = class _Board {
|
|
|
2653
2791
|
if (pending.includes(KNOWLEDGE_REMOVAL)) {
|
|
2654
2792
|
await this.grantKnowledgeRemoval();
|
|
2655
2793
|
}
|
|
2794
|
+
if (pending.includes(CHANNEL_CONVERSATIONS)) {
|
|
2795
|
+
await this.startChannelCursors();
|
|
2796
|
+
}
|
|
2656
2797
|
await writeJson(file, { applied: [...applied, ...pending] });
|
|
2657
2798
|
}
|
|
2658
2799
|
/**
|
|
@@ -2827,6 +2968,27 @@ var Board = class _Board {
|
|
|
2827
2968
|
}
|
|
2828
2969
|
}
|
|
2829
2970
|
}
|
|
2971
|
+
/**
|
|
2972
|
+
* Every channel of a scope was once read in its home conversation, so a member had read each up
|
|
2973
|
+
* to its scope's cursor. Each channel beside general starts from that cursor now, as each takes a
|
|
2974
|
+
* conversation of its own.
|
|
2975
|
+
*/
|
|
2976
|
+
async startChannelCursors() {
|
|
2977
|
+
for (const agent2 of await this.listAgents()) {
|
|
2978
|
+
const file = this.paths.agentCursors(agent2.name);
|
|
2979
|
+
if (!await exists(file)) {
|
|
2980
|
+
continue;
|
|
2981
|
+
}
|
|
2982
|
+
const cursors = await readJson(file, CursorsSchema);
|
|
2983
|
+
const scopes = /* @__PURE__ */ new Set([
|
|
2984
|
+
...Object.keys(cursors.scopes).filter((key) => !key.includes("/")),
|
|
2985
|
+
...agent2.memberships,
|
|
2986
|
+
SOCIETY_SCOPE
|
|
2987
|
+
]);
|
|
2988
|
+
const channelsFrom = Object.fromEntries([...scopes].map((scope) => [scope, cursorOf(cursors, scope)]));
|
|
2989
|
+
await writeJson(file, { ...cursors, channelsFrom });
|
|
2990
|
+
}
|
|
2991
|
+
}
|
|
2830
2992
|
/**
|
|
2831
2993
|
* Threads were once read in the scope's single conversation, so a member had read every thread
|
|
2832
2994
|
* up to its scope's cursor. Each scope's threads start from that cursor now, as each thread's
|
|
@@ -2960,17 +3122,20 @@ ${charter.purpose}
|
|
|
2960
3122
|
/**
|
|
2961
3123
|
* A short-lived token for one turn, handed to the CLI by the runner. Only its hash is kept,
|
|
2962
3124
|
* in memory, until it expires, so no raw agent token ever needs to exist at rest. The turn's
|
|
2963
|
-
* scope and thread ride on the actor it resolves to, so
|
|
2964
|
-
* digest.
|
|
3125
|
+
* scope and conversation part, a thread's id or `#channel`, ride on the actor it resolves to, so
|
|
3126
|
+
* `read_inbox` reads that conversation's digest.
|
|
2965
3127
|
*/
|
|
2966
|
-
issueTurnToken(agent2, role2, ttlMs, scope,
|
|
3128
|
+
issueTurnToken(agent2, role2, ttlMs, scope, part) {
|
|
2967
3129
|
const token = mintToken();
|
|
3130
|
+
const thread2 = threadOfPart(part);
|
|
3131
|
+
const channel = channelOfPart(part);
|
|
2968
3132
|
this.turnTokens.set(hashToken(token), {
|
|
2969
3133
|
actor: {
|
|
2970
3134
|
name: agent2,
|
|
2971
3135
|
role: role2,
|
|
2972
3136
|
...scope === void 0 ? {} : { scope },
|
|
2973
|
-
...thread2 === void 0 ? {} : { thread: thread2 }
|
|
3137
|
+
...thread2 === void 0 ? {} : { thread: thread2 },
|
|
3138
|
+
...channel === void 0 ? {} : { channel }
|
|
2974
3139
|
},
|
|
2975
3140
|
expiresAt: this.now().getTime() + ttlMs
|
|
2976
3141
|
});
|
|
@@ -3205,7 +3370,7 @@ ${charter.purpose}
|
|
|
3205
3370
|
}
|
|
3206
3371
|
async readProfile(name) {
|
|
3207
3372
|
const file = this.paths.agentProfile(name);
|
|
3208
|
-
return await exists(file) ? (await
|
|
3373
|
+
return await exists(file) ? (await readLooseMarkdown(file)).body : "";
|
|
3209
3374
|
}
|
|
3210
3375
|
/** The society's norms, the one knowledge topic every turn loads. Empty until the steward writes it. */
|
|
3211
3376
|
async readSocietyNorms() {
|
|
@@ -3252,10 +3417,11 @@ ${charter.purpose}
|
|
|
3252
3417
|
if (!name.success) {
|
|
3253
3418
|
continue;
|
|
3254
3419
|
}
|
|
3255
|
-
const doc = await
|
|
3420
|
+
const doc = await readLooseMarkdown(file);
|
|
3256
3421
|
skills.push({
|
|
3257
3422
|
name: name.data,
|
|
3258
|
-
|
|
3423
|
+
// The index is where its writer sees that the file needs fixing.
|
|
3424
|
+
summary: doc.error === null ? skillSummary(doc.data, doc.body) : `its frontmatter does not parse (${doc.error}), so fix the file`,
|
|
3259
3425
|
scope,
|
|
3260
3426
|
path: file
|
|
3261
3427
|
});
|
|
@@ -3269,34 +3435,44 @@ ${charter.purpose}
|
|
|
3269
3435
|
}
|
|
3270
3436
|
return (await readJson(file, PausedSchema)).paused;
|
|
3271
3437
|
}
|
|
3272
|
-
/**
|
|
3273
|
-
|
|
3438
|
+
/**
|
|
3439
|
+
* Every channel, the society's first, with how many messages it holds and its newest one; each
|
|
3440
|
+
* place's archived channels follow its open ones.
|
|
3441
|
+
*/
|
|
3274
3442
|
async listChannels() {
|
|
3275
3443
|
const summaries = [];
|
|
3276
|
-
const
|
|
3277
|
-
|
|
3444
|
+
const society = await this.society();
|
|
3445
|
+
const places = [
|
|
3446
|
+
{ project: null, names: society.channels, archived: society.archivedChannels },
|
|
3278
3447
|
...(await this.listProjects()).map((project2) => ({
|
|
3279
3448
|
project: project2.slug,
|
|
3280
|
-
names: project2.channels
|
|
3449
|
+
names: project2.channels,
|
|
3450
|
+
archived: project2.archivedChannels
|
|
3281
3451
|
}))
|
|
3282
3452
|
];
|
|
3283
|
-
for (const { project: project2, names } of
|
|
3284
|
-
|
|
3285
|
-
|
|
3453
|
+
for (const { project: project2, names, archived } of places) {
|
|
3454
|
+
const entries = [
|
|
3455
|
+
...names.map((name) => ({ name, archived: void 0 })),
|
|
3456
|
+
...archived.map((each) => ({ name: each.name, archived: each }))
|
|
3457
|
+
];
|
|
3458
|
+
for (const entry of entries) {
|
|
3459
|
+
const ref = channelRef(project2, entry.name);
|
|
3286
3460
|
const files = await listFiles(this.paths.channelDir(ref));
|
|
3287
3461
|
const last = files.at(-1)?.slice(0, 26) ?? null;
|
|
3288
3462
|
summaries.push({
|
|
3289
3463
|
ref,
|
|
3290
3464
|
project: project2,
|
|
3291
|
-
name,
|
|
3465
|
+
name: entry.name,
|
|
3292
3466
|
messages: files.length,
|
|
3293
3467
|
lastMessageId: last,
|
|
3294
|
-
lastAt: last === null ? null : new Date(decodeTime(last)).toISOString()
|
|
3468
|
+
lastAt: last === null ? null : new Date(decodeTime(last)).toISOString(),
|
|
3469
|
+
...entry.archived === void 0 ? {} : { archived: entry.archived }
|
|
3295
3470
|
});
|
|
3296
3471
|
}
|
|
3297
3472
|
}
|
|
3298
3473
|
return summaries;
|
|
3299
3474
|
}
|
|
3475
|
+
/** Every message in a channel, oldest first. Thread messages are listed with `listThread`. */
|
|
3300
3476
|
async listChannel(ref, limit = 200) {
|
|
3301
3477
|
await this.assertChannelExists(ref);
|
|
3302
3478
|
const messages = await this.readMessagesIn(this.paths.channelDir(ref), null);
|
|
@@ -3449,12 +3625,27 @@ Agents may edit this file. It is rendered by the board UI.
|
|
|
3449
3625
|
return (await this.writeRoleUnlocked(actor.name, parsed, {})).charter;
|
|
3450
3626
|
});
|
|
3451
3627
|
}
|
|
3628
|
+
/** Opens a channel in a project or the society for a workstream, such as a release's tasks. */
|
|
3629
|
+
async createChannel(actor, input) {
|
|
3630
|
+
const args = VerbInputs.create_channel.parse(input);
|
|
3631
|
+
await this.authorize(actor, "create_channel");
|
|
3632
|
+
return this.mutex.run(async () => {
|
|
3633
|
+
await this.validateAddChannel(args);
|
|
3634
|
+
return this.addChannelUnlocked(actor.name, args, {}, actor.name);
|
|
3635
|
+
});
|
|
3636
|
+
}
|
|
3637
|
+
/** Archives a channel whose workstream is done; refused while a task filed there is in play. */
|
|
3638
|
+
async archiveChannel(actor, input) {
|
|
3639
|
+
const args = VerbInputs.archive_channel.parse(input);
|
|
3640
|
+
await this.authorize(actor, "archive_channel");
|
|
3641
|
+
return this.mutex.run(() => this.archiveChannelUnlocked(actor, args.channel, args.reason));
|
|
3642
|
+
}
|
|
3452
3643
|
/** Adds a channel to a project or to the society. Also what an approved channel proposal executes. */
|
|
3453
3644
|
async addChannel(actor, input) {
|
|
3454
3645
|
this.assertAdmin(actor);
|
|
3455
3646
|
return this.mutex.run(async () => {
|
|
3456
3647
|
await this.validateAddChannel(input);
|
|
3457
|
-
return this.addChannelUnlocked(actor.name, input, {});
|
|
3648
|
+
return this.addChannelUnlocked(actor.name, input, {}, actor.name);
|
|
3458
3649
|
});
|
|
3459
3650
|
}
|
|
3460
3651
|
/**
|
|
@@ -3652,19 +3843,19 @@ Agents may edit this file. It is rendered by the board UI.
|
|
|
3652
3843
|
return this.mutex.run(async () => {
|
|
3653
3844
|
const stored = await this.readCursors(actor.name);
|
|
3654
3845
|
const from = args.since_cursor === void 0 ? stored : { ...NO_CURSORS, digest: args.since_cursor };
|
|
3655
|
-
const conversation = actor.scope === void 0 ? void 0 : { scope: actor.scope,
|
|
3846
|
+
const conversation = actor.scope === void 0 ? void 0 : { scope: actor.scope, part: conversationPart(actor) ?? null };
|
|
3656
3847
|
const filed = (await this.digestSince(actor, from, conversation)).slice(0, args.limit);
|
|
3657
3848
|
if (args.advance) {
|
|
3658
3849
|
await this.advanceCursors(actor.name, filed);
|
|
3659
3850
|
}
|
|
3660
3851
|
const last = filed.at(-1)?.message.id;
|
|
3661
|
-
const cursor = last ?? (conversation === void 0 ? from.digest : cursorOf(from, conversation.scope, conversation.
|
|
3852
|
+
const cursor = last ?? (conversation === void 0 ? from.digest : cursorOf(from, conversation.scope, conversation.part));
|
|
3662
3853
|
return { messages: filed.map((entry) => entry.message), cursor };
|
|
3663
3854
|
});
|
|
3664
3855
|
}
|
|
3665
3856
|
/**
|
|
3666
3857
|
* How many unread digest messages a member has in each conversation: a scope's home, for its
|
|
3667
|
-
* channels, or one of its threads. The heartbeat asks, so that it wakes a member where its unread
|
|
3858
|
+
* general channel, one of its other channels, or one of its threads. The heartbeat asks, so that it wakes a member where its unread
|
|
3668
3859
|
* messages are and nowhere else. Society channels count only for a member woken in the society
|
|
3669
3860
|
* scope; a project member reads them at its next turn. Two kinds of post are in the digest but not
|
|
3670
3861
|
* counted, and are read when the member wakes for something else: a task step's, which wakes
|
|
@@ -3675,7 +3866,7 @@ Agents may edit this file. It is rendered by the board UI.
|
|
|
3675
3866
|
return this.mutex.run(async () => {
|
|
3676
3867
|
const counts = /* @__PURE__ */ new Map();
|
|
3677
3868
|
const unscoped = { name: actor.name, role: actor.role };
|
|
3678
|
-
for (const { scope,
|
|
3869
|
+
for (const { scope, part, message, news } of await this.digestSince(unscoped, await this.readCursors(actor.name))) {
|
|
3679
3870
|
if (news === true || message.step !== void 0 || message.author === SYSTEM_ACTOR.name) {
|
|
3680
3871
|
continue;
|
|
3681
3872
|
}
|
|
@@ -3683,8 +3874,13 @@ Agents may edit this file. It is rendered by the board UI.
|
|
|
3683
3874
|
if (posted !== scope && scope !== SOCIETY_SCOPE) {
|
|
3684
3875
|
continue;
|
|
3685
3876
|
}
|
|
3686
|
-
const key = conversationKey(scope,
|
|
3687
|
-
const entry = counts.get(key) ?? {
|
|
3877
|
+
const key = conversationKey(scope, part);
|
|
3878
|
+
const entry = counts.get(key) ?? {
|
|
3879
|
+
scope,
|
|
3880
|
+
thread: threadOfPart(part) ?? null,
|
|
3881
|
+
channel: channelOfPart(part) ?? null,
|
|
3882
|
+
count: 0
|
|
3883
|
+
};
|
|
3688
3884
|
counts.set(key, { ...entry, count: entry.count + 1 });
|
|
3689
3885
|
}
|
|
3690
3886
|
return [...counts.values()];
|
|
@@ -3700,7 +3896,7 @@ Agents may edit this file. It is rendered by the board UI.
|
|
|
3700
3896
|
const scopes = { ...cursors.scopes };
|
|
3701
3897
|
let news = newsStart(cursors);
|
|
3702
3898
|
let moved = false;
|
|
3703
|
-
for (const { scope,
|
|
3899
|
+
for (const { scope, part, message, news: isNews } of filed) {
|
|
3704
3900
|
if (isNews === true) {
|
|
3705
3901
|
if (news === null || message.id > news) {
|
|
3706
3902
|
news = message.id;
|
|
@@ -3708,8 +3904,8 @@ Agents may edit this file. It is rendered by the board UI.
|
|
|
3708
3904
|
}
|
|
3709
3905
|
continue;
|
|
3710
3906
|
}
|
|
3711
|
-
const key = conversationKey(scope,
|
|
3712
|
-
const current = scopes[key] ?? cursorOf(cursors, scope,
|
|
3907
|
+
const key = conversationKey(scope, part);
|
|
3908
|
+
const current = scopes[key] ?? cursorOf(cursors, scope, part);
|
|
3713
3909
|
if (current === null || message.id > current) {
|
|
3714
3910
|
scopes[key] = message.id;
|
|
3715
3911
|
moved = true;
|
|
@@ -3728,29 +3924,32 @@ Agents may edit this file. It is rendered by the board UI.
|
|
|
3728
3924
|
* message is filed in: what mentions the member, sits in a channel it follows, or belongs to a
|
|
3729
3925
|
* thread it takes part in, and for the front desk, which every post by the user wakes, every post
|
|
3730
3926
|
* by the user wherever it is. With a conversation, only what is filed there: for a thread, every
|
|
3731
|
-
* message of the thread, since the conversation is about it; for a
|
|
3732
|
-
* messages,
|
|
3927
|
+
* message of the thread, since the conversation is about it; for a channel's, that channel's
|
|
3928
|
+
* top-level messages; for a scope's home, general's and those of a project the member is not in,
|
|
3929
|
+
* since each thread and each other channel is a conversation of its own.
|
|
3733
3930
|
*/
|
|
3734
3931
|
async digestSince(actor, cursors, conversation) {
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
const
|
|
3932
|
+
const conversationThread = threadOfPart(conversation?.part);
|
|
3933
|
+
if (conversation !== void 0 && conversationThread !== void 0) {
|
|
3934
|
+
const { scope, part } = conversation;
|
|
3935
|
+
const found = await this.tryFindThread(conversationThread);
|
|
3738
3936
|
if (found === null) {
|
|
3739
3937
|
return [];
|
|
3740
3938
|
}
|
|
3741
|
-
const messages = await this.readMessagesIn(this.paths.threadMessages(found.threads,
|
|
3742
|
-
return messages.filter((message) => message.author !== actor.name).map((message) => ({ scope,
|
|
3939
|
+
const messages = await this.readMessagesIn(this.paths.threadMessages(found.threads, conversationThread), cursorOf(cursors, scope, part));
|
|
3940
|
+
return messages.filter((message) => message.author !== actor.name).map((message) => ({ scope, part, message })).toSorted(byId);
|
|
3743
3941
|
}
|
|
3744
3942
|
const agent2 = await this.readAgent(actor.name);
|
|
3745
3943
|
const charter = await this.readRole(agent2.role);
|
|
3746
3944
|
const subscribed = new Set(agent2.subscriptions);
|
|
3747
3945
|
const frontDesk = charter.wakeTriggers.includes(FRONT_DESK_TRIGGER);
|
|
3748
3946
|
const threadParticipation = /* @__PURE__ */ new Map();
|
|
3947
|
+
const openChannels = /* @__PURE__ */ new Map();
|
|
3749
3948
|
const collected = [];
|
|
3750
3949
|
const newsFrom = charter.societyScope ? void 0 : newsStart(cursors);
|
|
3751
3950
|
const scopes = conversation !== void 0 ? [conversation.scope] : [...agent2.memberships, SOCIETY_SCOPE];
|
|
3752
3951
|
const starts = [
|
|
3753
|
-
...scopes.flatMap((each) => conversation === void 0 ? [cursorOf(cursors, each), threadStart(cursors, each)] : [cursorOf(cursors, each)]),
|
|
3952
|
+
...scopes.flatMap((each) => conversation === void 0 ? [cursorOf(cursors, each), threadStart(cursors, each), channelStart(cursors, each)] : [cursorOf(cursors, each, conversation.part)]),
|
|
3754
3953
|
...newsFrom === void 0 ? [] : [newsFrom]
|
|
3755
3954
|
];
|
|
3756
3955
|
const known = starts.filter((start) => start !== null);
|
|
@@ -3763,20 +3962,24 @@ Agents may edit this file. It is rendered by the board UI.
|
|
|
3763
3962
|
if ((newsFrom === null || message.id > newsFrom) && subscribed.has(message.channel) && message.author !== actor.name) {
|
|
3764
3963
|
collected.push({
|
|
3765
3964
|
scope: conversation?.scope ?? SOCIETY_SCOPE,
|
|
3766
|
-
|
|
3965
|
+
part: null,
|
|
3767
3966
|
message,
|
|
3768
3967
|
news: true
|
|
3769
3968
|
});
|
|
3770
3969
|
}
|
|
3771
3970
|
continue;
|
|
3772
3971
|
}
|
|
3773
|
-
|
|
3774
|
-
|
|
3972
|
+
const named = thread2 === null ? channelConversation(filedIn, message.channel) : void 0;
|
|
3973
|
+
let open2 = named === void 0 ? void 0 : openChannels.get(message.channel);
|
|
3974
|
+
if (named !== void 0 && open2 === void 0) {
|
|
3975
|
+
open2 = await this.channelOpen(message.channel);
|
|
3976
|
+
openChannels.set(message.channel, open2);
|
|
3775
3977
|
}
|
|
3776
|
-
|
|
3978
|
+
const part = thread2 ?? (open2 === true ? `#${named}` : null);
|
|
3979
|
+
if (conversation !== void 0 && (filedIn !== conversation.scope || part !== conversation.part)) {
|
|
3777
3980
|
continue;
|
|
3778
3981
|
}
|
|
3779
|
-
const cursor = cursorOf(cursors, filedIn,
|
|
3982
|
+
const cursor = cursorOf(cursors, filedIn, part);
|
|
3780
3983
|
if (cursor !== null && message.id <= cursor) {
|
|
3781
3984
|
continue;
|
|
3782
3985
|
}
|
|
@@ -3794,7 +3997,7 @@ Agents may edit this file. It is rendered by the board UI.
|
|
|
3794
3997
|
include = participates;
|
|
3795
3998
|
}
|
|
3796
3999
|
if (include && message.author !== actor.name) {
|
|
3797
|
-
collected.push({ scope: filedIn,
|
|
4000
|
+
collected.push({ scope: filedIn, part, message });
|
|
3798
4001
|
}
|
|
3799
4002
|
}
|
|
3800
4003
|
return collected.toSorted(byId);
|
|
@@ -4033,7 +4236,7 @@ ${task2.body}`;
|
|
|
4033
4236
|
taskThreadOpening(task2) {
|
|
4034
4237
|
return {
|
|
4035
4238
|
id: task2.id,
|
|
4036
|
-
channel: channelRef(task2.project,
|
|
4239
|
+
channel: channelRef(task2.project, task2.channel),
|
|
4037
4240
|
title: task2.title,
|
|
4038
4241
|
subject: { kind: "task", id: task2.id }
|
|
4039
4242
|
};
|
|
@@ -4079,7 +4282,7 @@ ${task2.body}`;
|
|
|
4079
4282
|
if (task2.status === "done" || task2.status === "abandoned") {
|
|
4080
4283
|
throw new BoardError("INVALID_STATE", `task ${task2.id} is ${task2.status}`);
|
|
4081
4284
|
}
|
|
4082
|
-
const channel = args.channel ?? channelRef(project2,
|
|
4285
|
+
const channel = args.channel ?? channelRef(project2, task2.channel);
|
|
4083
4286
|
if (parseChannelRef(channel).project !== project2) {
|
|
4084
4287
|
throw new BoardError("VALIDATION", `a task's thread hangs off a channel of its project, ${project2}`);
|
|
4085
4288
|
}
|
|
@@ -4112,6 +4315,44 @@ ${task2.body}`;
|
|
|
4112
4315
|
await this.assertChannelOpen(args.channel);
|
|
4113
4316
|
return { id: this.newId(), channel: args.channel, title: args.title };
|
|
4114
4317
|
}
|
|
4318
|
+
/**
|
|
4319
|
+
* Where the board learns which turns are in flight, from the server that hosts them, so it
|
|
4320
|
+
* refuses to end a conversation under someone else's turn.
|
|
4321
|
+
*/
|
|
4322
|
+
watchTurns(source) {
|
|
4323
|
+
this.turnsInFlight = source;
|
|
4324
|
+
}
|
|
4325
|
+
/**
|
|
4326
|
+
* Refuses to end conversations while another citizen's turn is in one of them: `cut` picks the
|
|
4327
|
+
* turns that would lose theirs. The caller's own turn is exempt, since ending a conversation
|
|
4328
|
+
* from inside it is how a citizen finishes there.
|
|
4329
|
+
*/
|
|
4330
|
+
assertNoTurnCut(actor, what, cut) {
|
|
4331
|
+
const busy = [
|
|
4332
|
+
...new Set(this.turnsInFlight().filter((turn2) => cut(turn2) && !ownTurn(actor, turn2)).map((turn2) => turn2.agent))
|
|
4333
|
+
].toSorted((a, b) => a.localeCompare(b));
|
|
4334
|
+
if (busy.length === 0) {
|
|
4335
|
+
return;
|
|
4336
|
+
}
|
|
4337
|
+
const one = busy.length === 1;
|
|
4338
|
+
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"}`);
|
|
4339
|
+
}
|
|
4340
|
+
/** The open threads in a folder of threads that `keep` picks. */
|
|
4341
|
+
async openThreadIds(dir, keep) {
|
|
4342
|
+
const ids = /* @__PURE__ */ new Set();
|
|
4343
|
+
for (const file of await listFiles(dir)) {
|
|
4344
|
+
const doc = await readMarkdown(path6.join(dir, file), ThreadFrontmatterSchema);
|
|
4345
|
+
if (doc.data.state === "open" && keep({ ...doc.data, body: doc.body })) {
|
|
4346
|
+
ids.add(doc.data.id);
|
|
4347
|
+
}
|
|
4348
|
+
}
|
|
4349
|
+
return ids;
|
|
4350
|
+
}
|
|
4351
|
+
/** Refuses to archive a project while a turn runs in it or in a thread its archive closes. */
|
|
4352
|
+
async assertProjectQuiet(actor, slug) {
|
|
4353
|
+
const threads = await this.openThreadIds(this.paths.threads(slug), () => true);
|
|
4354
|
+
this.assertNoTurnCut(actor, `project ${slug} cannot be archived`, (turn2) => turn2.scope === slug || turn2.thread !== void 0 && threads.has(turn2.thread));
|
|
4355
|
+
}
|
|
4115
4356
|
async closeThread(actor, input) {
|
|
4116
4357
|
const args = VerbInputs.close_thread.parse(input);
|
|
4117
4358
|
await this.authorize(actor, "close_thread");
|
|
@@ -4126,6 +4367,7 @@ ${task2.body}`;
|
|
|
4126
4367
|
if (!PLANNING_ROLES.includes(actor.role) && !await this.participatesInThread(actor, thread2, threads)) {
|
|
4127
4368
|
throw new BoardError("FORBIDDEN", "only the thread's participants, the user, the steward, or the concierge may close it");
|
|
4128
4369
|
}
|
|
4370
|
+
this.assertNoTurnCut(actor, `thread ${thread2.id} cannot close`, (turn2) => turn2.thread === thread2.id);
|
|
4129
4371
|
const summary = await this.writeMessage(this.paths.channelDir(thread2.channel), { author: actor.name, channel: thread2.channel, closes: thread2.id }, `Thread closed: "${thread2.title}" (${thread2.id}).
|
|
4130
4372
|
|
|
4131
4373
|
${args.summary}`);
|
|
@@ -4164,10 +4406,12 @@ ${args.summary}`);
|
|
|
4164
4406
|
if (first === void 0) {
|
|
4165
4407
|
throw new BoardError("VALIDATION", "a plan needs at least one stage");
|
|
4166
4408
|
}
|
|
4409
|
+
await this.assertChannelOpen(channelRef(args.project, args.channel));
|
|
4167
4410
|
const ts = this.now().toISOString();
|
|
4168
4411
|
const frontmatter = TaskFrontmatterSchema.parse({
|
|
4169
4412
|
id: this.newId(),
|
|
4170
4413
|
project: args.project,
|
|
4414
|
+
channel: args.channel,
|
|
4171
4415
|
title: args.title,
|
|
4172
4416
|
status: "open",
|
|
4173
4417
|
createdBy: actor.name,
|
|
@@ -4183,11 +4427,11 @@ ${args.summary}`);
|
|
|
4183
4427
|
onDone: project2.onDone,
|
|
4184
4428
|
completing: false
|
|
4185
4429
|
});
|
|
4186
|
-
await this.assertChannelExists(channelRef(args.project, "general"));
|
|
4187
4430
|
const task2 = await this.writeTask(args.project, { ...frontmatter, body: args.body });
|
|
4188
4431
|
await this.events.append("task.created", actor.name, {
|
|
4189
4432
|
taskId: task2.id,
|
|
4190
4433
|
project: task2.project,
|
|
4434
|
+
channel: task2.channel,
|
|
4191
4435
|
title: task2.title,
|
|
4192
4436
|
parentId: task2.parentId ?? null,
|
|
4193
4437
|
stage: task2.stage
|
|
@@ -4303,6 +4547,9 @@ ${args.summary}`);
|
|
|
4303
4547
|
completedAt: ts
|
|
4304
4548
|
};
|
|
4305
4549
|
const next = current.stages[index + 1];
|
|
4550
|
+
if (next === void 0 && current.onDone === "none") {
|
|
4551
|
+
this.assertNoTurnCut(actor, `task ${current.id} cannot end`, (turn2) => turn2.thread === current.id);
|
|
4552
|
+
}
|
|
4306
4553
|
const reworkDone = current.returned === void 0 || next === void 0 || index + 1 >= stageIndex(current, current.returned.from);
|
|
4307
4554
|
const base = {
|
|
4308
4555
|
...current,
|
|
@@ -4466,6 +4713,7 @@ ${args.summary}`);
|
|
|
4466
4713
|
if (!mayAbandon) {
|
|
4467
4714
|
throw new BoardError("FORBIDDEN", "only the holder, the creator, the user, the steward, or the concierge may abandon a task");
|
|
4468
4715
|
}
|
|
4716
|
+
this.assertNoTurnCut(actor, `task ${current.id} cannot be abandoned`, (turn2) => turn2.thread === current.id);
|
|
4469
4717
|
next = {
|
|
4470
4718
|
...next,
|
|
4471
4719
|
status: "abandoned",
|
|
@@ -4667,6 +4915,7 @@ ${rationale}`}`);
|
|
|
4667
4915
|
await this.authorize(actor, "archive_project");
|
|
4668
4916
|
return this.mutex.run(async () => {
|
|
4669
4917
|
await this.validateArchive(args.project);
|
|
4918
|
+
await this.assertProjectQuiet(actor, args.project);
|
|
4670
4919
|
return (await this.archiveUnlocked(actor.name, args.project, args.reason, {})).project;
|
|
4671
4920
|
});
|
|
4672
4921
|
}
|
|
@@ -4674,6 +4923,10 @@ ${rationale}`}`);
|
|
|
4674
4923
|
* A citizen joins a project itself, or a reallocating role adds it. The pair gets its project
|
|
4675
4924
|
* directory now and, through the `agent.joined` event, an onboarding turn.
|
|
4676
4925
|
*/
|
|
4926
|
+
/**
|
|
4927
|
+
* A member follows the project's general from the start; the result names the project's other
|
|
4928
|
+
* open channels, which it subscribes to when its work there needs them.
|
|
4929
|
+
*/
|
|
4677
4930
|
async joinProject(actor, input) {
|
|
4678
4931
|
const args = VerbInputs.join_project.parse(input);
|
|
4679
4932
|
await this.authorize(actor, "join_project");
|
|
@@ -4686,7 +4939,7 @@ ${rationale}`}`);
|
|
|
4686
4939
|
throw new BoardError("INVALID_STATE", `${target} is retired`);
|
|
4687
4940
|
}
|
|
4688
4941
|
if (current.memberships.includes(args.project)) {
|
|
4689
|
-
return current;
|
|
4942
|
+
return { ...current, otherChannels: await this.otherChannels(args.project, current) };
|
|
4690
4943
|
}
|
|
4691
4944
|
await this.assertMayJoinProjects(target, current.role);
|
|
4692
4945
|
const agent2 = await this.updateAgent(target, (a) => ({
|
|
@@ -4705,9 +4958,13 @@ ${rationale}`}`);
|
|
|
4705
4958
|
project: args.project,
|
|
4706
4959
|
cli: agent2.cli
|
|
4707
4960
|
});
|
|
4708
|
-
return agent2;
|
|
4961
|
+
return { ...agent2, otherChannels: await this.otherChannels(args.project, agent2) };
|
|
4709
4962
|
});
|
|
4710
4963
|
}
|
|
4964
|
+
/** The open channels of a project a citizen does not follow. */
|
|
4965
|
+
async otherChannels(project2, agent2) {
|
|
4966
|
+
return (await this.readProject(project2)).channels.map((name) => channelRef(project2, name)).filter((ref) => !agent2.subscriptions.includes(ref));
|
|
4967
|
+
}
|
|
4711
4968
|
async leaveProject(actor, input) {
|
|
4712
4969
|
const args = VerbInputs.leave_project.parse(input);
|
|
4713
4970
|
await this.authorize(actor, "leave_project");
|
|
@@ -4812,6 +5069,10 @@ ${rationale}`}`);
|
|
|
4812
5069
|
return this.configureProject(actor, VerbInputs.configure_project.parse(input));
|
|
4813
5070
|
case "archive_project":
|
|
4814
5071
|
return this.archiveProject(actor, VerbInputs.archive_project.parse(input));
|
|
5072
|
+
case "create_channel":
|
|
5073
|
+
return this.createChannel(actor, VerbInputs.create_channel.parse(input));
|
|
5074
|
+
case "archive_channel":
|
|
5075
|
+
return this.archiveChannel(actor, VerbInputs.archive_channel.parse(input));
|
|
4815
5076
|
default:
|
|
4816
5077
|
throw new BoardError("VALIDATION", `unknown verb ${String(verb)}`);
|
|
4817
5078
|
}
|
|
@@ -4917,34 +5178,35 @@ ${rationale}`}`);
|
|
|
4917
5178
|
}
|
|
4918
5179
|
async readMemoryCore(agent2) {
|
|
4919
5180
|
const file = this.paths.agentMemoryCore(agent2);
|
|
4920
|
-
return await exists(file) ? (await
|
|
5181
|
+
return await exists(file) ? (await readLooseMarkdown(file)).body : "";
|
|
4921
5182
|
}
|
|
4922
5183
|
/**
|
|
4923
5184
|
* Moves a conversation's cursor forward to `cursor` once a turn there has delivered up to it. It
|
|
4924
5185
|
* never moves back, so a turn that ends after a later one in the same conversation cannot rewind it.
|
|
5186
|
+
* `part` names the conversation beside the scope's home: a thread's id or `#channel`.
|
|
4925
5187
|
*/
|
|
4926
|
-
async setDigestCursor(agent2, scope, cursor,
|
|
5188
|
+
async setDigestCursor(agent2, scope, cursor, part) {
|
|
4927
5189
|
await this.mutex.run(async () => {
|
|
4928
5190
|
if (cursor === null) {
|
|
4929
5191
|
return;
|
|
4930
5192
|
}
|
|
4931
5193
|
const cursors = await this.readCursors(agent2);
|
|
4932
|
-
const current = cursorOf(cursors, scope,
|
|
5194
|
+
const current = cursorOf(cursors, scope, part ?? null);
|
|
4933
5195
|
const news = newsStart(cursors);
|
|
4934
|
-
const readNews =
|
|
5196
|
+
const readNews = part === void 0 && (news === null || cursor > news);
|
|
4935
5197
|
if (current !== null && cursor <= current && !readNews) {
|
|
4936
5198
|
return;
|
|
4937
5199
|
}
|
|
4938
5200
|
await writeJson(this.paths.agentCursors(agent2), {
|
|
4939
5201
|
...cursors,
|
|
4940
|
-
scopes: current !== null && cursor <= current ? cursors.scopes : { ...cursors.scopes, [conversationKey(scope,
|
|
5202
|
+
scopes: current !== null && cursor <= current ? cursors.scopes : { ...cursors.scopes, [conversationKey(scope, part ?? null)]: cursor },
|
|
4941
5203
|
...readNews ? { news: cursor } : {}
|
|
4942
5204
|
});
|
|
4943
5205
|
});
|
|
4944
5206
|
}
|
|
4945
5207
|
/** Where a conversation's digest resumes: the newest message a turn there was shown. */
|
|
4946
|
-
async digestCursor(agent2, scope,
|
|
4947
|
-
return this.mutex.run(async () => cursorOf(await this.readCursors(agent2), scope,
|
|
5208
|
+
async digestCursor(agent2, scope, part) {
|
|
5209
|
+
return this.mutex.run(async () => cursorOf(await this.readCursors(agent2), scope, part ?? null));
|
|
4948
5210
|
}
|
|
4949
5211
|
/** Records that a running turn took posts delivered into it, which wake latency counts to. */
|
|
4950
5212
|
async recordSteered(input) {
|
|
@@ -4953,25 +5215,29 @@ ${rationale}`}`);
|
|
|
4953
5215
|
turnId: input.turnId,
|
|
4954
5216
|
project: input.project,
|
|
4955
5217
|
...input.thread === void 0 ? {} : { thread: input.thread },
|
|
5218
|
+
...input.channel === void 0 ? {} : { channel: input.channel },
|
|
4956
5219
|
messages: [...input.messages]
|
|
4957
5220
|
});
|
|
4958
5221
|
});
|
|
4959
5222
|
}
|
|
4960
|
-
/**
|
|
4961
|
-
|
|
4962
|
-
|
|
5223
|
+
/**
|
|
5224
|
+
* Where a conversation's session ids and last turn are kept: the scope's home, or beside it a
|
|
5225
|
+
* thread's or a channel's, named by `part` as `conversationPart` gives it.
|
|
5226
|
+
*/
|
|
5227
|
+
sessionDir(agent2, scope, part) {
|
|
5228
|
+
return part === void 0 ? this.paths.agentProject(agent2, scope) : this.paths.agentConversation(agent2, scope, part);
|
|
4963
5229
|
}
|
|
4964
|
-
async readSessions(agent2, project2,
|
|
4965
|
-
const file = path6.join(this.sessionDir(agent2, project2,
|
|
5230
|
+
async readSessions(agent2, project2, part) {
|
|
5231
|
+
const file = path6.join(this.sessionDir(agent2, project2, part), "sessions.json");
|
|
4966
5232
|
return await exists(file) ? readJson(file, SessionsFileSchema) : {};
|
|
4967
5233
|
}
|
|
4968
5234
|
/**
|
|
4969
5235
|
* Records a conversation's session for a CLI and the runner it began on. A session started on
|
|
4970
5236
|
* another runner replaces the record, since sessions do not move between machines.
|
|
4971
5237
|
*/
|
|
4972
|
-
async writeSession(agent2, project2, cli, sessionId, runner2,
|
|
5238
|
+
async writeSession(agent2, project2, cli, sessionId, runner2, part) {
|
|
4973
5239
|
await this.mutex.run(async () => {
|
|
4974
|
-
const dir = this.sessionDir(agent2, project2,
|
|
5240
|
+
const dir = this.sessionDir(agent2, project2, part);
|
|
4975
5241
|
await ensureDir(dir);
|
|
4976
5242
|
const file = path6.join(dir, "sessions.json");
|
|
4977
5243
|
const current = await exists(file) ? await readJson(file, SessionsFileSchema) : {};
|
|
@@ -4979,17 +5245,21 @@ ${rationale}`}`);
|
|
|
4979
5245
|
await writeJson(file, { ...kept, [cli]: sessionId, runner: runner2 });
|
|
4980
5246
|
});
|
|
4981
5247
|
}
|
|
4982
|
-
/** The last turn of one conversation: the scope's home, or a thread's. */
|
|
4983
|
-
async readLastTurn(agent2, project2,
|
|
4984
|
-
const file = path6.join(this.sessionDir(agent2, project2,
|
|
5248
|
+
/** The last turn of one conversation: the scope's home, a channel's, or a thread's. */
|
|
5249
|
+
async readLastTurn(agent2, project2, part) {
|
|
5250
|
+
const file = path6.join(this.sessionDir(agent2, project2, part), "last-turn.json");
|
|
4985
5251
|
return await exists(file) ? readJson(file, TurnRecordSchema) : null;
|
|
4986
5252
|
}
|
|
4987
5253
|
/** The latest turn a citizen took in a scope, in any of its conversations there. */
|
|
4988
5254
|
async readLatestTurn(agent2, project2) {
|
|
4989
5255
|
let latest = await this.readLastTurn(agent2, project2);
|
|
4990
|
-
const
|
|
4991
|
-
|
|
4992
|
-
|
|
5256
|
+
const place = this.paths.agentProject(agent2, project2);
|
|
5257
|
+
const parts = [
|
|
5258
|
+
...await listDirs(path6.join(place, "threads")),
|
|
5259
|
+
...(await listDirs(path6.join(place, "channels"))).map((name) => `#${name}`)
|
|
5260
|
+
];
|
|
5261
|
+
for (const part of parts) {
|
|
5262
|
+
const turn2 = await this.readLastTurn(agent2, project2, part);
|
|
4993
5263
|
if (turn2 !== null && (latest === null || turnTime(turn2) > turnTime(latest))) {
|
|
4994
5264
|
latest = turn2;
|
|
4995
5265
|
}
|
|
@@ -5006,6 +5276,7 @@ ${rationale}`}`);
|
|
|
5006
5276
|
turnId: parsed.id,
|
|
5007
5277
|
project: parsed.project,
|
|
5008
5278
|
...parsed.thread === void 0 ? {} : { thread: parsed.thread },
|
|
5279
|
+
...parsed.channel === void 0 ? {} : { channel: parsed.channel },
|
|
5009
5280
|
trigger: parsed.trigger.kind,
|
|
5010
5281
|
session: parsed.session,
|
|
5011
5282
|
runner: parsed.runner
|
|
@@ -5031,13 +5302,15 @@ ${rationale}`}`);
|
|
|
5031
5302
|
...parsed.id === void 0 ? {} : { turnId: parsed.id },
|
|
5032
5303
|
agent: parsed.agent,
|
|
5033
5304
|
project: parsed.project,
|
|
5034
|
-
...parsed.thread === void 0 ? {} : { thread: parsed.thread }
|
|
5305
|
+
...parsed.thread === void 0 ? {} : { thread: parsed.thread },
|
|
5306
|
+
...parsed.channel === void 0 ? {} : { channel: parsed.channel }
|
|
5035
5307
|
});
|
|
5036
5308
|
}
|
|
5037
5309
|
await this.events.append(failed ? "turn.failed" : "turn.completed", parsed.agent, {
|
|
5038
5310
|
...parsed.id === void 0 ? {} : { turnId: parsed.id },
|
|
5039
5311
|
project: parsed.project,
|
|
5040
5312
|
...parsed.thread === void 0 ? {} : { thread: parsed.thread },
|
|
5313
|
+
...parsed.channel === void 0 ? {} : { channel: parsed.channel },
|
|
5041
5314
|
trigger: parsed.trigger.kind,
|
|
5042
5315
|
session: parsed.session,
|
|
5043
5316
|
exitReason: parsed.exitReason,
|
|
@@ -5061,6 +5334,47 @@ ${rationale}`}`);
|
|
|
5061
5334
|
});
|
|
5062
5335
|
});
|
|
5063
5336
|
}
|
|
5337
|
+
/**
|
|
5338
|
+
* Records that a citizen's workspace in a conversation that has ended holds work on no branch, so
|
|
5339
|
+
* the scheduler gives the citizen a closing turn there to decide about it. False, recording
|
|
5340
|
+
* nothing, once `CLOSING_ATTEMPTS` closing turns have been given for it.
|
|
5341
|
+
*/
|
|
5342
|
+
async recordLeftovers(input) {
|
|
5343
|
+
const { conversation } = input;
|
|
5344
|
+
const payload = {
|
|
5345
|
+
agent: input.agent,
|
|
5346
|
+
project: input.scope,
|
|
5347
|
+
...conversation.kind === "thread" ? { thread: conversation.id } : { channel: conversation.name }
|
|
5348
|
+
};
|
|
5349
|
+
return this.mutex.run(async () => {
|
|
5350
|
+
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;
|
|
5351
|
+
if (earlier >= CLOSING_ATTEMPTS) {
|
|
5352
|
+
return false;
|
|
5353
|
+
}
|
|
5354
|
+
await this.events.append("workspace.leftovers", SYSTEM_ACTOR.name, payload);
|
|
5355
|
+
return true;
|
|
5356
|
+
});
|
|
5357
|
+
}
|
|
5358
|
+
/**
|
|
5359
|
+
* Whether a conversation with a workspace of its own has ended: a thread closed, a task ended
|
|
5360
|
+
* (for one from before tasks had threads), or a channel archived.
|
|
5361
|
+
*/
|
|
5362
|
+
async conversationEnded(conversation) {
|
|
5363
|
+
if (conversation.kind === "channel") {
|
|
5364
|
+
const place = conversation.scope === SOCIETY_SCOPE ? null : conversation.scope;
|
|
5365
|
+
return !await this.channelOpen(channelRef(place, conversation.name));
|
|
5366
|
+
}
|
|
5367
|
+
const found = await this.tryFindThread(conversation.id);
|
|
5368
|
+
if (found !== null) {
|
|
5369
|
+
return found.thread.state === "closed";
|
|
5370
|
+
}
|
|
5371
|
+
try {
|
|
5372
|
+
const { task: task2 } = await this.findTask(conversation.id);
|
|
5373
|
+
return task2.status === "done" || task2.status === "abandoned";
|
|
5374
|
+
} catch {
|
|
5375
|
+
return false;
|
|
5376
|
+
}
|
|
5377
|
+
}
|
|
5064
5378
|
/**
|
|
5065
5379
|
* Records the outcome of a completing task's effect: done, or waiting again at its last stage so
|
|
5066
5380
|
* its participants can reshape the plan. A merge's outcome is posted to the task's thread, and a
|
|
@@ -5096,7 +5410,7 @@ ${rationale}`}`);
|
|
|
5096
5410
|
await this.refreshMember(name);
|
|
5097
5411
|
}
|
|
5098
5412
|
if (merged) {
|
|
5099
|
-
await this.appendMessage(actor.name, channelRef(location.project,
|
|
5413
|
+
await this.appendMessage(actor.name, channelRef(location.project, task3.channel), `Task ${task3.id} "${task3.title}" is done: ${sentence(input.detail)}.`);
|
|
5100
5414
|
}
|
|
5101
5415
|
return task3;
|
|
5102
5416
|
}
|
|
@@ -5132,7 +5446,8 @@ ${rationale}`}`);
|
|
|
5132
5446
|
if (!await exists(file)) {
|
|
5133
5447
|
return { data: { project: slug }, body: "" };
|
|
5134
5448
|
}
|
|
5135
|
-
|
|
5449
|
+
const { data, body } = await readLooseMarkdown(file);
|
|
5450
|
+
return { data, body };
|
|
5136
5451
|
}
|
|
5137
5452
|
async listRunners() {
|
|
5138
5453
|
const runners = [];
|
|
@@ -5332,7 +5647,7 @@ ${rationale}`}`);
|
|
|
5332
5647
|
return this.files.read(this.paths.board, paths, () => true);
|
|
5333
5648
|
}
|
|
5334
5649
|
async writeTurnRecord(record) {
|
|
5335
|
-
const dir = this.sessionDir(record.agent, record.project, record
|
|
5650
|
+
const dir = this.sessionDir(record.agent, record.project, conversationPart(record));
|
|
5336
5651
|
await ensureDir(dir);
|
|
5337
5652
|
await writeJson(path6.join(dir, "last-turn.json"), record);
|
|
5338
5653
|
}
|
|
@@ -5636,7 +5951,7 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
|
|
|
5636
5951
|
};
|
|
5637
5952
|
}
|
|
5638
5953
|
case "channel": {
|
|
5639
|
-
const channel = await this.addChannelUnlocked(by, ChannelProposalSchema.parse(proposal2.charter), meta);
|
|
5954
|
+
const channel = await this.addChannelUnlocked(by, ChannelProposalSchema.parse(proposal2.charter), meta, proposal2.proposedBy);
|
|
5640
5955
|
return { channel };
|
|
5641
5956
|
}
|
|
5642
5957
|
case "role": {
|
|
@@ -5742,18 +6057,20 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
|
|
|
5742
6057
|
return { agent: agent2, token };
|
|
5743
6058
|
}
|
|
5744
6059
|
async validateAddChannel(input) {
|
|
5745
|
-
|
|
5746
|
-
|
|
5747
|
-
|
|
5748
|
-
}
|
|
5749
|
-
return;
|
|
6060
|
+
const place = input.project === null ? await this.society() : await this.readActiveProject(input.project);
|
|
6061
|
+
const ref = channelRef(input.project, input.name);
|
|
6062
|
+
if (place.channels.includes(input.name)) {
|
|
6063
|
+
throw new BoardError("ALREADY_EXISTS", `channel ${ref} already exists`);
|
|
5750
6064
|
}
|
|
5751
|
-
|
|
5752
|
-
|
|
5753
|
-
throw new BoardError("ALREADY_EXISTS", `channel ${channelRef(input.project, input.name)} already exists`);
|
|
6065
|
+
if (place.archivedChannels.some((each) => each.name === input.name)) {
|
|
6066
|
+
throw new BoardError("ALREADY_EXISTS", `channel ${ref} was archived; open the new one under another name`);
|
|
5754
6067
|
}
|
|
5755
6068
|
}
|
|
5756
|
-
|
|
6069
|
+
/**
|
|
6070
|
+
* Opens a channel, which `opener` follows from the start: the citizen who opened it or proposed
|
|
6071
|
+
* it. Nobody else is subscribed, and the user, who reads channels directly, follows nothing.
|
|
6072
|
+
*/
|
|
6073
|
+
async addChannelUnlocked(by, input, meta, opener) {
|
|
5757
6074
|
const ref = channelRef(input.project, input.name);
|
|
5758
6075
|
if (input.project === null) {
|
|
5759
6076
|
const doc = await readMarkdown(this.paths.societyFile(), SocietySchema);
|
|
@@ -5767,14 +6084,104 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
|
|
|
5767
6084
|
}));
|
|
5768
6085
|
await ensureDir(this.paths.projectChannel(project2, input.name));
|
|
5769
6086
|
}
|
|
6087
|
+
const follower = await this.readAgent(opener).catch(() => null);
|
|
6088
|
+
const follows = follower !== null && follower.status === "active" && follower.role !== USER_ROLE;
|
|
6089
|
+
if (follows) {
|
|
6090
|
+
await this.updateAgent(opener, (agent2) => ({
|
|
6091
|
+
...agent2,
|
|
6092
|
+
subscriptions: [.../* @__PURE__ */ new Set([...agent2.subscriptions, ref])]
|
|
6093
|
+
}));
|
|
6094
|
+
await this.refreshMember(opener);
|
|
6095
|
+
}
|
|
5770
6096
|
await this.events.append("channel.added", by, {
|
|
5771
6097
|
channel: ref,
|
|
5772
6098
|
purpose: input.purpose,
|
|
6099
|
+
...follows ? { followedBy: opener } : {},
|
|
5773
6100
|
...meta
|
|
5774
6101
|
});
|
|
5775
|
-
|
|
6102
|
+
const purpose = withoutMentions(input.purpose);
|
|
6103
|
+
await this.appendMessage(SYSTEM_ACTOR.name, ref, `Channel ${ref} opened: ${purpose}`);
|
|
6104
|
+
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.`);
|
|
5776
6105
|
return ref;
|
|
5777
6106
|
}
|
|
6107
|
+
/**
|
|
6108
|
+
* Ends a channel: its open threads close, its followers stop following it, and it leaves the
|
|
6109
|
+
* place's channels for its archived ones, where its history stays readable. The notice is the
|
|
6110
|
+
* board's and leaves the reason out, since a board-authored mention would wake whoever it names.
|
|
6111
|
+
*/
|
|
6112
|
+
async archiveChannelUnlocked(actor, ref, reason) {
|
|
6113
|
+
const by = actor.name;
|
|
6114
|
+
const { project: project2, channel } = parseChannelRef(ref);
|
|
6115
|
+
if (isBoardChannel(project2, channel)) {
|
|
6116
|
+
throw new BoardError("FORBIDDEN", `the board itself uses ${ref}, so it is never archived`);
|
|
6117
|
+
}
|
|
6118
|
+
await this.assertChannelOpen(ref);
|
|
6119
|
+
if (project2 !== null) {
|
|
6120
|
+
const inPlay = (await this.listTasks(project2)).filter((task2) => task2.channel === channel && task2.status !== "done" && task2.status !== "abandoned");
|
|
6121
|
+
if (inPlay.length > 0) {
|
|
6122
|
+
throw new BoardError("INVALID_STATE", `${inPlay.length} task(s) filed in ${ref} are still in play: ${inPlay.map((task2) => task2.id).join(", ")}`);
|
|
6123
|
+
}
|
|
6124
|
+
}
|
|
6125
|
+
const ts = this.now().toISOString();
|
|
6126
|
+
const threads = project2 === null ? this.paths.societyThreads() : this.paths.threads(project2);
|
|
6127
|
+
const closing = await this.openThreadIds(threads, (thread2) => thread2.channel === ref);
|
|
6128
|
+
const scope = project2 ?? SOCIETY_SCOPE;
|
|
6129
|
+
this.assertNoTurnCut(actor, `${ref} cannot be archived`, (turn2) => turn2.channel === channel && turn2.scope === scope || turn2.thread !== void 0 && closing.has(turn2.thread));
|
|
6130
|
+
const threadsClosed = [];
|
|
6131
|
+
for (const file of await listFiles(threads)) {
|
|
6132
|
+
const doc = await readMarkdown(path6.join(threads, file), ThreadFrontmatterSchema);
|
|
6133
|
+
if (doc.data.channel !== ref || doc.data.state !== "open") {
|
|
6134
|
+
continue;
|
|
6135
|
+
}
|
|
6136
|
+
await this.writeThread(threads, {
|
|
6137
|
+
...doc.data,
|
|
6138
|
+
body: doc.body,
|
|
6139
|
+
state: "closed",
|
|
6140
|
+
closedBy: by,
|
|
6141
|
+
closedAt: ts
|
|
6142
|
+
});
|
|
6143
|
+
await this.events.append("thread.closed", by, {
|
|
6144
|
+
threadId: doc.data.id,
|
|
6145
|
+
channel: ref,
|
|
6146
|
+
ended: "archived"
|
|
6147
|
+
});
|
|
6148
|
+
threadsClosed.push(doc.data.id);
|
|
6149
|
+
}
|
|
6150
|
+
const unsubscribed = [];
|
|
6151
|
+
for (const agent2 of await this.listAgents()) {
|
|
6152
|
+
if (agent2.subscriptions.includes(ref)) {
|
|
6153
|
+
await this.updateAgent(agent2.name, (a) => ({
|
|
6154
|
+
...a,
|
|
6155
|
+
subscriptions: a.subscriptions.filter((each) => each !== ref)
|
|
6156
|
+
}));
|
|
6157
|
+
await this.refreshMember(agent2.name);
|
|
6158
|
+
unsubscribed.push(agent2.name);
|
|
6159
|
+
}
|
|
6160
|
+
}
|
|
6161
|
+
const archived = { name: channel, at: ts, by, reason };
|
|
6162
|
+
if (project2 === null) {
|
|
6163
|
+
const doc = await readMarkdown(this.paths.societyFile(), SocietySchema);
|
|
6164
|
+
await writeMarkdown(this.paths.societyFile(), {
|
|
6165
|
+
...doc.data,
|
|
6166
|
+
channels: doc.data.channels.filter((name) => name !== channel),
|
|
6167
|
+
archivedChannels: [...doc.data.archivedChannels, archived]
|
|
6168
|
+
}, doc.body);
|
|
6169
|
+
} else {
|
|
6170
|
+
await this.updateProject(project2, (current) => ({
|
|
6171
|
+
...current,
|
|
6172
|
+
channels: current.channels.filter((name) => name !== channel),
|
|
6173
|
+
archivedChannels: [...current.archivedChannels, archived]
|
|
6174
|
+
}));
|
|
6175
|
+
}
|
|
6176
|
+
await this.events.append("channel.archived", by, {
|
|
6177
|
+
channel: ref,
|
|
6178
|
+
reason,
|
|
6179
|
+
threadsClosed,
|
|
6180
|
+
unsubscribed
|
|
6181
|
+
});
|
|
6182
|
+
await this.appendMessage(SYSTEM_ACTOR.name, channelRef(project2, "general"), `Channel ${ref} is archived by ${by}. Its posts, threads, and tasks stay readable; nothing more is posted or filed there.`);
|
|
6183
|
+
return archived;
|
|
6184
|
+
}
|
|
5778
6185
|
/** A role becomes a society role only while none of its members is in a project. */
|
|
5779
6186
|
async validateRole(charter) {
|
|
5780
6187
|
if (charter.name === USER_ROLE) {
|
|
@@ -5987,23 +6394,35 @@ ${charter.purpose}
|
|
|
5987
6394
|
async publishSignalUnlocked(signal) {
|
|
5988
6395
|
return this.events.append("ops.signal", SYSTEM_ACTOR.name, OpsSignalSchema.parse(signal));
|
|
5989
6396
|
}
|
|
6397
|
+
/** A channel that exists, open or archived, and the record of its archive when it has one. */
|
|
5990
6398
|
async assertChannelExists(ref) {
|
|
5991
6399
|
const parsed = parseChannelRef(ref);
|
|
5992
|
-
|
|
5993
|
-
|
|
5994
|
-
|
|
5995
|
-
throw new BoardError("NOT_FOUND", `society channel ${ref} not found`);
|
|
5996
|
-
}
|
|
5997
|
-
return;
|
|
6400
|
+
const place = parsed.project === null ? await this.society() : await this.readProject(parsed.project);
|
|
6401
|
+
if (place.channels.includes(parsed.channel)) {
|
|
6402
|
+
return void 0;
|
|
5998
6403
|
}
|
|
5999
|
-
const
|
|
6000
|
-
if (
|
|
6001
|
-
throw new BoardError("NOT_FOUND", `channel ${ref} not found`);
|
|
6404
|
+
const archived = place.archivedChannels.find((each) => each.name === parsed.channel);
|
|
6405
|
+
if (archived === void 0) {
|
|
6406
|
+
throw new BoardError("NOT_FOUND", parsed.project === null ? `society channel ${ref} not found` : `channel ${ref} not found`);
|
|
6002
6407
|
}
|
|
6408
|
+
return archived;
|
|
6003
6409
|
}
|
|
6004
|
-
/**
|
|
6410
|
+
/** Whether a channel exists and is not archived, its conversations still going. */
|
|
6411
|
+
async channelOpen(ref) {
|
|
6412
|
+
try {
|
|
6413
|
+
return await this.assertChannelExists(ref) === void 0;
|
|
6414
|
+
} catch {
|
|
6415
|
+
return false;
|
|
6416
|
+
}
|
|
6417
|
+
}
|
|
6418
|
+
/**
|
|
6419
|
+
* A channel that takes new posts, threads, tasks, and followers: it exists, it is not archived,
|
|
6420
|
+
* and its project is not archived.
|
|
6421
|
+
*/
|
|
6005
6422
|
async assertChannelOpen(ref) {
|
|
6006
|
-
await this.assertChannelExists(ref)
|
|
6423
|
+
if (await this.assertChannelExists(ref) !== void 0) {
|
|
6424
|
+
throw new BoardError("INVALID_STATE", `channel ${ref} is archived`);
|
|
6425
|
+
}
|
|
6007
6426
|
const { project: project2 } = parseChannelRef(ref);
|
|
6008
6427
|
if (project2 !== null) {
|
|
6009
6428
|
await this.readActiveProject(project2);
|
|
@@ -6103,6 +6522,10 @@ ${charter.purpose}
|
|
|
6103
6522
|
if (outcome === "approved") {
|
|
6104
6523
|
await this.validateProvision(proposal2.kind, proposal2.charter);
|
|
6105
6524
|
}
|
|
6525
|
+
this.assertNoTurnCut(actor, `proposal ${proposalId} cannot be decided`, (turn2) => turn2.thread === proposalId);
|
|
6526
|
+
if (outcome === "approved" && proposal2.kind === "archive") {
|
|
6527
|
+
await this.assertProjectQuiet(actor, ArchiveProposalSchema.parse(proposal2.charter).project);
|
|
6528
|
+
}
|
|
6106
6529
|
const ts = this.now().toISOString();
|
|
6107
6530
|
const decision = DecisionSchema.parse({
|
|
6108
6531
|
id: this.newId(),
|
|
@@ -6503,6 +6926,14 @@ channelCommand.command("add <ref>").description("Open a channel: <name> for the
|
|
|
6503
6926
|
});
|
|
6504
6927
|
print({ channel: added }, () => `Channel ${added} opened`);
|
|
6505
6928
|
});
|
|
6929
|
+
channelCommand.command("archive <ref>").description("Archive a channel whose workstream is done, once no task filed there is in play").requiredOption("--reason <text>", "why its work is done").option("--as <agent>", "act as this agent instead of the user").action(async (ref, opts) => {
|
|
6930
|
+
const board = await open();
|
|
6931
|
+
const archived = await board.archiveChannel(await actorFor(board, opts.as), {
|
|
6932
|
+
channel: ref,
|
|
6933
|
+
reason: opts.reason
|
|
6934
|
+
});
|
|
6935
|
+
print(archived, () => `Channel ${ref} archived: ${archived.reason}`);
|
|
6936
|
+
});
|
|
6506
6937
|
program.command("signals").description("Recent operations signals, oldest first").option("--limit <n>", "how many", "50").action(async (opts) => {
|
|
6507
6938
|
const board = await open();
|
|
6508
6939
|
const signals = await board.listSignals(Number(opts.limit));
|
|
@@ -6524,18 +6955,19 @@ program.command("ask <text>").description("Post to the society's general channel
|
|
|
6524
6955
|
print(message, () => `Posted ${message.id} to general; the concierge wakes on it`);
|
|
6525
6956
|
});
|
|
6526
6957
|
var task = program.command("task").description("Manage tasks");
|
|
6527
|
-
task.command("create <project> <title>").description("Create a task with a plan, or with one stage anyone in the project may take").option("--body <text>", "task body", "").option("--parent <id>", "parent task id").option("--plan <json>", `stages, for example '[{"name":"analysis","role":"analyst"}]'`).option("--as <agent>", "act as this agent instead of the user").action(async (projectSlug, title, opts) => {
|
|
6958
|
+
task.command("create <project> <title>").description("Create a task with a plan, or with one stage anyone in the project may take").option("--body <text>", "task body", "").option("--parent <id>", "parent task id").option("--plan <json>", `stages, for example '[{"name":"analysis","role":"analyst"}]'`).option("--channel <name>", "the project's channel to file it in, such as a release's", "general").option("--as <agent>", "act as this agent instead of the user").action(async (projectSlug, title, opts) => {
|
|
6528
6959
|
const board = await open();
|
|
6529
6960
|
const who = await actorFor(board, opts.as);
|
|
6530
6961
|
const created = await board.createTask(who, {
|
|
6531
6962
|
project: projectSlug,
|
|
6532
6963
|
title,
|
|
6533
6964
|
body: opts.body,
|
|
6965
|
+
channel: opts.channel,
|
|
6534
6966
|
...opts.parent === void 0 ? {} : { parent_id: opts.parent },
|
|
6535
6967
|
...opts.plan === void 0 ? {} : { stages: parsePlan(opts.plan) }
|
|
6536
6968
|
});
|
|
6537
6969
|
print(created, () => [
|
|
6538
|
-
`Task ${created.id} created in ${created.project}: ${created.title}`,
|
|
6970
|
+
`Task ${created.id} created in ${created.project}/${created.channel}: ${created.title}`,
|
|
6539
6971
|
describePlan(created)
|
|
6540
6972
|
].join("\n"));
|
|
6541
6973
|
});
|