@kubitnodes/stellaris 0.3.17 → 0.3.19
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 +475 -117
- package/dist/server/index.js +466 -102
- package/package.json +1 -1
- package/web/dist/assets/{BackLink-CssfJ-vJ.js → BackLink-CDrsO9Ls.js} +1 -1
- package/web/dist/assets/{ChannelForms-B3XwkJj_.js → ChannelForms-BjD5Lp_u.js} +1 -1
- package/web/dist/assets/ChannelView-dI5EHpof.js +2 -0
- package/web/dist/assets/{CitizenView-BUeC52q2.js → CitizenView-tvC6dNUB.js} +2 -2
- package/web/dist/assets/{CitizensView-BSWYcQ-Q.js → CitizensView-DsUhIYtl.js} +1 -1
- package/web/dist/assets/{CliIcon-CFObB9XW.js → CliIcon-TlyIqh7V.js} +1 -1
- package/web/dist/assets/{CronsView-BBIx84f7.js → CronsView-BPlSyoAe.js} +1 -1
- package/web/dist/assets/KnowledgeView-CgRGZrOK.js +1 -0
- package/web/dist/assets/{Markdown-BO_bF8WD.js → Markdown-CePsfcUS.js} +4 -4
- package/web/dist/assets/MessageItem-kg5zWMDe.js +1 -0
- package/web/dist/assets/{MetricsView-DqAQ4th0.js → MetricsView-cJeDTfBE.js} +1 -1
- package/web/dist/assets/{NeedsYouView-D2Z-iLdT.js → NeedsYouView-Cd1TnY7W.js} +1 -1
- package/web/dist/assets/{Overview-DThLwb0l.js → Overview-B6aBmove.js} +1 -1
- package/web/dist/assets/{Pane-DHXhVwfI.js → Pane-DGtHsANK.js} +1 -1
- package/web/dist/assets/{Picker-CQQ7hj8T.js → Picker-Dq5Vvxod.js} +1 -1
- package/web/dist/assets/ProjectView-uskZHP8l.js +1 -0
- package/web/dist/assets/ProposalView-DmjIL53_.js +2 -0
- package/web/dist/assets/{ProposalsView-BJg4ZsTy.js → ProposalsView-BjEfHBtx.js} +1 -1
- package/web/dist/assets/{RunnersView-DOW8xq-J.js → RunnersView-CM-q_AX9.js} +1 -1
- package/web/dist/assets/SocietyView-DZuGEqIl.js +1 -0
- package/web/dist/assets/SubjectThread-pCRDn42X.js +1 -0
- package/web/dist/assets/{TaskFileView-CPwEmR6p.js → TaskFileView-BMAkbh-e.js} +2 -2
- package/web/dist/assets/{TaskView-q6Kn30K7.js → TaskView-HDwqXuYy.js} +2 -2
- package/web/dist/assets/{TasksView-CT9Cui_C.js → TasksView-Dt8M_z96.js} +1 -1
- package/web/dist/assets/ThreadForms-DbV3kPCi.js +1 -0
- package/web/dist/assets/ThreadItem-BVlz2w6q.js +1 -0
- package/web/dist/assets/{ThreadView-BZ2nKfyD.js → ThreadView-CpBytSH7.js} +1 -1
- package/web/dist/assets/{citizen-DFaT61S1.js → citizen-DQ0Bmk-r.js} +1 -1
- package/web/dist/assets/compose-Cx9c9k90.js +1 -0
- package/web/dist/assets/{format-Cp_C4ygr.js → format-SP8GnzAX.js} +1 -1
- package/web/dist/assets/governance-DX2ph-qr.js +1 -0
- package/web/dist/assets/index-BDKI0Rje.js +23 -0
- package/web/dist/assets/index-BPVeX6tV.css +2 -0
- package/web/dist/assets/{link-DIpiSYJj.js → link-DxSeFzFf.js} +1 -1
- package/web/dist/assets/{tasks-C1wf85BJ.js → tasks-BeWF0Xy2.js} +1 -1
- package/web/dist/assets/useMatch-HNekgJrF.js +1 -0
- package/web/dist/assets/{useStickyScroll-7ZZcyLI0.js → useStickyScroll-BmqddNx5.js} +1 -1
- package/web/dist/index.html +9 -9
- package/web/dist/assets/ChannelView-B3qNylz5.js +0 -2
- package/web/dist/assets/KnowledgeView-C8r5hAa_.js +0 -1
- package/web/dist/assets/MessageItem-BkDlfhQC.js +0 -1
- package/web/dist/assets/ProjectView-DOtPYF6z.js +0 -1
- package/web/dist/assets/ProposalView-BpeiCg46.js +0 -1
- package/web/dist/assets/SocietyView-CbIpCGhP.js +0 -1
- package/web/dist/assets/SubjectThread-Do5As1cO.js +0 -1
- package/web/dist/assets/ThreadCard-DsIkg4Br.js +0 -1
- package/web/dist/assets/ThreadForms-BPowHd4M.js +0 -1
- package/web/dist/assets/compose-96HAETM2.js +0 -1
- package/web/dist/assets/governance-BbogC_X-.js +0 -1
- package/web/dist/assets/index-BXHV32GA.js +0 -23
- package/web/dist/assets/index-BoTts74M.css +0 -2
- package/web/dist/assets/useMatch-DYOY33wl.js +0 -1
package/dist/server/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import path9 from "node:path";
|
|
|
6
6
|
import { serve } from "@hono/node-server";
|
|
7
7
|
|
|
8
8
|
// packages/board-core/dist/board.js
|
|
9
|
-
import { mkdir as mkdir4, readdir as readdir3, readFile as readFile5, rename as rename3, stat as stat2 } from "node:fs/promises";
|
|
9
|
+
import { chmod as chmod2, cp, mkdir as mkdir4, readdir as readdir3, readFile as readFile5, rename as rename3, rm as rm2, stat as stat2, writeFile as writeFile4 } from "node:fs/promises";
|
|
10
10
|
import path6 from "node:path";
|
|
11
11
|
import { decodeTime, monotonicFactory } from "ulid";
|
|
12
12
|
import { z as z13 } from "zod";
|
|
@@ -63,7 +63,8 @@ var VERB_NAMES = [
|
|
|
63
63
|
"update_dashboard",
|
|
64
64
|
"create_cron",
|
|
65
65
|
"update_cron",
|
|
66
|
-
"remove_cron"
|
|
66
|
+
"remove_cron",
|
|
67
|
+
"remove_skill"
|
|
67
68
|
];
|
|
68
69
|
var VerbNameSchema = z2.enum(VERB_NAMES);
|
|
69
70
|
var CharterTriggerSchema = z2.enum(["user_post", "ops_event", "heartbeat"]);
|
|
@@ -123,6 +124,7 @@ var PLANNING_VERBS = [
|
|
|
123
124
|
"archive_channel"
|
|
124
125
|
];
|
|
125
126
|
var ARCHIVE_VERBS = ["archive_project"];
|
|
127
|
+
var CURATING_VERBS = ["remove_skill"];
|
|
126
128
|
var SEED_ROLES = [
|
|
127
129
|
{
|
|
128
130
|
name: USER_ROLE,
|
|
@@ -132,7 +134,8 @@ var SEED_ROLES = [
|
|
|
132
134
|
...GOVERNANCE_VERBS,
|
|
133
135
|
...FRONT_DESK_VERBS,
|
|
134
136
|
...PLANNING_VERBS,
|
|
135
|
-
...ARCHIVE_VERBS
|
|
137
|
+
...ARCHIVE_VERBS,
|
|
138
|
+
...CURATING_VERBS
|
|
136
139
|
],
|
|
137
140
|
wakeTriggers: [],
|
|
138
141
|
maxReplicas: 1,
|
|
@@ -144,7 +147,7 @@ var SEED_ROLES = [
|
|
|
144
147
|
{
|
|
145
148
|
name: "steward",
|
|
146
149
|
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.",
|
|
147
|
-
verbs: [...MEMBER_VERBS, ...GOVERNANCE_VERBS, ...PLANNING_VERBS],
|
|
150
|
+
verbs: [...MEMBER_VERBS, ...GOVERNANCE_VERBS, ...PLANNING_VERBS, ...CURATING_VERBS],
|
|
148
151
|
wakeTriggers: ["ops_event", "heartbeat"],
|
|
149
152
|
maxReplicas: 1,
|
|
150
153
|
backlogThreshold: 3,
|
|
@@ -634,7 +637,7 @@ var ArchiveProposalSchema = z4.object({
|
|
|
634
637
|
var SkillProposalSchema = z4.object({
|
|
635
638
|
name: NameSchema,
|
|
636
639
|
summary: z4.string().min(1).max(200),
|
|
637
|
-
body: z4.string().min(1)
|
|
640
|
+
body: z4.string().min(1).optional()
|
|
638
641
|
});
|
|
639
642
|
var ProposalCharterSchemas = {
|
|
640
643
|
role: RoleCharterSchema,
|
|
@@ -712,6 +715,12 @@ var SkillSchema = z4.object({
|
|
|
712
715
|
scope: z4.enum(["own", "society"]),
|
|
713
716
|
path: z4.string().min(1)
|
|
714
717
|
});
|
|
718
|
+
var RemovedSkillSchema = z4.object({
|
|
719
|
+
name: NameSchema,
|
|
720
|
+
reason: z4.string().min(1),
|
|
721
|
+
removedBy: NameSchema,
|
|
722
|
+
removedAt: IsoDateTimeSchema
|
|
723
|
+
});
|
|
715
724
|
var HomeConflictSchema = z4.object({
|
|
716
725
|
/** The copy, relative to the home. */
|
|
717
726
|
path: z4.string().min(1),
|
|
@@ -755,6 +764,7 @@ var HomeFileDiffSchema = HomeFileChangeSchema.pick({ path: true, status: true })
|
|
|
755
764
|
var KnowledgeSchema = z4.object({
|
|
756
765
|
topic: NameSchema,
|
|
757
766
|
project: NameSchema.nullable(),
|
|
767
|
+
revision: z4.number().int().min(0),
|
|
758
768
|
updatedBy: NameSchema,
|
|
759
769
|
updatedAt: IsoDateTimeSchema
|
|
760
770
|
});
|
|
@@ -777,6 +787,24 @@ var ProposalOriginSchema = z4.object({
|
|
|
777
787
|
thread: UlidSchema.optional(),
|
|
778
788
|
channel: NameSchema.optional()
|
|
779
789
|
});
|
|
790
|
+
var SKILL_FILES_LIMIT = 100;
|
|
791
|
+
var SKILL_BYTES_LIMIT = 10 * 1024 * 1024;
|
|
792
|
+
var SkillFileSchema = z4.object({
|
|
793
|
+
path: z4.string().min(1),
|
|
794
|
+
size: z4.number().int().nonnegative(),
|
|
795
|
+
executable: z4.boolean()
|
|
796
|
+
});
|
|
797
|
+
var SkillFilesSchema = z4.object({
|
|
798
|
+
/** The commit of the proposer's home the files came from. */
|
|
799
|
+
commit: CommitIdSchema,
|
|
800
|
+
takenAt: IsoDateTimeSchema,
|
|
801
|
+
files: z4.array(SkillFileSchema)
|
|
802
|
+
});
|
|
803
|
+
var SkillFileContentSchema = SkillFileSchema.extend({
|
|
804
|
+
text: z4.string().nullable(),
|
|
805
|
+
/** The text was cut at its size limit. */
|
|
806
|
+
truncated: z4.boolean()
|
|
807
|
+
});
|
|
780
808
|
var ProposalFrontmatterSchema = z4.object({
|
|
781
809
|
id: UlidSchema,
|
|
782
810
|
kind: ProposalKindSchema,
|
|
@@ -785,6 +813,8 @@ var ProposalFrontmatterSchema = z4.object({
|
|
|
785
813
|
createdAt: IsoDateTimeSchema,
|
|
786
814
|
/** Absent for a proposal filed outside a turn, or before proposals recorded it. */
|
|
787
815
|
filedFrom: ProposalOriginSchema.optional(),
|
|
816
|
+
/** A skill proposal's folder, once taken; absent for one proposed as text alone. */
|
|
817
|
+
skillFiles: SkillFilesSchema.optional(),
|
|
788
818
|
decidedBy: NameSchema.optional(),
|
|
789
819
|
decidedAt: IsoDateTimeSchema.optional(),
|
|
790
820
|
reason: z4.string().optional(),
|
|
@@ -887,7 +917,9 @@ var VerbInputs = {
|
|
|
887
917
|
/** A project you belong to, or null for society knowledge, which the steward and the user curate. */
|
|
888
918
|
project: NameSchema.nullable().default(null),
|
|
889
919
|
topic: NameSchema,
|
|
890
|
-
body: z5.string().min(1)
|
|
920
|
+
body: z5.string().min(1),
|
|
921
|
+
/** The `revision` in the frontmatter of the topic you read, 0 when it names none or for a new topic. */
|
|
922
|
+
revision: z5.number().int().min(0)
|
|
891
923
|
}),
|
|
892
924
|
remove_knowledge: z5.object({
|
|
893
925
|
/** As for write_knowledge: a project you belong to, or null for society knowledge. */
|
|
@@ -955,7 +987,8 @@ var VerbInputs = {
|
|
|
955
987
|
timezone: TimeZoneSchema.nullable().optional(),
|
|
956
988
|
at: MomentSchema.optional()
|
|
957
989
|
}),
|
|
958
|
-
remove_cron: z5.object({ cron_id: UlidSchema, reason: z5.string().trim().min(1) })
|
|
990
|
+
remove_cron: z5.object({ cron_id: UlidSchema, reason: z5.string().trim().min(1) }),
|
|
991
|
+
remove_skill: z5.object({ name: NameSchema, reason: z5.string().trim().min(1) })
|
|
959
992
|
};
|
|
960
993
|
var VERB_DESCRIPTIONS = {
|
|
961
994
|
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.",
|
|
@@ -976,7 +1009,7 @@ var VERB_DESCRIPTIONS = {
|
|
|
976
1009
|
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, merge, or ghpr for a GitHub remote).",
|
|
977
1010
|
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.",
|
|
978
1011
|
leave_project: "Leave a project, or remove another citizen from one when your role allows it.",
|
|
979
|
-
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.",
|
|
1012
|
+
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. Pass the revision from the frontmatter of the topic you read, or 0 for a new topic; if it was written since, the call is refused with the current text to merge yours into.",
|
|
980
1013
|
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.",
|
|
981
1014
|
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.",
|
|
982
1015
|
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.",
|
|
@@ -987,7 +1020,8 @@ var VERB_DESCRIPTIONS = {
|
|
|
987
1020
|
update_dashboard: "Replace the dashboard of a project you are a member of, the status page the user reads on its view, with a whole markdown body. Pass the revision from the frontmatter of the dashboard you read; if it was updated since, the call is refused with the current text to merge yours into. dashboard.md in the board projection is a copy, so editing it changes nothing.",
|
|
988
1021
|
create_cron: "Set a cron that wakes you later: a title, a note on what to do then, and either cron, a five-field expression (minute hour day-of-month month day-of-week) read in timezone or the society's, or at, one time. It fires in this turn's conversation unless you name a project, thread_id, or channel, at most every 15 minutes, and every fire is a turn. Only the user, the steward, and the concierge set one that wakes another citizen (agent).",
|
|
989
1022
|
update_cron: "Pause or resume a cron, or change its title, note, or schedule; resuming or rescheduling starts it afresh from now. Yours, or anyone's for the user, the steward, and the concierge.",
|
|
990
|
-
remove_cron: "End a cron for good, with a reason. Yours, or anyone's for the user, the steward, and the concierge. A cron also ends with its conversation, and a one-time cron once it fires."
|
|
1023
|
+
remove_cron: "End a cron for good, with a reason. Yours, or anyone's for the user, the steward, and the concierge. A cron also ends with its conversation, and a one-time cron once it fires.",
|
|
1024
|
+
remove_skill: "Remove a skill from the society's skills, with a reason, when it is wrong, superseded, or no longer used: no skills index lists it from the next turn, and a notice in general says so with the reason. Its proposer's own copy stays in its home. Steward and user only; the board keeps its text aside for the user to restore."
|
|
991
1025
|
};
|
|
992
1026
|
|
|
993
1027
|
// packages/shared/dist/events.js
|
|
@@ -1019,6 +1053,7 @@ var BOARD_EVENT_TYPES = [
|
|
|
1019
1053
|
"proposal.created",
|
|
1020
1054
|
"proposal.decided",
|
|
1021
1055
|
"proposal.provisioned",
|
|
1056
|
+
"proposal.files_taken",
|
|
1022
1057
|
"channel.added",
|
|
1023
1058
|
"channel.archived",
|
|
1024
1059
|
"role.added",
|
|
@@ -1029,6 +1064,7 @@ var BOARD_EVENT_TYPES = [
|
|
|
1029
1064
|
"knowledge.removed",
|
|
1030
1065
|
"dashboard.updated",
|
|
1031
1066
|
"skill.promoted",
|
|
1067
|
+
"skill.removed",
|
|
1032
1068
|
"runner.added",
|
|
1033
1069
|
"user.signed_in",
|
|
1034
1070
|
"user.signed_out",
|
|
@@ -1446,6 +1482,9 @@ var RunnerAnswerSchema = z7.discriminatedUnion("ok", [
|
|
|
1446
1482
|
]);
|
|
1447
1483
|
var WarmSessionsSchema = z7.object({ warm: z7.array(z7.string()) });
|
|
1448
1484
|
var FileManifestSchema = z7.object({ files: z7.record(z7.string(), z7.string()) });
|
|
1485
|
+
function fileVersion(sha, executable) {
|
|
1486
|
+
return executable ? `${sha}+x` : sha;
|
|
1487
|
+
}
|
|
1449
1488
|
var FileReadSchema = z7.object({ paths: z7.array(z7.string()) });
|
|
1450
1489
|
var FileContentsSchema = z7.object({ files: z7.record(z7.string(), z7.string()) });
|
|
1451
1490
|
function isSafeRelativePath(file) {
|
|
@@ -1822,7 +1861,7 @@ var RunnerConfigSchema = z11.object({
|
|
|
1822
1861
|
});
|
|
1823
1862
|
|
|
1824
1863
|
// packages/shared/dist/version.js
|
|
1825
|
-
var STELLARIS_VERSION = "0.3.
|
|
1864
|
+
var STELLARIS_VERSION = "0.3.19";
|
|
1826
1865
|
|
|
1827
1866
|
// packages/board-core/dist/errors.js
|
|
1828
1867
|
var BoardError = class extends Error {
|
|
@@ -1879,7 +1918,9 @@ async function readMarkdown(file, schema) {
|
|
|
1879
1918
|
return { data: schema.parse(parsed.data), body: parsed.content.replace(/^\n/, "") };
|
|
1880
1919
|
}
|
|
1881
1920
|
async function readLooseMarkdown(file) {
|
|
1882
|
-
|
|
1921
|
+
return parseLooseMarkdown(await readFile(file, "utf8"));
|
|
1922
|
+
}
|
|
1923
|
+
function parseLooseMarkdown(raw) {
|
|
1883
1924
|
try {
|
|
1884
1925
|
const parsed = parseMatter(raw);
|
|
1885
1926
|
return { data: parsed.data, body: parsed.content.replace(/^\n/, ""), error: null };
|
|
@@ -2066,13 +2107,14 @@ var FileTree = class {
|
|
|
2066
2107
|
if (info.size > MAX_FILE_BYTES) {
|
|
2067
2108
|
return null;
|
|
2068
2109
|
}
|
|
2110
|
+
const executable = (info.mode & 73) !== 0;
|
|
2069
2111
|
const known = this.hashes.get(absolute);
|
|
2070
2112
|
if (known !== void 0 && known.mtimeMs === info.mtimeMs && known.size === info.size) {
|
|
2071
|
-
return known.sha;
|
|
2113
|
+
return fileVersion(known.sha, executable);
|
|
2072
2114
|
}
|
|
2073
2115
|
const sha = createHash("sha256").update(await readFile3(absolute)).digest("hex");
|
|
2074
2116
|
this.hashes.set(absolute, { mtimeMs: info.mtimeMs, size: info.size, sha });
|
|
2075
|
-
return sha;
|
|
2117
|
+
return fileVersion(sha, executable);
|
|
2076
2118
|
} catch (error) {
|
|
2077
2119
|
if (isMissing2(error)) {
|
|
2078
2120
|
return null;
|
|
@@ -2323,6 +2365,55 @@ var HomeRepos = class {
|
|
|
2323
2365
|
}
|
|
2324
2366
|
return files;
|
|
2325
2367
|
}
|
|
2368
|
+
/**
|
|
2369
|
+
* The files of a folder in a home's newest commit, by path within the folder, with the commit;
|
|
2370
|
+
* conflict copies and anything but a regular file are left out. Null when the home is not a
|
|
2371
|
+
* repository yet.
|
|
2372
|
+
*/
|
|
2373
|
+
async folder(home, folder) {
|
|
2374
|
+
if (!await exists(path4.join(home, ".git"))) {
|
|
2375
|
+
return null;
|
|
2376
|
+
}
|
|
2377
|
+
const commit = (await read(home, ["rev-parse", "--verify", "HEAD^{commit}"])).trim();
|
|
2378
|
+
const prefix = `${folder}/`;
|
|
2379
|
+
const listed = await read(home, [
|
|
2380
|
+
"ls-tree",
|
|
2381
|
+
"-r",
|
|
2382
|
+
"-z",
|
|
2383
|
+
"--long",
|
|
2384
|
+
"--full-tree",
|
|
2385
|
+
commit,
|
|
2386
|
+
prefix
|
|
2387
|
+
]);
|
|
2388
|
+
const files = [];
|
|
2389
|
+
for (const entry of listed.split("\0")) {
|
|
2390
|
+
const match = /^(100644|100755) blob ([0-9a-f]+) +(\d+)\t(.+)$/.exec(entry);
|
|
2391
|
+
const [, mode = "", object = "", size = "0", file = ""] = match ?? [];
|
|
2392
|
+
if (match === null || !file.startsWith(prefix) || !isSafeRelativePath(file) || conflictCopyOf(file) !== null) {
|
|
2393
|
+
continue;
|
|
2394
|
+
}
|
|
2395
|
+
files.push({
|
|
2396
|
+
path: file.slice(prefix.length),
|
|
2397
|
+
object,
|
|
2398
|
+
size: Number.parseInt(size, 10),
|
|
2399
|
+
executable: mode === "100755"
|
|
2400
|
+
});
|
|
2401
|
+
}
|
|
2402
|
+
return { commit, files };
|
|
2403
|
+
}
|
|
2404
|
+
/** A blob of a home's repository, as `folder` names it. */
|
|
2405
|
+
async blob(home, object) {
|
|
2406
|
+
const result = await execa("git", ["cat-file", "blob", object], {
|
|
2407
|
+
cwd: home,
|
|
2408
|
+
encoding: "buffer",
|
|
2409
|
+
stripFinalNewline: false,
|
|
2410
|
+
reject: false
|
|
2411
|
+
});
|
|
2412
|
+
if (result.exitCode !== 0) {
|
|
2413
|
+
throw new Error(`git cat-file failed: ${Buffer.from(result.stderr).toString().trim()}`);
|
|
2414
|
+
}
|
|
2415
|
+
return Buffer.from(result.stdout);
|
|
2416
|
+
}
|
|
2326
2417
|
/** The conflict copies a merge added, against its first parent. */
|
|
2327
2418
|
async keptCopies(home, parent, commit) {
|
|
2328
2419
|
const added = await read(home, [
|
|
@@ -2738,6 +2829,10 @@ var BoardPaths = class {
|
|
|
2738
2829
|
proposal(id) {
|
|
2739
2830
|
return path5.join(this.proposals(), `${id}.md`);
|
|
2740
2831
|
}
|
|
2832
|
+
/** A skill proposal's folder, as the board took it from its proposer's home. */
|
|
2833
|
+
proposalFiles(id) {
|
|
2834
|
+
return path5.join(this.proposals(), id);
|
|
2835
|
+
}
|
|
2741
2836
|
decisions() {
|
|
2742
2837
|
return path5.join(this.society, "decisions");
|
|
2743
2838
|
}
|
|
@@ -2885,6 +2980,10 @@ var BoardPaths = class {
|
|
|
2885
2980
|
removedKnowledge(scope) {
|
|
2886
2981
|
return path5.join(this.state(), "removed-knowledge", scope);
|
|
2887
2982
|
}
|
|
2983
|
+
/** Skills removed from the society, set aside outside the projection, one directory per removal. */
|
|
2984
|
+
removedSkills() {
|
|
2985
|
+
return path5.join(this.state(), "removed-skills");
|
|
2986
|
+
}
|
|
2888
2987
|
};
|
|
2889
2988
|
|
|
2890
2989
|
// packages/board-core/dist/tokens.js
|
|
@@ -2985,6 +3084,20 @@ var MENTION_PATTERN = /(^|[^\w@])@([a-z0-9][a-z0-9-]{0,31})(?![\w-])/g;
|
|
|
2985
3084
|
function withoutMentions(text) {
|
|
2986
3085
|
return text.replaceAll(MENTION_PATTERN, "$1$2");
|
|
2987
3086
|
}
|
|
3087
|
+
var SKILL_TEXT_LIMIT_CHARS = 1e5;
|
|
3088
|
+
function skillFolderOf(proposal) {
|
|
3089
|
+
if (proposal.kind !== "skill") {
|
|
3090
|
+
return null;
|
|
3091
|
+
}
|
|
3092
|
+
const parsed = SkillProposalSchema.safeParse(proposal.charter);
|
|
3093
|
+
return parsed.success && parsed.data.body === void 0 ? parsed.data.name : null;
|
|
3094
|
+
}
|
|
3095
|
+
function proposedFrom(origin, where) {
|
|
3096
|
+
return origin !== void 0 && origin.scope === where.scope && origin.thread === where.thread && origin.channel === where.channel;
|
|
3097
|
+
}
|
|
3098
|
+
function megabytes(bytes) {
|
|
3099
|
+
return (bytes / (1024 * 1024)).toFixed(1).replace(/\.0$/, "");
|
|
3100
|
+
}
|
|
2988
3101
|
var CURATING_ROLES = [USER_ROLE, "steward"];
|
|
2989
3102
|
var SEED_INSTRUCTIONS_HEADING = "## Seed instructions";
|
|
2990
3103
|
var OPS_WAKE_TRIGGER = "ops_event";
|
|
@@ -3907,22 +4020,41 @@ ${charter.purpose}
|
|
|
3907
4020
|
const dir = project === null ? this.paths.societyKnowledge() : this.paths.projectKnowledge(project);
|
|
3908
4021
|
const topics = [];
|
|
3909
4022
|
for (const file of await listFiles(dir)) {
|
|
3910
|
-
|
|
3911
|
-
const parsed = KnowledgeSchema.safeParse({
|
|
3912
|
-
...doc.data,
|
|
3913
|
-
project,
|
|
3914
|
-
topic: file.replace(/\.md$/, "")
|
|
3915
|
-
});
|
|
3916
|
-
const data = parsed.success ? parsed.data : {
|
|
3917
|
-
topic: file.replace(/\.md$/, ""),
|
|
3918
|
-
project,
|
|
3919
|
-
updatedBy: SYSTEM_ACTOR.name,
|
|
3920
|
-
updatedAt: (await stat2(path6.join(dir, file))).mtime.toISOString()
|
|
3921
|
-
};
|
|
3922
|
-
topics.push({ ...data, body: doc.body });
|
|
4023
|
+
topics.push(await this.knowledgeAt(path6.join(dir, file), project, file.replace(/\.md$/, "")));
|
|
3923
4024
|
}
|
|
3924
4025
|
return topics;
|
|
3925
4026
|
}
|
|
4027
|
+
knowledgeFile(project, topic) {
|
|
4028
|
+
return project === null ? this.paths.societyKnowledgeFile(topic) : this.paths.projectKnowledgeFile(project, topic);
|
|
4029
|
+
}
|
|
4030
|
+
/**
|
|
4031
|
+
* Read loosely, as a dashboard is: a topic written before revisions counts as revision 0, and an
|
|
4032
|
+
* author or a time that does not parse is the board's at the file's mtime.
|
|
4033
|
+
*/
|
|
4034
|
+
async knowledgeAt(file, project, topic) {
|
|
4035
|
+
const { data, body } = await readLooseMarkdown(file);
|
|
4036
|
+
const revision = z13.number().int().min(0).safeParse(data["revision"]);
|
|
4037
|
+
const updatedBy = NameSchema.safeParse(data["updatedBy"]);
|
|
4038
|
+
const updatedAt = IsoDateTimeSchema.safeParse(data["updatedAt"]);
|
|
4039
|
+
return {
|
|
4040
|
+
topic,
|
|
4041
|
+
project,
|
|
4042
|
+
revision: revision.success ? revision.data : 0,
|
|
4043
|
+
updatedBy: updatedBy.success ? updatedBy.data : SYSTEM_ACTOR.name,
|
|
4044
|
+
updatedAt: updatedAt.success ? updatedAt.data : (await stat2(file)).mtime.toISOString(),
|
|
4045
|
+
body
|
|
4046
|
+
};
|
|
4047
|
+
}
|
|
4048
|
+
/** The highest revision among a topic's removed copies, 0 when it has none. */
|
|
4049
|
+
async removedRevision(project, topic) {
|
|
4050
|
+
const aside = this.paths.removedKnowledge(project ?? SOCIETY_SCOPE);
|
|
4051
|
+
let highest = 0;
|
|
4052
|
+
for (const file of (await listFiles(aside)).filter((name) => name.startsWith(`${topic}.`))) {
|
|
4053
|
+
const copy = await this.knowledgeAt(path6.join(aside, file), project, topic);
|
|
4054
|
+
highest = Math.max(highest, copy.revision);
|
|
4055
|
+
}
|
|
4056
|
+
return highest;
|
|
4057
|
+
}
|
|
3926
4058
|
/** Skills promoted to the society: every citizen may read them. */
|
|
3927
4059
|
async listSocietySkills() {
|
|
3928
4060
|
return this.listSkillsIn(this.paths.societySkills(), "society");
|
|
@@ -4636,26 +4768,42 @@ ${task.body}`;
|
|
|
4636
4768
|
}
|
|
4637
4769
|
/**
|
|
4638
4770
|
* Writes a topic: a project's by its members, the society's by the curating roles. The note in
|
|
4639
|
-
* the matching general channel carries no mention, so nobody is woken.
|
|
4771
|
+
* the matching general channel carries no mention, so nobody is woken. A turn reads a topic from
|
|
4772
|
+
* the copy of the projection pulled when it started, so a revision other than the current one, 0
|
|
4773
|
+
* for a topic not there, means someone wrote or removed it since; the refusal carries the current
|
|
4774
|
+
* text, which that copy lacks.
|
|
4640
4775
|
*/
|
|
4641
4776
|
async writeKnowledge(actor, input) {
|
|
4642
4777
|
const args = VerbInputs.write_knowledge.parse(input);
|
|
4643
4778
|
await this.authorize(actor, "write_knowledge");
|
|
4644
4779
|
return this.mutex.run(async () => {
|
|
4645
4780
|
await this.assertMayCurate(actor, args.project);
|
|
4781
|
+
const file = this.knowledgeFile(args.project, args.topic);
|
|
4782
|
+
const current = await exists(file) ? await this.knowledgeAt(file, args.project, args.topic) : null;
|
|
4783
|
+
const place = args.project ?? "the society";
|
|
4784
|
+
if (current === null && args.revision !== 0) {
|
|
4785
|
+
throw new BoardError("INVALID_STATE", `${place} has no knowledge topic ${args.topic}, so it has no revision ${args.revision}: it was removed since you read it, or never written. Write it with revision 0 to start it anew.`);
|
|
4786
|
+
}
|
|
4787
|
+
if (current !== null && args.revision !== current.revision) {
|
|
4788
|
+
throw new BoardError("INVALID_STATE", `the knowledge topic ${args.topic} of ${place} is at revision ${current.revision}, updated by ${current.updatedBy} at ${current.updatedAt}, not ${args.revision}. Merge your changes into its current text, below, and write again with revision ${current.revision}.
|
|
4789
|
+
|
|
4790
|
+
${current.body}`);
|
|
4791
|
+
}
|
|
4792
|
+
const base = current?.revision ?? await this.removedRevision(args.project, args.topic);
|
|
4646
4793
|
const data = KnowledgeSchema.parse({
|
|
4647
4794
|
topic: args.topic,
|
|
4648
4795
|
project: args.project,
|
|
4796
|
+
revision: base + 1,
|
|
4649
4797
|
updatedBy: actor.name,
|
|
4650
4798
|
updatedAt: this.now().toISOString()
|
|
4651
4799
|
});
|
|
4652
|
-
const
|
|
4653
|
-
const replaced = await exists(file);
|
|
4800
|
+
const replaced = current !== null;
|
|
4654
4801
|
await writeMarkdown(file, data, args.body);
|
|
4655
4802
|
await this.events.append("knowledge.written", actor.name, {
|
|
4656
4803
|
topic: args.topic,
|
|
4657
4804
|
project: args.project,
|
|
4658
|
-
replaced
|
|
4805
|
+
replaced,
|
|
4806
|
+
revision: data.revision
|
|
4659
4807
|
});
|
|
4660
4808
|
const where = args.project === null ? "general" : channelRef(args.project, "general");
|
|
4661
4809
|
await this.appendMessage(actor.name, where, `Knowledge ${replaced ? "updated" : "written"}: ${args.topic}. It is under knowledge/${args.topic}.md${args.project === null ? " of the society" : ""}.`, {
|
|
@@ -4678,7 +4826,7 @@ ${task.body}`;
|
|
|
4678
4826
|
await this.authorize(actor, "remove_knowledge");
|
|
4679
4827
|
return this.mutex.run(async () => {
|
|
4680
4828
|
await this.assertMayCurate(actor, args.project);
|
|
4681
|
-
const file =
|
|
4829
|
+
const file = this.knowledgeFile(args.project, args.topic);
|
|
4682
4830
|
if (!await exists(file)) {
|
|
4683
4831
|
throw new BoardError("NOT_FOUND", `${args.project ?? "the society"} has no knowledge topic ${args.topic}`);
|
|
4684
4832
|
}
|
|
@@ -4713,6 +4861,40 @@ ${task.body}`;
|
|
|
4713
4861
|
throw new BoardError("FORBIDDEN", `${actor.name} is not a member of ${project}`);
|
|
4714
4862
|
}
|
|
4715
4863
|
}
|
|
4864
|
+
/**
|
|
4865
|
+
* Takes a skill out of the society's skills, by the steward or the user. Its directory leaves
|
|
4866
|
+
* the projection, so no skills index or search lists it from the next turn, and is set aside
|
|
4867
|
+
* under `state/removed-skills` for the user to restore by hand. The notice is the board's, as a
|
|
4868
|
+
* promotion's is, so a removal by the user wakes no front desk, and quotes the reason without
|
|
4869
|
+
* its mentions. The proposer's own copy in its home is not touched.
|
|
4870
|
+
*/
|
|
4871
|
+
async removeSkill(actor, input) {
|
|
4872
|
+
const args = VerbInputs.remove_skill.parse(input);
|
|
4873
|
+
await this.authorize(actor, "remove_skill");
|
|
4874
|
+
if (!CURATING_ROLES.includes(actor.role)) {
|
|
4875
|
+
throw new BoardError("FORBIDDEN", "the society's skills are curated by the steward and the user");
|
|
4876
|
+
}
|
|
4877
|
+
return this.mutex.run(async () => {
|
|
4878
|
+
if (!await exists(this.paths.societySkillFile(args.name))) {
|
|
4879
|
+
throw new BoardError("NOT_FOUND", `the society has no skill ${args.name}`);
|
|
4880
|
+
}
|
|
4881
|
+
const removed = RemovedSkillSchema.parse({
|
|
4882
|
+
name: args.name,
|
|
4883
|
+
reason: args.reason,
|
|
4884
|
+
removedBy: actor.name,
|
|
4885
|
+
removedAt: this.now().toISOString()
|
|
4886
|
+
});
|
|
4887
|
+
const aside = this.paths.removedSkills();
|
|
4888
|
+
await mkdir4(aside, { recursive: true });
|
|
4889
|
+
await rename3(this.paths.societySkill(args.name), path6.join(aside, `${args.name}.${removed.removedAt.replaceAll(":", "-")}`));
|
|
4890
|
+
await this.events.append("skill.removed", actor.name, {
|
|
4891
|
+
name: args.name,
|
|
4892
|
+
reason: args.reason
|
|
4893
|
+
});
|
|
4894
|
+
await this.appendMessage(SYSTEM_ACTOR.name, "general", `Skill ${args.name} removed from the society by ${actor.name}: ${sentence(withoutMentions(args.reason))}. Every citizen's skills index stops listing it from its next turn.`);
|
|
4895
|
+
return removed;
|
|
4896
|
+
});
|
|
4897
|
+
}
|
|
4716
4898
|
/**
|
|
4717
4899
|
* Replaces a project's dashboard, by a member or the user. A turn reads the dashboard from the
|
|
4718
4900
|
* copy of the projection pulled when it started, so a revision other than the current one means
|
|
@@ -5695,7 +5877,7 @@ ${args.summary}`);
|
|
|
5695
5877
|
throw new BoardError("VALIDATION", `invalid ${args.kind} charter: ${parsed.error.message}`);
|
|
5696
5878
|
}
|
|
5697
5879
|
return this.mutex.run(async () => {
|
|
5698
|
-
|
|
5880
|
+
let proposal = {
|
|
5699
5881
|
...ProposalFrontmatterSchema.parse({
|
|
5700
5882
|
id: this.newId(),
|
|
5701
5883
|
kind: args.kind,
|
|
@@ -5714,6 +5896,14 @@ ${args.summary}`);
|
|
|
5714
5896
|
body: args.rationale
|
|
5715
5897
|
};
|
|
5716
5898
|
await this.validateProvision(proposal.kind, proposal.charter);
|
|
5899
|
+
const skill = skillFolderOf(proposal);
|
|
5900
|
+
if (skill !== null && proposal.filedFrom === void 0) {
|
|
5901
|
+
const taken = await this.takeSkillFolderUnlocked(proposal, skill);
|
|
5902
|
+
if (typeof taken === "string") {
|
|
5903
|
+
throw new BoardError("VALIDATION", taken);
|
|
5904
|
+
}
|
|
5905
|
+
proposal = { ...proposal, skillFiles: taken };
|
|
5906
|
+
}
|
|
5717
5907
|
await this.writeProposal(proposal);
|
|
5718
5908
|
await this.events.append("proposal.created", actor.name, {
|
|
5719
5909
|
proposalId: proposal.id,
|
|
@@ -5935,6 +6125,8 @@ ${rationale}`}`);
|
|
|
5935
6125
|
return this.writeKnowledge(actor, VerbInputs.write_knowledge.parse(input));
|
|
5936
6126
|
case "remove_knowledge":
|
|
5937
6127
|
return this.removeKnowledge(actor, VerbInputs.remove_knowledge.parse(input));
|
|
6128
|
+
case "remove_skill":
|
|
6129
|
+
return this.removeSkill(actor, VerbInputs.remove_skill.parse(input));
|
|
5938
6130
|
case "plan_task":
|
|
5939
6131
|
return this.planTask(actor, VerbInputs.plan_task.parse(input));
|
|
5940
6132
|
case "advance_task":
|
|
@@ -6928,7 +7120,7 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
|
|
|
6928
7120
|
}
|
|
6929
7121
|
case "skill": {
|
|
6930
7122
|
const skill = SkillProposalSchema.parse(proposal.charter);
|
|
6931
|
-
const replaced = await this.promoteSkillUnlocked(by, proposal
|
|
7123
|
+
const replaced = await this.promoteSkillUnlocked(by, proposal, skill, meta);
|
|
6932
7124
|
return { skill: skill.name, replaced };
|
|
6933
7125
|
}
|
|
6934
7126
|
case "archive": {
|
|
@@ -6942,26 +7134,180 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
|
|
|
6942
7134
|
return void 0;
|
|
6943
7135
|
}
|
|
6944
7136
|
}
|
|
6945
|
-
/**
|
|
6946
|
-
|
|
7137
|
+
/**
|
|
7138
|
+
* Publishes a promoted skill under the society's skills, where every citizen's skills index lists
|
|
7139
|
+
* it: the folder the board took from its proposer's home, or the text proposed alone. A
|
|
7140
|
+
* replacement is the whole skill, so files the new version dropped go with the old one.
|
|
7141
|
+
*/
|
|
7142
|
+
async promoteSkillUnlocked(by, proposal, skill, meta) {
|
|
7143
|
+
const dir = this.paths.societySkill(skill.name);
|
|
6947
7144
|
const file = this.paths.societySkillFile(skill.name);
|
|
6948
7145
|
const replaced = await exists(file);
|
|
6949
|
-
|
|
7146
|
+
const taken = proposal.skillFiles;
|
|
7147
|
+
const staged = path6.join(this.paths.proposalFiles(proposal.id), "SKILL.md");
|
|
7148
|
+
if (skill.body === void 0 && (taken === void 0 || !await exists(staged))) {
|
|
7149
|
+
throw new BoardError("INVALID_STATE", `proposal ${proposal.id} holds no skill files`);
|
|
7150
|
+
}
|
|
7151
|
+
await rm2(dir, { recursive: true, force: true });
|
|
7152
|
+
if (skill.body === void 0) {
|
|
7153
|
+
await cp(this.paths.proposalFiles(proposal.id), dir, { recursive: true });
|
|
7154
|
+
} else {
|
|
7155
|
+
await ensureDir(dir);
|
|
7156
|
+
}
|
|
7157
|
+
const body = skill.body ?? (await readLooseMarkdown(file)).body;
|
|
6950
7158
|
await writeMarkdown(file, {
|
|
6951
7159
|
name: skill.name,
|
|
6952
7160
|
description: skill.summary,
|
|
6953
|
-
metadata: {
|
|
6954
|
-
|
|
7161
|
+
metadata: {
|
|
7162
|
+
proposedBy: proposal.proposedBy,
|
|
7163
|
+
promotedBy: by,
|
|
7164
|
+
promotedAt: this.now().toISOString(),
|
|
7165
|
+
...taken === void 0 ? {} : { commit: taken.commit }
|
|
7166
|
+
}
|
|
7167
|
+
}, body);
|
|
6955
7168
|
await this.events.append("skill.promoted", by, {
|
|
6956
7169
|
name: skill.name,
|
|
6957
7170
|
summary: skill.summary,
|
|
6958
|
-
proposedBy,
|
|
7171
|
+
proposedBy: proposal.proposedBy,
|
|
6959
7172
|
replaced,
|
|
7173
|
+
...taken === void 0 ? {} : { files: taken.files.length },
|
|
6960
7174
|
...meta
|
|
6961
7175
|
});
|
|
6962
7176
|
await this.appendMessage(SYSTEM_ACTOR.name, "general", `Skill ${skill.name} ${replaced ? "updated in" : "promoted to"} the society: ${sentence(skill.summary)}. Every citizen's skills index now lists it.`);
|
|
6963
7177
|
return replaced;
|
|
6964
7178
|
}
|
|
7179
|
+
/**
|
|
7180
|
+
* Copies a skill's folder from its proposer's home, as the home's newest commit holds it, into the
|
|
7181
|
+
* proposal's folder in the projection, where its deciders read it and from where approval
|
|
7182
|
+
* publishes it. Returns what was taken, or why the home could not give it.
|
|
7183
|
+
*/
|
|
7184
|
+
async takeSkillFolderUnlocked(proposal, name) {
|
|
7185
|
+
const home = this.paths.agent(proposal.proposedBy);
|
|
7186
|
+
const folder = `skills/${name}`;
|
|
7187
|
+
const where = `${folder} in ${proposal.proposedBy}'s home`;
|
|
7188
|
+
const taken = await this.homes.folder(home, folder);
|
|
7189
|
+
if (taken === null || !taken.files.some((entry) => entry.path === "SKILL.md")) {
|
|
7190
|
+
return `no ${folder}/SKILL.md in ${proposal.proposedBy}'s home`;
|
|
7191
|
+
}
|
|
7192
|
+
if (taken.files.length > SKILL_FILES_LIMIT) {
|
|
7193
|
+
return `${where} holds ${taken.files.length} files, more than the ${SKILL_FILES_LIMIT} a society skill may`;
|
|
7194
|
+
}
|
|
7195
|
+
const bytes = taken.files.reduce((sum, entry) => sum + entry.size, 0);
|
|
7196
|
+
if (bytes > SKILL_BYTES_LIMIT) {
|
|
7197
|
+
return `${where} holds ${megabytes(bytes)} MB, more than the ${megabytes(SKILL_BYTES_LIMIT)} MB a society skill may`;
|
|
7198
|
+
}
|
|
7199
|
+
const contents = /* @__PURE__ */ new Map();
|
|
7200
|
+
for (const entry of taken.files) {
|
|
7201
|
+
contents.set(entry.path, await this.homes.blob(home, entry.object));
|
|
7202
|
+
}
|
|
7203
|
+
const manual = parseLooseMarkdown(contents.get("SKILL.md")?.toString("utf8") ?? "");
|
|
7204
|
+
if (manual.error !== null) {
|
|
7205
|
+
return `the frontmatter of ${folder}/SKILL.md in ${proposal.proposedBy}'s home does not parse (${manual.error})`;
|
|
7206
|
+
}
|
|
7207
|
+
const dir = this.paths.proposalFiles(proposal.id);
|
|
7208
|
+
await rm2(dir, { recursive: true, force: true });
|
|
7209
|
+
for (const entry of taken.files) {
|
|
7210
|
+
const target = path6.join(dir, ...entry.path.split("/"));
|
|
7211
|
+
await ensureDir(path6.dirname(target));
|
|
7212
|
+
await writeFile4(target, contents.get(entry.path) ?? Buffer.alloc(0));
|
|
7213
|
+
await chmod2(target, entry.executable ? 493 : 420);
|
|
7214
|
+
}
|
|
7215
|
+
return {
|
|
7216
|
+
commit: taken.commit,
|
|
7217
|
+
takenAt: this.now().toISOString(),
|
|
7218
|
+
files: taken.files.map((entry) => ({
|
|
7219
|
+
path: entry.path,
|
|
7220
|
+
size: entry.size,
|
|
7221
|
+
executable: entry.executable
|
|
7222
|
+
}))
|
|
7223
|
+
};
|
|
7224
|
+
}
|
|
7225
|
+
/** Keeps what was taken of a skill proposal's folder on the proposal, and logs it. */
|
|
7226
|
+
async recordSkillFiles(proposal, taken) {
|
|
7227
|
+
const next = { ...proposal, skillFiles: taken };
|
|
7228
|
+
await this.writeProposal(next);
|
|
7229
|
+
await this.events.append("proposal.files_taken", SYSTEM_ACTOR.name, {
|
|
7230
|
+
proposalId: proposal.id,
|
|
7231
|
+
kind: proposal.kind,
|
|
7232
|
+
proposedBy: proposal.proposedBy,
|
|
7233
|
+
commit: taken.commit,
|
|
7234
|
+
files: taken.files.length
|
|
7235
|
+
});
|
|
7236
|
+
return next;
|
|
7237
|
+
}
|
|
7238
|
+
/**
|
|
7239
|
+
* A skill proposal with its folder, for approval: taken now when the turn that filed it ended
|
|
7240
|
+
* without it, as when the server restarted before the turn's end was recorded, and refused while
|
|
7241
|
+
* that turn is still in flight, since the folder reaches the board only when it ends.
|
|
7242
|
+
*/
|
|
7243
|
+
async skillFilesForApproval(proposal) {
|
|
7244
|
+
const name = skillFolderOf(proposal);
|
|
7245
|
+
if (name === null || proposal.skillFiles !== void 0) {
|
|
7246
|
+
return proposal;
|
|
7247
|
+
}
|
|
7248
|
+
const cannot = `proposal ${proposal.id} cannot be approved`;
|
|
7249
|
+
const filing = this.turnsInFlight().some((turn) => turn.agent === proposal.proposedBy && proposedFrom(proposal.filedFrom, turn));
|
|
7250
|
+
if (filing) {
|
|
7251
|
+
throw new BoardError("INVALID_STATE", `${cannot} until ${proposal.proposedBy}'s turn that proposed it ends, when the board takes the skill's folder from its home`);
|
|
7252
|
+
}
|
|
7253
|
+
const taken = await this.takeSkillFolderUnlocked(proposal, name);
|
|
7254
|
+
if (typeof taken === "string") {
|
|
7255
|
+
throw new BoardError("INVALID_STATE", `${cannot}: ${taken}; reject it with that reason`);
|
|
7256
|
+
}
|
|
7257
|
+
return this.recordSkillFiles(proposal, taken);
|
|
7258
|
+
}
|
|
7259
|
+
/**
|
|
7260
|
+
* Takes the folders of the skills a citizen proposed from a conversation, once the turn there has
|
|
7261
|
+
* ended and its runner has pushed the citizen's home. A proposal whose folder the home does not
|
|
7262
|
+
* give is rejected by the board with the reason, which wakes the proposer to put it right; while
|
|
7263
|
+
* someone is in a turn in the proposal's thread, it stays as it is, and approval meets the reason.
|
|
7264
|
+
*/
|
|
7265
|
+
async takeSkillFiles(turn) {
|
|
7266
|
+
const where = {
|
|
7267
|
+
scope: turn.project,
|
|
7268
|
+
...turn.thread === void 0 ? {} : { thread: turn.thread },
|
|
7269
|
+
...turn.channel === void 0 ? {} : { channel: turn.channel }
|
|
7270
|
+
};
|
|
7271
|
+
await this.mutex.run(async () => {
|
|
7272
|
+
for (const proposal of await this.listProposals()) {
|
|
7273
|
+
const name = skillFolderOf(proposal);
|
|
7274
|
+
if (name === null || proposal.status !== "proposed" || proposal.skillFiles !== void 0 || proposal.proposedBy !== turn.agent || !proposedFrom(proposal.filedFrom, where)) {
|
|
7275
|
+
continue;
|
|
7276
|
+
}
|
|
7277
|
+
const taken = await this.takeSkillFolderUnlocked(proposal, name);
|
|
7278
|
+
if (typeof taken !== "string") {
|
|
7279
|
+
await this.recordSkillFiles(proposal, taken);
|
|
7280
|
+
continue;
|
|
7281
|
+
}
|
|
7282
|
+
try {
|
|
7283
|
+
await this.decideUnlocked(SYSTEM_ACTOR, proposal.id, "rejected", `${taken} when the turn that proposed it ended; propose it again once the folder is there`);
|
|
7284
|
+
} catch (error) {
|
|
7285
|
+
if (!(error instanceof BoardError)) {
|
|
7286
|
+
throw error;
|
|
7287
|
+
}
|
|
7288
|
+
}
|
|
7289
|
+
}
|
|
7290
|
+
});
|
|
7291
|
+
}
|
|
7292
|
+
/** A skill proposal's files with their text, as the board took them; none before then. */
|
|
7293
|
+
async readSkillFiles(proposalId) {
|
|
7294
|
+
const proposal = await this.readProposal(proposalId);
|
|
7295
|
+
const dir = this.paths.proposalFiles(proposal.id);
|
|
7296
|
+
const files = [];
|
|
7297
|
+
for (const entry of proposal.skillFiles?.files ?? []) {
|
|
7298
|
+
if (!isSafeRelativePath(entry.path)) {
|
|
7299
|
+
continue;
|
|
7300
|
+
}
|
|
7301
|
+
const bytes = await readFile5(path6.join(dir, ...entry.path.split("/"))).catch(() => null);
|
|
7302
|
+
const text = bytes === null || bytes.includes(0) ? null : bytes.toString("utf8");
|
|
7303
|
+
files.push({
|
|
7304
|
+
...entry,
|
|
7305
|
+
text: text === null ? null : text.slice(0, SKILL_TEXT_LIMIT_CHARS),
|
|
7306
|
+
truncated: text !== null && text.length > SKILL_TEXT_LIMIT_CHARS
|
|
7307
|
+
});
|
|
7308
|
+
}
|
|
7309
|
+
return files;
|
|
7310
|
+
}
|
|
6965
7311
|
async validateAddAgent(input) {
|
|
6966
7312
|
if (await exists(this.paths.agentFile(input.name))) {
|
|
6967
7313
|
throw new BoardError("ALREADY_EXISTS", `agent ${input.name} already exists`);
|
|
@@ -7473,67 +7819,70 @@ ${charter.purpose}
|
|
|
7473
7819
|
* retired. Validation runs before any write, so an impossible provision fails the decision.
|
|
7474
7820
|
*/
|
|
7475
7821
|
async decide(actor, proposalId, outcome, reason) {
|
|
7476
|
-
return this.mutex.run(
|
|
7477
|
-
|
|
7478
|
-
|
|
7479
|
-
|
|
7480
|
-
|
|
7481
|
-
|
|
7482
|
-
|
|
7483
|
-
|
|
7484
|
-
|
|
7485
|
-
|
|
7486
|
-
|
|
7487
|
-
|
|
7488
|
-
|
|
7489
|
-
|
|
7490
|
-
|
|
7491
|
-
this.
|
|
7492
|
-
|
|
7493
|
-
|
|
7494
|
-
|
|
7495
|
-
|
|
7496
|
-
|
|
7497
|
-
|
|
7498
|
-
|
|
7499
|
-
|
|
7500
|
-
|
|
7501
|
-
|
|
7502
|
-
|
|
7503
|
-
|
|
7504
|
-
|
|
7505
|
-
|
|
7506
|
-
|
|
7507
|
-
|
|
7508
|
-
|
|
7509
|
-
|
|
7510
|
-
|
|
7511
|
-
|
|
7512
|
-
|
|
7513
|
-
|
|
7514
|
-
|
|
7515
|
-
|
|
7822
|
+
return this.mutex.run(() => this.decideUnlocked(actor, proposalId, outcome, reason));
|
|
7823
|
+
}
|
|
7824
|
+
async decideUnlocked(actor, proposalId, outcome, reason) {
|
|
7825
|
+
const filed = await this.readProposal(proposalId);
|
|
7826
|
+
if (filed.status !== "proposed") {
|
|
7827
|
+
throw new BoardError("INVALID_STATE", `proposal ${proposalId} is already ${filed.status}`);
|
|
7828
|
+
}
|
|
7829
|
+
if (filed.proposedBy === actor.name) {
|
|
7830
|
+
throw new BoardError("FORBIDDEN", "a proposer never decides its own proposal");
|
|
7831
|
+
}
|
|
7832
|
+
const approvers = ROLE_KIND_APPROVERS[filed.kind];
|
|
7833
|
+
if (!approvers.includes(actor.role)) {
|
|
7834
|
+
throw new BoardError("FORBIDDEN", `a ${filed.kind} proposal requires one of: ${approvers.join(", ")}`);
|
|
7835
|
+
}
|
|
7836
|
+
if (outcome === "approved") {
|
|
7837
|
+
await this.validateProvision(filed.kind, filed.charter);
|
|
7838
|
+
}
|
|
7839
|
+
this.assertNoTurnCut(actor, `proposal ${proposalId} cannot be decided`, (turn) => turn.thread === proposalId);
|
|
7840
|
+
if (outcome === "approved" && filed.kind === "archive") {
|
|
7841
|
+
await this.assertProjectQuiet(actor, ArchiveProposalSchema.parse(filed.charter).project);
|
|
7842
|
+
}
|
|
7843
|
+
const proposal = outcome === "approved" ? await this.skillFilesForApproval(filed) : filed;
|
|
7844
|
+
const ts = this.now().toISOString();
|
|
7845
|
+
const decision = DecisionSchema.parse({
|
|
7846
|
+
id: this.newId(),
|
|
7847
|
+
proposalId,
|
|
7848
|
+
decidedBy: actor.name,
|
|
7849
|
+
outcome,
|
|
7850
|
+
...reason === void 0 ? {} : { reason },
|
|
7851
|
+
ts
|
|
7852
|
+
});
|
|
7853
|
+
await writeMarkdown(this.paths.decision(decision.id), decision, reason ?? "");
|
|
7854
|
+
const provision = outcome === "approved" ? await this.executeProvision(actor.name, proposal) : void 0;
|
|
7855
|
+
const status = outcome === "rejected" ? "rejected" : provision === void 0 ? "approved" : "provisioned";
|
|
7856
|
+
await this.writeProposal({
|
|
7857
|
+
...proposal,
|
|
7858
|
+
status,
|
|
7859
|
+
decidedBy: actor.name,
|
|
7860
|
+
decidedAt: ts,
|
|
7861
|
+
...reason === void 0 ? {} : { reason },
|
|
7862
|
+
...provision === void 0 ? {} : { provisionedAt: ts, provision }
|
|
7863
|
+
});
|
|
7864
|
+
await this.events.append("proposal.decided", actor.name, {
|
|
7865
|
+
proposalId,
|
|
7866
|
+
outcome,
|
|
7867
|
+
kind: proposal.kind,
|
|
7868
|
+
proposedBy: proposal.proposedBy,
|
|
7869
|
+
...proposal.filedFrom === void 0 ? {} : { filedFrom: proposal.filedFrom }
|
|
7870
|
+
});
|
|
7871
|
+
if (provision !== void 0) {
|
|
7872
|
+
await this.events.append("proposal.provisioned", actor.name, {
|
|
7516
7873
|
proposalId,
|
|
7517
|
-
outcome,
|
|
7518
7874
|
kind: proposal.kind,
|
|
7519
|
-
|
|
7520
|
-
...proposal.filedFrom === void 0 ? {} : { filedFrom: proposal.filedFrom }
|
|
7875
|
+
...provision
|
|
7521
7876
|
});
|
|
7522
|
-
|
|
7523
|
-
|
|
7524
|
-
|
|
7525
|
-
|
|
7526
|
-
|
|
7527
|
-
|
|
7528
|
-
|
|
7529
|
-
|
|
7530
|
-
|
|
7531
|
-
const result = provision === void 0 ? "" : ` Provisioned: ${Object.entries(provision).map(([key, value]) => `${key} ${plain(value)}`).join(", ")}.`;
|
|
7532
|
-
const thread = await this.proposalThreadUnlocked(actor.name, proposal);
|
|
7533
|
-
await this.appendThreadMessage(actor.name, thread.id, `${verdict}: ${describeCharter(proposal.kind, proposal.charter)}.${why}${result}`, { step: { action: outcome } });
|
|
7534
|
-
await this.endThreadOf(proposalId, actor.name, outcome);
|
|
7535
|
-
return decision;
|
|
7536
|
-
});
|
|
7877
|
+
}
|
|
7878
|
+
const verdict = outcome === "approved" ? "Approved" : "Rejected";
|
|
7879
|
+
const why = reason === void 0 ? "" : ` Reason: ${reason}.`;
|
|
7880
|
+
const result = provision === void 0 ? "" : ` Provisioned: ${Object.entries(provision).map(([key, value]) => `${key} ${plain(value)}`).join(", ")}.`;
|
|
7881
|
+
const thread = await this.proposalThreadUnlocked(actor.name, proposal);
|
|
7882
|
+
const post = `${verdict}: ${describeCharter(proposal.kind, proposal.charter)}.${why}${result}`;
|
|
7883
|
+
await this.appendThreadMessage(actor.name, thread.id, actor.name === SYSTEM_ACTOR.name ? withoutMentions(post) : post, { step: { action: outcome } });
|
|
7884
|
+
await this.endThreadOf(proposalId, actor.name, outcome);
|
|
7885
|
+
return decision;
|
|
7537
7886
|
}
|
|
7538
7887
|
/** The opener, the people of its subject, and anyone who posted in it; mentions reach the rest. */
|
|
7539
7888
|
async participatesInThread(actor, thread, threads) {
|
|
@@ -9284,7 +9633,8 @@ var TURN_CONTRACT = [
|
|
|
9284
9633
|
"- 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.",
|
|
9285
9634
|
"- 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.",
|
|
9286
9635
|
"- 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.",
|
|
9287
|
-
'- 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.',
|
|
9636
|
+
'- 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. Files the steps use, such as scripts/ and references/, go in the same folder, named in SKILL.md by paths relative to it. Propose it with kind "skill" when the whole society would use it.',
|
|
9637
|
+
"- To take a published skill for yourself, install it in a temporary folder and copy it into your home: in a folder from `mktemp -d`, `npx -y skills add <source> --skill <name> -a universal --copy -y` writes `.agents/skills/<name>/` and a lock file there, and you copy that skill folder to skills/<name>/ in your home, named in lowercase letters, digits, and hyphens. Never install one inside a worktree, where it would be committed into the project, or with -g, which writes to the runner's own home, shared by every citizen on it and listed in no skills index.",
|
|
9288
9638
|
"- 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.",
|
|
9289
9639
|
"- 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.",
|
|
9290
9640
|
"- 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.",
|
|
@@ -9307,7 +9657,7 @@ var GOVERNANCE = [
|
|
|
9307
9657
|
' - retirement: {"agent", "reason"}',
|
|
9308
9658
|
' - 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',
|
|
9309
9659
|
' - reallocation: {"description"}',
|
|
9310
|
-
|
|
9660
|
+
' - skill: {"name", "summary"}: the folder skills/<name>/ in your home, SKILL.md and every file beside it, which the board takes as it stands when this turn ends into society/proposals/<id>/, where whoever decides reads it; a proposal whose folder has no SKILL.md then is rejected by the board. Approval publishes it under society/skills/<name>/, where every citizen\'s skills index lists it until the steward or the user removes it with `remove_skill`; ask the steward to remove one that is wrong or superseded. A proposal from before folders carries "body", its SKILL.md text alone',
|
|
9311
9661
|
"- 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.",
|
|
9312
9662
|
"- 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.",
|
|
9313
9663
|
"- 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."
|
|
@@ -9335,7 +9685,7 @@ function renderSkillsIndex(skills) {
|
|
|
9335
9685
|
];
|
|
9336
9686
|
}
|
|
9337
9687
|
return [
|
|
9338
|
-
"Read a skill's file when its summary matches the work at hand; do not load them all.",
|
|
9688
|
+
"Read a skill's file when its summary matches the work at hand; do not load them all. Paths in it are relative to its folder, which holds whatever else it uses.",
|
|
9339
9689
|
"",
|
|
9340
9690
|
...skills.map((skill) => `- ${skill.name} (${skill.scope === "own" ? "yours" : "society"}): ${skill.summary.trim().length === 0 ? "no summary" : skill.summary.trim().replace(/\.+$/, "")}. File: ${skill.path}`)
|
|
9341
9691
|
];
|
|
@@ -9360,7 +9710,7 @@ var REFLECTION_INTRO = "This turn is for your memory; take no new work, and answ
|
|
|
9360
9710
|
var RECONCILE = "- Reconcile first: merge each copy listed under Edits to reconcile in your home into the file beside it, then delete the copy.";
|
|
9361
9711
|
var REFLECTION_STEPS = [
|
|
9362
9712
|
"- Consolidate memory/core.md: keep it to lessons that still hold, about sixty lines at most, and move detail worth keeping to memory/<topic>.md in your home, which the board's search covers for you alone.",
|
|
9363
|
-
'- Extract a skill: a procedure you have followed twice goes to skills/<name>/SKILL.md, frontmatter with `name` and a one-line `description` and then the steps. Propose it with kind "skill" when other citizens would use it.',
|
|
9713
|
+
'- Extract a skill: a procedure you have followed twice goes to skills/<name>/SKILL.md, frontmatter with `name` and a one-line `description` and then the steps, with any script or reference it uses in the same folder. Propose it with kind "skill" when other citizens would use it.',
|
|
9364
9714
|
"- Refresh profile.md: one paragraph on what you do well and what to send your way; the roster the front desk routes with is built from it.",
|
|
9365
9715
|
"- Share: a durable fact about this scope's codebase or process goes through write_knowledge; a norm the whole society should follow goes to the steward as a plain post in general.",
|
|
9366
9716
|
"- Report memoryUpdated: true in the status object when core.md or a skill changed."
|
|
@@ -9575,7 +9925,7 @@ function buildTurnPrompt(input) {
|
|
|
9575
9925
|
} else {
|
|
9576
9926
|
lines.push(`Topics under ${knowledge.dir}; open one with your file tools when it is relevant.`, "");
|
|
9577
9927
|
for (const topic of knowledge.topics) {
|
|
9578
|
-
lines.push(`- ${topic.topic}: updated by ${topic.updatedBy} at ${topic.updatedAt}`);
|
|
9928
|
+
lines.push(`- ${topic.topic}: revision ${topic.revision}, updated by ${topic.updatedBy} at ${topic.updatedAt}`);
|
|
9579
9929
|
}
|
|
9580
9930
|
}
|
|
9581
9931
|
}
|
|
@@ -10105,6 +10455,7 @@ var TurnHost = class {
|
|
|
10105
10455
|
channel: job.channel,
|
|
10106
10456
|
exitReason: finished.exitReason
|
|
10107
10457
|
}, "turn finished");
|
|
10458
|
+
await this.takeSkillFiles(finished);
|
|
10108
10459
|
turn.done(finished);
|
|
10109
10460
|
}
|
|
10110
10461
|
return { dropWorkspace: ended && (closing ? decided : !outcome.leftovers) };
|
|
@@ -10129,9 +10480,21 @@ var TurnHost = class {
|
|
|
10129
10480
|
};
|
|
10130
10481
|
if (await this.recorded(turn, () => this.board.finishTurn(finished, turn.transcript))) {
|
|
10131
10482
|
this.log.warn({ agent: turn.job.agent, project: turn.job.scope, reason }, "turn aborted");
|
|
10483
|
+
await this.takeSkillFiles(finished);
|
|
10132
10484
|
turn.done(finished);
|
|
10133
10485
|
}
|
|
10134
10486
|
}
|
|
10487
|
+
/**
|
|
10488
|
+
* Takes the folders of the skills the turn proposed, which reached the board with the home its
|
|
10489
|
+
* runner pushed before reporting. A failure only leaves them for approval to take.
|
|
10490
|
+
*/
|
|
10491
|
+
async takeSkillFiles(record) {
|
|
10492
|
+
try {
|
|
10493
|
+
await this.board.takeSkillFiles(record);
|
|
10494
|
+
} catch (error) {
|
|
10495
|
+
this.log.warn({ agent: record.agent, project: record.project, error: String(error) }, "could not take the folders of the skills a turn proposed");
|
|
10496
|
+
}
|
|
10497
|
+
}
|
|
10135
10498
|
/**
|
|
10136
10499
|
* Records the end of a turn already taken out of `open`, and says whether it could. The scheduler
|
|
10137
10500
|
* holds the turn's conversation until the turn ends, and nothing would end it once it has left
|
|
@@ -11691,6 +12054,7 @@ function createApp(deps) {
|
|
|
11691
12054
|
api.get("/proposals", async (c) => c.json(await board2.listProposals()));
|
|
11692
12055
|
api.get("/crons", async (c) => c.json(await board2.listCrons()));
|
|
11693
12056
|
api.get("/proposals/:id", async (c) => c.json(await board2.readProposal(c.req.param("id"))));
|
|
12057
|
+
api.get("/proposals/:id/files", async (c) => c.json(await board2.readSkillFiles(UlidSchema.parse(c.req.param("id")))));
|
|
11694
12058
|
api.get("/signals", async (c) => c.json(await board2.listSignals(Number(c.req.query("limit") ?? "100"))));
|
|
11695
12059
|
api.get("/metrics", async (c) => {
|
|
11696
12060
|
const window = MetricsWindowSchema.parse(c.req.query("window") ?? "7d");
|