symbio 0.3.0 → 0.5.0
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/CHANGELOG.md +35 -0
- package/README.md +14 -4
- package/dist/server/api.js +47 -1
- package/dist/server/docs.js +49 -2
- package/dist/server/guard.js +21 -0
- package/dist/server/images.js +54 -0
- package/dist/server/index.js +3 -0
- package/dist/server/mcp.js +2 -2
- package/dist/server/opencode/client.js +1 -0
- package/dist/server/prompts.js +93 -7
- package/dist/server/rules.js +191 -0
- package/dist/server/store.js +5 -0
- package/dist/server/team.js +240 -42
- package/dist/server/tools.js +126 -7
- package/dist/server/updates.js +34 -0
- package/dist/server/workspaces.js +43 -5
- package/dist/web/assets/{index-BM4oACfZ.css → index-CtcmbMmw.css} +1 -1
- package/dist/web/assets/index-yZ8d_EGH.js +53 -0
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
- package/dist/web/assets/index-CrEYaqhD.js +0 -52
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Rules register: the project's rules, each with where it came from and how it's met. Kept as
|
|
2
|
+
// JSON next to the project docs so the Docs tab can show it as a filterable index. Agents change
|
|
3
|
+
// it only through Symbio's tools (validated, stable ids), never by editing the file.
|
|
4
|
+
//
|
|
5
|
+
// Domain-neutral on purpose: a "rule" is anything the project must respect (a requirement, a
|
|
6
|
+
// decision, a style rule, a constraint), whatever the project is about.
|
|
7
|
+
import { existsSync, promises as fs } from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { docsRoot } from "./docs.js";
|
|
10
|
+
export const CONFIRMATIONS = ["found", "checked-by-agent", "checked-by-human", "contested"];
|
|
11
|
+
export const STATUSES = ["done", "partial", "missing", "not-applicable"];
|
|
12
|
+
const empty = () => ({ areas: [], sources: [], authorities: [], rules: [] });
|
|
13
|
+
export function registerPath(project) {
|
|
14
|
+
return path.join(docsRoot(project), project.docsLocation === "private" ? "rules.json" : path.join("docs", "rules.json"));
|
|
15
|
+
}
|
|
16
|
+
export async function readRegister(project) {
|
|
17
|
+
const file = registerPath(project);
|
|
18
|
+
if (!existsSync(file))
|
|
19
|
+
return empty();
|
|
20
|
+
const data = JSON.parse(await fs.readFile(file, "utf8"));
|
|
21
|
+
return { areas: data.areas ?? [], sources: data.sources ?? [], authorities: data.authorities ?? [], rules: data.rules ?? [] };
|
|
22
|
+
}
|
|
23
|
+
// Several agents may write at once: one write at a time per file.
|
|
24
|
+
const queues = new Map();
|
|
25
|
+
async function change(project, fn) {
|
|
26
|
+
const file = registerPath(project);
|
|
27
|
+
const run = (queues.get(file) ?? Promise.resolve()).then(async () => {
|
|
28
|
+
const reg = await readRegister(project);
|
|
29
|
+
const result = fn(reg);
|
|
30
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
31
|
+
const tmp = `${file}.tmp`;
|
|
32
|
+
await fs.writeFile(tmp, JSON.stringify(reg, null, 2) + "\n");
|
|
33
|
+
await fs.rename(tmp, file);
|
|
34
|
+
return result;
|
|
35
|
+
});
|
|
36
|
+
queues.set(file, run.catch(() => { }));
|
|
37
|
+
return run;
|
|
38
|
+
}
|
|
39
|
+
const CODE = /^[A-Za-z0-9][A-Za-z0-9_.]{0,15}$/;
|
|
40
|
+
function code(value, what) {
|
|
41
|
+
const s = String(value ?? "").trim();
|
|
42
|
+
if (!CODE.test(s))
|
|
43
|
+
throw new Error(`${what} must be a short code (letters, digits, dots; up to 16), e.g. "AP" or "MA".`);
|
|
44
|
+
return s;
|
|
45
|
+
}
|
|
46
|
+
const text = (value) => (value === undefined || value === null ? undefined : String(value).trim() || undefined);
|
|
47
|
+
export async function saveArea(project, input) {
|
|
48
|
+
const id = code(input.id, "The area id").toUpperCase();
|
|
49
|
+
const name = text(input.name);
|
|
50
|
+
if (!name)
|
|
51
|
+
throw new Error("Give the area a name.");
|
|
52
|
+
const stages = Array.isArray(input.stages)
|
|
53
|
+
? input.stages.map((s, i) => {
|
|
54
|
+
const stageName = text(s?.name ?? s);
|
|
55
|
+
if (!stageName)
|
|
56
|
+
throw new Error(`Stage ${i + 1} needs a name.`);
|
|
57
|
+
return { id: text(s?.id) ?? String.fromCharCode(65 + i), name: stageName };
|
|
58
|
+
})
|
|
59
|
+
: undefined;
|
|
60
|
+
return change(project, (reg) => {
|
|
61
|
+
if (reg.sources.some((s) => s.id === id))
|
|
62
|
+
throw new Error(`"${id}" is already a source code; an area prefix must differ from every source code.`);
|
|
63
|
+
const existing = reg.areas.find((a) => a.id === id);
|
|
64
|
+
const area = { id, name, group: text(input.group), description: text(input.description), stages: stages ?? existing?.stages ?? [] };
|
|
65
|
+
if (existing)
|
|
66
|
+
Object.assign(existing, area);
|
|
67
|
+
else
|
|
68
|
+
reg.areas.push(area);
|
|
69
|
+
return `${existing ? "Updated" : "Added"} area ${id} "${name}"${area.stages.length ? ` with stages ${area.stages.map((s) => `${s.id}. ${s.name}`).join("; ")}` : ""}.`;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
export async function saveSource(project, input) {
|
|
73
|
+
const id = code(input.id, "The source id");
|
|
74
|
+
const name = text(input.name);
|
|
75
|
+
if (!name)
|
|
76
|
+
throw new Error("Give the source a name.");
|
|
77
|
+
const authority = input.authority === undefined || input.authority === "" ? undefined : code(input.authority, "The authority");
|
|
78
|
+
return change(project, (reg) => {
|
|
79
|
+
if (reg.areas.some((a) => a.id === id))
|
|
80
|
+
throw new Error(`"${id}" is already an area prefix; a source code must differ from every area prefix.`);
|
|
81
|
+
const source = { id, name, file: text(input.file)?.replace(/\\/g, "/"), authority };
|
|
82
|
+
const existing = reg.sources.find((s) => s.id === id);
|
|
83
|
+
if (existing)
|
|
84
|
+
Object.assign(existing, source);
|
|
85
|
+
else
|
|
86
|
+
reg.sources.push(source);
|
|
87
|
+
const authorityName = text(input.authority_name);
|
|
88
|
+
if (authority && authorityName) {
|
|
89
|
+
const known = reg.authorities.find((a) => a.id === authority);
|
|
90
|
+
if (known)
|
|
91
|
+
known.name = authorityName;
|
|
92
|
+
else
|
|
93
|
+
reg.authorities.push({ id: authority, name: authorityName });
|
|
94
|
+
}
|
|
95
|
+
return `${existing ? "Updated" : "Added"} source ${id} "${name}".`;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/** Creates a rule (id assigned in its area) or updates one; only the given fields change. */
|
|
99
|
+
export async function saveRule(project, input, by) {
|
|
100
|
+
return change(project, (reg) => {
|
|
101
|
+
const id = text(input.id)?.toUpperCase();
|
|
102
|
+
const existing = id ? reg.rules.find((r) => r.id === id) : undefined;
|
|
103
|
+
if (id && !existing)
|
|
104
|
+
throw new Error(`No rule ${id}. Leave id out to add a new rule (Symbio assigns the next id in its area).`);
|
|
105
|
+
const areaId = text(input.area)?.toUpperCase() ?? existing?.area;
|
|
106
|
+
const area = reg.areas.find((a) => a.id === areaId);
|
|
107
|
+
if (!area) {
|
|
108
|
+
throw new Error(areaId
|
|
109
|
+
? `No area ${areaId}. Define it first with save_rule_area.${reg.areas.length ? ` Areas: ${reg.areas.map((a) => a.id).join(", ")}.` : ""}`
|
|
110
|
+
: "Say which area the rule belongs to (area).");
|
|
111
|
+
}
|
|
112
|
+
const stage = input.stage === undefined ? existing?.stage : text(input.stage);
|
|
113
|
+
if (stage && !area.stages.some((s) => s.id === stage)) {
|
|
114
|
+
throw new Error(`Area ${area.id} has no stage "${stage}". Stages: ${area.stages.map((s) => s.id).join(", ") || "none (define them with save_rule_area)"}.`);
|
|
115
|
+
}
|
|
116
|
+
const ruleText = input.text === undefined ? existing?.text : text(input.text);
|
|
117
|
+
if (!ruleText)
|
|
118
|
+
throw new Error("Say what the rule is (text).");
|
|
119
|
+
const confirmation = (input.confirmation ?? existing?.confirmation ?? "found");
|
|
120
|
+
if (!CONFIRMATIONS.includes(confirmation))
|
|
121
|
+
throw new Error(`confirmation must be one of: ${CONFIRMATIONS.join(", ")}.`);
|
|
122
|
+
const status = (input.status ?? existing?.status ?? "missing");
|
|
123
|
+
if (!STATUSES.includes(status))
|
|
124
|
+
throw new Error(`status must be one of: ${STATUSES.join(", ")}.`);
|
|
125
|
+
let citations = existing?.citations ?? [];
|
|
126
|
+
if (input.citations !== undefined) {
|
|
127
|
+
if (!Array.isArray(input.citations))
|
|
128
|
+
throw new Error("citations must be a list of {source, location, excerpt}.");
|
|
129
|
+
citations = input.citations.map((c) => {
|
|
130
|
+
const source = code(c?.source, "A citation's source");
|
|
131
|
+
if (!reg.sources.some((s) => s.id === source))
|
|
132
|
+
throw new Error(`Unknown source "${source}". Define it first with save_rule_source.`);
|
|
133
|
+
return { source, location: text(c?.location), excerpt: text(c?.excerpt) };
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
const imposedBy = input.imposed_by === undefined ? existing?.imposedBy : text(input.imposed_by);
|
|
137
|
+
const supersededBy = input.superseded_by === undefined ? existing?.supersededBy : text(input.superseded_by)?.toUpperCase();
|
|
138
|
+
if (supersededBy && !reg.rules.some((r) => r.id === supersededBy))
|
|
139
|
+
throw new Error(`No rule ${supersededBy} to supersede this one.`);
|
|
140
|
+
const rule = {
|
|
141
|
+
id: existing?.id ?? nextId(reg, area.id),
|
|
142
|
+
area: area.id,
|
|
143
|
+
stage,
|
|
144
|
+
text: ruleText,
|
|
145
|
+
action: input.action === undefined ? existing?.action : text(input.action),
|
|
146
|
+
citations,
|
|
147
|
+
imposedBy,
|
|
148
|
+
confirmation,
|
|
149
|
+
status,
|
|
150
|
+
current: input.current === undefined ? existing?.current : text(input.current),
|
|
151
|
+
check: input.check === undefined ? existing?.check : text(input.check),
|
|
152
|
+
supersededBy,
|
|
153
|
+
updatedAt: Date.now(),
|
|
154
|
+
updatedBy: by,
|
|
155
|
+
};
|
|
156
|
+
if (existing)
|
|
157
|
+
Object.assign(existing, rule);
|
|
158
|
+
else
|
|
159
|
+
reg.rules.push(rule);
|
|
160
|
+
const unsourced = rule.citations.length === 0 ? " It has no source yet: add one when you find it." : "";
|
|
161
|
+
return `${existing ? "Updated" : "Added"} ${rule.id}.${unsourced}`;
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
/** Numbers are never reused: the next one is above every number the area ever had. */
|
|
165
|
+
function nextId(reg, area) {
|
|
166
|
+
const used = reg.rules.filter((r) => r.id.startsWith(`${area}-`)).map((r) => Number(r.id.slice(area.length + 1)) || 0);
|
|
167
|
+
return `${area}-${String(Math.max(0, ...used) + 1).padStart(3, "0")}`;
|
|
168
|
+
}
|
|
169
|
+
/** A compact listing for agents. */
|
|
170
|
+
export async function listRules(project, filter) {
|
|
171
|
+
const reg = await readRegister(project);
|
|
172
|
+
const q = text(filter.query)?.toLowerCase();
|
|
173
|
+
const area = text(filter.area)?.toUpperCase();
|
|
174
|
+
const status = text(filter.status);
|
|
175
|
+
const rules = reg.rules.filter((r) => (!area || r.area === area) &&
|
|
176
|
+
(!status || r.status === status) &&
|
|
177
|
+
(!q || [r.id, r.text, r.current, r.action, ...r.citations.map((c) => `${c.source} ${c.location ?? ""} ${c.excerpt ?? ""}`)].some((s) => s?.toLowerCase().includes(q))));
|
|
178
|
+
const head = [
|
|
179
|
+
`Areas: ${reg.areas.map((a) => `${a.id} (${a.name}${a.stages.length ? `; stages ${a.stages.map((s) => `${s.id}. ${s.name}`).join(", ")}` : ""})`).join(" · ") || "none yet"}`,
|
|
180
|
+
`Sources: ${reg.sources.map((s) => `${s.id} = ${s.name}${s.file ? ` [${s.file}]` : ""}`).join(" · ") || "none yet"}`,
|
|
181
|
+
];
|
|
182
|
+
if (!rules.length)
|
|
183
|
+
return [...head, reg.rules.length ? "No rules match." : "No rules yet."].join("\n");
|
|
184
|
+
const shown = rules.slice(0, 200);
|
|
185
|
+
return [
|
|
186
|
+
...head,
|
|
187
|
+
`${rules.length} rule(s)${rules.length > shown.length ? ` (first ${shown.length}; narrow the filter for the rest)` : ""}:`,
|
|
188
|
+
...shown.map((r) => `- ${r.id}${r.stage ? ` [${r.stage}]` : ""} ${r.status}/${r.confirmation}${r.supersededBy ? ` superseded by ${r.supersededBy}` : ""}: ${r.text.replace(/\s+/g, " ").slice(0, 220)}` +
|
|
189
|
+
(r.citations.length ? ` — ${r.citations.map((c) => `${c.source}${c.location ? ` ${c.location}` : ""}`).join(", ")}` : " — no source")),
|
|
190
|
+
].join("\n");
|
|
191
|
+
}
|
package/dist/server/store.js
CHANGED
|
@@ -84,6 +84,7 @@ export class Store {
|
|
|
84
84
|
allowedModels: input.allowedModels,
|
|
85
85
|
coordinatorModel: input.coordinatorModel,
|
|
86
86
|
docsLocation: input.docsLocation,
|
|
87
|
+
rulesRegister: input.rulesRegister,
|
|
87
88
|
origin: input.origin,
|
|
88
89
|
inspectionSummary: input.inspectionSummary,
|
|
89
90
|
kickedOff: false,
|
|
@@ -93,6 +94,10 @@ export class Store {
|
|
|
93
94
|
createdAt: Date.now(),
|
|
94
95
|
};
|
|
95
96
|
project.directory = directory;
|
|
97
|
+
// A reopened project keeps its history and team, but takes the options just chosen in the
|
|
98
|
+
// dialog that have no history behind them.
|
|
99
|
+
if (onDisk && input.rulesRegister !== undefined)
|
|
100
|
+
project.rulesRegister = input.rulesRegister;
|
|
96
101
|
this.projects.set(project.id, project);
|
|
97
102
|
await this.saveProject(project);
|
|
98
103
|
await this.saveGlobal();
|