@mnemom/mnemom 0.17.30 → 0.17.31-next.1
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/commands/team.js +27 -7
- package/dist/lib/api.d.ts +21 -1
- package/dist/lib/slack-fs.d.ts +15 -0
- package/dist/lib/slack-fs.js +36 -0
- package/dist/lib/slack-launch.js +3 -2
- package/dist/lib/slack-router.js +3 -2
- package/dist/lib/slack-state.js +10 -13
- package/dist/turn-origin.mjs +8 -1
- package/package.json +1 -1
- package/plugins/mnemom/skills/about-mnemom/SKILL.md +7 -5
package/dist/commands/team.js
CHANGED
|
@@ -105,6 +105,31 @@ export async function teamShowCommand(teamId, opts) {
|
|
|
105
105
|
}
|
|
106
106
|
console.log();
|
|
107
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* What a template write did to the members' SERVED cards (mnemom-platform#2779).
|
|
110
|
+
*
|
|
111
|
+
* Before #2779 this printed "Agents recompose: N" for a count of rows merely
|
|
112
|
+
* FLAGGED for the 15-minute recompose sweep, while `card show` kept serving
|
|
113
|
+
* the stale composed card. The API now recomposes active members in-request
|
|
114
|
+
* and reports `agents_recomposed` / `agents_recompose_pending`; an older API
|
|
115
|
+
* that only flags is reported as queued, never as recomposed.
|
|
116
|
+
*/
|
|
117
|
+
function printRecomposeLines(result) {
|
|
118
|
+
if (typeof result.agents_recomposed === "number") {
|
|
119
|
+
const pending = result.agents_recompose_pending ?? 0;
|
|
120
|
+
console.log(` Agents recomposed: ${result.agents_recomposed} ` +
|
|
121
|
+
`(active members of this team; their served card is updated now)`);
|
|
122
|
+
if (pending > 0) {
|
|
123
|
+
console.log(` Agents pending: ${pending} ` +
|
|
124
|
+
`(queued for the recompose sweep, ~15 min; confirm with \`mnemom card show --agent <id>\`)`);
|
|
125
|
+
}
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (typeof result.agents_flagged_for_recompose === "number") {
|
|
129
|
+
console.log(` Agents queued: ${result.agents_flagged_for_recompose} ` +
|
|
130
|
+
`(active members of this team only; served cards update on the next recompose sweep, ~15 min)`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
108
133
|
export async function teamTemplateCommand(kind, teamId, opts) {
|
|
109
134
|
await requireAuth();
|
|
110
135
|
if (!teamId) {
|
|
@@ -149,10 +174,7 @@ export async function teamTemplateCommand(kind, teamId, opts) {
|
|
|
149
174
|
console.log(` Team ID: ${result.team_id}`);
|
|
150
175
|
console.log(` Org ID: ${result.org_id}`);
|
|
151
176
|
console.log(` Enabled: ${result.enabled ? "yes" : "no"}`);
|
|
152
|
-
|
|
153
|
-
console.log(` Agents recompose: ${result.agents_flagged_for_recompose} ` +
|
|
154
|
-
`(active members of this team only — not blanket fan-out)`);
|
|
155
|
-
}
|
|
177
|
+
printRecomposeLines(result);
|
|
156
178
|
console.log();
|
|
157
179
|
return;
|
|
158
180
|
}
|
|
@@ -177,9 +199,7 @@ export async function teamTemplateCommand(kind, teamId, opts) {
|
|
|
177
199
|
console.log(` Team ID: ${result.team_id}`);
|
|
178
200
|
console.log(` Org ID: ${result.org_id}`);
|
|
179
201
|
console.log(` Deleted: yes`);
|
|
180
|
-
|
|
181
|
-
console.log(` Agents recompose: ${result.agents_flagged_for_recompose}`);
|
|
182
|
-
}
|
|
202
|
+
printRecomposeLines(result);
|
|
183
203
|
console.log();
|
|
184
204
|
return;
|
|
185
205
|
}
|
package/dist/lib/api.d.ts
CHANGED
|
@@ -470,7 +470,15 @@ export interface TeamTemplateBody {
|
|
|
470
470
|
name?: string;
|
|
471
471
|
template: Record<string, unknown> | null;
|
|
472
472
|
enabled: boolean;
|
|
473
|
+
/** Active members whose canonical rows were flagged for the recompose sweep. */
|
|
473
474
|
agents_flagged_for_recompose?: number;
|
|
475
|
+
/**
|
|
476
|
+
* Active members recomposed in-request (mnemom-platform#2779): their served
|
|
477
|
+
* card already reflects this write. Absent on an API that only flags.
|
|
478
|
+
*/
|
|
479
|
+
agents_recomposed?: number;
|
|
480
|
+
/** Active members still waiting on the recompose sweep (cap or compose failure). */
|
|
481
|
+
agents_recompose_pending?: number;
|
|
474
482
|
deleted?: boolean;
|
|
475
483
|
}
|
|
476
484
|
export type TeamTemplateKind = "alignment" | "protection";
|
|
@@ -1326,10 +1334,16 @@ export interface AgentSessionGuardrails {
|
|
|
1326
1334
|
turns: number;
|
|
1327
1335
|
spend_usd: number;
|
|
1328
1336
|
stall_run: number;
|
|
1337
|
+
/**
|
|
1338
|
+
* Which ceilings this session has crossed. Under `enforce` a crossing refuses the request
|
|
1339
|
+
* (429) without counting it as a turn — the flag is the record of that refusal (#2782) and
|
|
1340
|
+
* stays set even after the owner raises the ceiling and the session runs on.
|
|
1341
|
+
*/
|
|
1329
1342
|
fired: {
|
|
1330
1343
|
turns?: boolean;
|
|
1331
1344
|
budget?: boolean;
|
|
1332
1345
|
stall?: boolean;
|
|
1346
|
+
time?: boolean;
|
|
1333
1347
|
};
|
|
1334
1348
|
} | null;
|
|
1335
1349
|
}
|
|
@@ -1340,14 +1354,20 @@ export interface AgentSessionGuardrails {
|
|
|
1340
1354
|
* `false` for a conclusive mechanical allowed/forbidden-paths check, `null` when the contract has
|
|
1341
1355
|
* no fences or the verdict predates the check. This shape is also what MNE-8234 mirrors — keep
|
|
1342
1356
|
* the field name and member shape stable.
|
|
1357
|
+
*
|
|
1358
|
+
* `refused` (#2782) is not a judge verdict: it records an enforce-mode ceiling refusal — the
|
|
1359
|
+
* request was turned away (429) and not counted, so `turn` is the turn it would have been and
|
|
1360
|
+
* the counter never reached it; `ceilings` names what was crossed and `fence_breach` is null.
|
|
1343
1361
|
*/
|
|
1344
1362
|
export interface AgentSessionVerdictTrailEntry {
|
|
1345
1363
|
request_id: string | null;
|
|
1346
1364
|
turn: number;
|
|
1347
|
-
verdict: "aligned" | "drifting" | "violation";
|
|
1365
|
+
verdict: "aligned" | "drifting" | "violation" | "refused";
|
|
1348
1366
|
reason: string;
|
|
1349
1367
|
fence_breach: boolean | null;
|
|
1350
1368
|
at: string;
|
|
1369
|
+
/** Only on a `refused` entry: the ceilings the refusal named. */
|
|
1370
|
+
ceilings?: Array<"turns" | "budget" | "time">;
|
|
1351
1371
|
}
|
|
1352
1372
|
/**
|
|
1353
1373
|
* The seal's lifecycle (MNE-8149 R2, MNE-8199 D9/R9, MNE-8234/mnemom-api#3541). `live`: a sealed
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/slack-fs.ts — the only way the CLI writes a file under ~/.mnemom/slack.
|
|
3
|
+
*
|
|
4
|
+
* A sandboxed agent session is allowed to write a few of these files itself (its own
|
|
5
|
+
* offsets/<session>.off). If it swaps one for a symlink, a plain writeFileSync from the
|
|
6
|
+
* router or channel server — which run outside the sandbox, as the operator — follows
|
|
7
|
+
* the link and truncates whatever it points at. So every write here opens the path with
|
|
8
|
+
* O_NOFOLLOW (a symlink fails with ELOOP instead of being followed), and refuses anything
|
|
9
|
+
* that is not a regular file with a single link (a hard link to another file is the same
|
|
10
|
+
* trick) before a single byte is written or truncated.
|
|
11
|
+
*/
|
|
12
|
+
/** Replace `path`'s contents with `data`; never follows a symlink at `path`. */
|
|
13
|
+
export declare function writeFileNoFollow(path: string, data: string, mode?: number): void;
|
|
14
|
+
/** Append `data` to `path`; never follows a symlink at `path`. */
|
|
15
|
+
export declare function appendFileNoFollow(path: string, data: string, mode?: number): void;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/slack-fs.ts — the only way the CLI writes a file under ~/.mnemom/slack.
|
|
3
|
+
*
|
|
4
|
+
* A sandboxed agent session is allowed to write a few of these files itself (its own
|
|
5
|
+
* offsets/<session>.off). If it swaps one for a symlink, a plain writeFileSync from the
|
|
6
|
+
* router or channel server — which run outside the sandbox, as the operator — follows
|
|
7
|
+
* the link and truncates whatever it points at. So every write here opens the path with
|
|
8
|
+
* O_NOFOLLOW (a symlink fails with ELOOP instead of being followed), and refuses anything
|
|
9
|
+
* that is not a regular file with a single link (a hard link to another file is the same
|
|
10
|
+
* trick) before a single byte is written or truncated.
|
|
11
|
+
*/
|
|
12
|
+
import { closeSync, constants, fstatSync, ftruncateSync, openSync, writeSync } from "node:fs";
|
|
13
|
+
// O_NOFOLLOW is undefined on Windows, where creating symlinks needs elevation anyway.
|
|
14
|
+
const NOFOLLOW = constants.O_NOFOLLOW ?? 0;
|
|
15
|
+
function writeNoFollow(path, data, flags, truncate, mode) {
|
|
16
|
+
const fd = openSync(path, flags | NOFOLLOW, mode);
|
|
17
|
+
try {
|
|
18
|
+
const st = fstatSync(fd);
|
|
19
|
+
if (!st.isFile() || st.nlink !== 1)
|
|
20
|
+
throw new Error(`refusing to write ${path}: not a regular file with a single link`);
|
|
21
|
+
if (truncate)
|
|
22
|
+
ftruncateSync(fd, 0);
|
|
23
|
+
writeSync(fd, data);
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
closeSync(fd);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Replace `path`'s contents with `data`; never follows a symlink at `path`. */
|
|
30
|
+
export function writeFileNoFollow(path, data, mode = 0o600) {
|
|
31
|
+
writeNoFollow(path, data, constants.O_WRONLY | constants.O_CREAT, true, mode);
|
|
32
|
+
}
|
|
33
|
+
/** Append `data` to `path`; never follows a symlink at `path`. */
|
|
34
|
+
export function appendFileNoFollow(path, data, mode = 0o600) {
|
|
35
|
+
writeNoFollow(path, data, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND, false, mode);
|
|
36
|
+
}
|
package/dist/lib/slack-launch.js
CHANGED
|
@@ -8,12 +8,13 @@
|
|
|
8
8
|
* `--dangerously-load-development-channels`, which shows a confirmation dialog;
|
|
9
9
|
* orgs on Team/Enterprise must also enable `channelsEnabled` in managed settings.
|
|
10
10
|
*/
|
|
11
|
-
import { mkdirSync
|
|
11
|
+
import { mkdirSync } from "node:fs";
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import { SESSION_NAME_ENV } from "./agent.js";
|
|
14
14
|
import { ensureRouter } from "./slack-router.js";
|
|
15
15
|
import { slackDir } from "./slack-state.js";
|
|
16
16
|
import { normSession } from "./slack-route.js";
|
|
17
|
+
import { writeFileNoFollow } from "./slack-fs.js";
|
|
17
18
|
export const SLACK_CHANNEL_SERVER = "mnemom-slack";
|
|
18
19
|
/** The MCP config document for `session` (pure; unit-tested). */
|
|
19
20
|
export function slackMcpConfig(session, cli) {
|
|
@@ -31,7 +32,7 @@ export function slackMcpConfig(session, cli) {
|
|
|
31
32
|
export function slackChannelArgs(session, cli = [process.execPath, process.argv[1] ?? "mnemom"]) {
|
|
32
33
|
mkdirSync(slackDir(), { recursive: true, mode: 0o700 });
|
|
33
34
|
const path = join(slackDir(), `mcp-${normSession(session)}.json`);
|
|
34
|
-
|
|
35
|
+
writeFileNoFollow(path, JSON.stringify(slackMcpConfig(session, cli), null, 2));
|
|
35
36
|
ensureRouter(cli);
|
|
36
37
|
return [
|
|
37
38
|
"--mcp-config",
|
package/dist/lib/slack-router.js
CHANGED
|
@@ -12,11 +12,12 @@
|
|
|
12
12
|
* in-memory user-name cache.
|
|
13
13
|
*/
|
|
14
14
|
import { spawn } from "node:child_process";
|
|
15
|
-
import { existsSync, readFileSync,
|
|
15
|
+
import { existsSync, readFileSync, mkdirSync } from "node:fs";
|
|
16
16
|
import { join } from "node:path";
|
|
17
17
|
import { fetchThread, formatThread, messageBody, slackCall, } from "./slack.js";
|
|
18
18
|
import { buildThreadIndex, normSession, routeHits, stripAddressing, } from "./slack-route.js";
|
|
19
19
|
import { appendInbox, inboxSize, listAliases, liveSessions, PENDING_TTL_MS, readAliases, readPosts, readThreadRecords, recordPending, recordRoute, settlePending, slackDir, } from "./slack-state.js";
|
|
20
|
+
import { writeFileNoFollow } from "./slack-fs.js";
|
|
20
21
|
/** How often the router settles held messages (delivered / expired). */
|
|
21
22
|
export const PENDING_SWEEP_MS = 30_000;
|
|
22
23
|
const ttlText = () => `${Math.round(PENDING_TTL_MS / 60_000)} min`;
|
|
@@ -92,7 +93,7 @@ export async function runRouter(opts) {
|
|
|
92
93
|
if (other && other !== process.pid)
|
|
93
94
|
throw new Error(`a router is already running (pid ${other}).`);
|
|
94
95
|
mkdirSync(slackDir(), { recursive: true, mode: 0o700 });
|
|
95
|
-
|
|
96
|
+
writeFileNoFollow(routerPidFile(), String(process.pid));
|
|
96
97
|
log(`aliases ${aliasesOn ? "on" : "off"}, sticky threads ${stickyOn ? "on" : "off"}`);
|
|
97
98
|
if (aliasesOn) {
|
|
98
99
|
const sweep = setInterval(() => {
|
package/dist/lib/slack-state.js
CHANGED
|
@@ -15,9 +15,10 @@
|
|
|
15
15
|
* each session's listener is the only reader of its own inbox. No secret is
|
|
16
16
|
* stored here.
|
|
17
17
|
*/
|
|
18
|
-
import {
|
|
18
|
+
import { closeSync, existsSync, fstatSync, openSync, readSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, } from "node:fs";
|
|
19
19
|
import { join } from "node:path";
|
|
20
20
|
import { MNEMOM_DIR } from "./config.js";
|
|
21
|
+
import { appendFileNoFollow, writeFileNoFollow } from "./slack-fs.js";
|
|
21
22
|
import { normHandle, normSession, reresolveRoutes, } from "./slack-route.js";
|
|
22
23
|
export function slackDir() {
|
|
23
24
|
return join(MNEMOM_DIR, "slack");
|
|
@@ -59,14 +60,14 @@ function readJsonl(path) {
|
|
|
59
60
|
}
|
|
60
61
|
export function recordPost(p) {
|
|
61
62
|
ensure(slackDir());
|
|
62
|
-
|
|
63
|
+
appendFileNoFollow(postsFile(), JSON.stringify(p) + "\n");
|
|
63
64
|
}
|
|
64
65
|
export function readPosts() {
|
|
65
66
|
return readJsonl(postsFile()).slice(-MAX_POSTS);
|
|
66
67
|
}
|
|
67
68
|
export function recordRoute(r) {
|
|
68
69
|
ensure(slackDir());
|
|
69
|
-
|
|
70
|
+
appendFileNoFollow(routesFile(), JSON.stringify(r) + "\n");
|
|
70
71
|
}
|
|
71
72
|
/** Sticky routes still within their TTL at `now`, oldest first (capped like posts). */
|
|
72
73
|
export function readRoutes(now = Date.now()) {
|
|
@@ -112,9 +113,7 @@ export function freshInbox(messages, now = Date.now()) {
|
|
|
112
113
|
const fileFor = (session) => `${normSession(session)}`;
|
|
113
114
|
export function appendInbox(session, msg) {
|
|
114
115
|
ensure(inboxDir());
|
|
115
|
-
|
|
116
|
-
mode: 0o600,
|
|
117
|
-
});
|
|
116
|
+
appendFileNoFollow(join(inboxDir(), `${fileFor(session)}.jsonl`), JSON.stringify(msg) + "\n");
|
|
118
117
|
}
|
|
119
118
|
export function inboxPath(session) {
|
|
120
119
|
return join(inboxDir(), `${fileFor(session)}.jsonl`);
|
|
@@ -142,7 +141,7 @@ export function readOffset(session) {
|
|
|
142
141
|
/** `session` has read its inbox up to byte `offset`. */
|
|
143
142
|
export function writeOffset(session, offset) {
|
|
144
143
|
ensure(offsetDir());
|
|
145
|
-
|
|
144
|
+
writeFileNoFollow(offsetFile(session), String(offset));
|
|
146
145
|
}
|
|
147
146
|
/** True when `session` has saved an offset at all (it has read its inbox before). */
|
|
148
147
|
export function hasOffset(session) {
|
|
@@ -150,7 +149,7 @@ export function hasOffset(session) {
|
|
|
150
149
|
}
|
|
151
150
|
export function recordPending(p) {
|
|
152
151
|
ensure(slackDir());
|
|
153
|
-
|
|
152
|
+
appendFileNoFollow(pendingFile(), JSON.stringify(p) + "\n");
|
|
154
153
|
}
|
|
155
154
|
export function readPending() {
|
|
156
155
|
return readJsonl(pendingFile());
|
|
@@ -198,9 +197,7 @@ export function settlePending(now = Date.now()) {
|
|
|
198
197
|
else
|
|
199
198
|
held.push(p);
|
|
200
199
|
}
|
|
201
|
-
|
|
202
|
-
mode: 0o600,
|
|
203
|
-
});
|
|
200
|
+
writeFileNoFollow(pendingFile(), held.map((p) => JSON.stringify(p) + "\n").join(""));
|
|
204
201
|
return { delivered, expired, held };
|
|
205
202
|
}
|
|
206
203
|
/** Messages appended after byte `offset`, and the new offset. */
|
|
@@ -246,7 +243,7 @@ export function readInboxFrom(session, offset) {
|
|
|
246
243
|
}
|
|
247
244
|
export function registerLive(session, pid = process.pid) {
|
|
248
245
|
ensure(liveDir());
|
|
249
|
-
|
|
246
|
+
writeFileNoFollow(join(liveDir(), `${fileFor(session)}.json`), JSON.stringify({ session, pid, since: new Date().toISOString() }));
|
|
250
247
|
}
|
|
251
248
|
export function unregisterLive(session) {
|
|
252
249
|
rmSync(join(liveDir(), `${fileFor(session)}.json`), { force: true });
|
|
@@ -280,7 +277,7 @@ export function setAlias(handle, session) {
|
|
|
280
277
|
session: s,
|
|
281
278
|
since: prev && prev.session === s ? prev.since : new Date().toISOString(),
|
|
282
279
|
};
|
|
283
|
-
|
|
280
|
+
writeFileNoFollow(path, JSON.stringify(rec));
|
|
284
281
|
return rec;
|
|
285
282
|
}
|
|
286
283
|
/** Remove `handle`; true when something was removed. */
|
package/dist/turn-origin.mjs
CHANGED
|
@@ -70,7 +70,13 @@ export function isPromptEntry(entry) {
|
|
|
70
70
|
* The origin Claude Code recorded for a prompt entry, or null when it recorded none we know
|
|
71
71
|
* (then the entry is not stated at all, and the gateway reads that text as before).
|
|
72
72
|
* Precedence: summary / transcript-only → meta; channel; scheduled; peer; task; isMeta → meta;
|
|
73
|
-
* a human origin or a typed / queued prompt → human.
|
|
73
|
+
* a human origin or a typed / queued prompt → human; an SDK prompt → human.
|
|
74
|
+
*
|
|
75
|
+
* SDK prompts: under the Agent SDK (and `claude -p`) Claude Code records the caller's own
|
|
76
|
+
* prompts as promptSource / turnOrigin "sdk". The caller is the operator driving the session,
|
|
77
|
+
* so its prompt is the user's turn. Every non-user kind (wakeup, task notification, peer,
|
|
78
|
+
* hook or skill text, compaction summary) is matched above it first, so it keeps its own
|
|
79
|
+
* origin in SDK mode too; `source=sdk` still tells the gateway which surface the turn came from.
|
|
74
80
|
*/
|
|
75
81
|
export function entryOrigin(entry) {
|
|
76
82
|
if (!entry) return null;
|
|
@@ -85,6 +91,7 @@ export function entryOrigin(entry) {
|
|
|
85
91
|
if (entry.isMeta === true || turn === "system") return "meta";
|
|
86
92
|
if (turn === "human" || kind === "human") return "human";
|
|
87
93
|
if (entry.promptSource === "typed" || entry.promptSource === "queued") return "human";
|
|
94
|
+
if (entry.promptSource === "sdk" || turn === "sdk") return "human";
|
|
88
95
|
return null;
|
|
89
96
|
}
|
|
90
97
|
|
package/package.json
CHANGED
|
@@ -26,8 +26,9 @@ Mnemom gateway:
|
|
|
26
26
|
|
|
27
27
|
This plugin is the visible part: the band above the prompt, the status pane, the ◆ sign on the
|
|
28
28
|
spinner and the turn line, Mnemom trailers in commit attribution, a governance section in the
|
|
29
|
-
system prompt, and a guard that denies Bash commands which
|
|
30
|
-
`ANTHROPIC_AUTH_TOKEN` or `ANTHROPIC_CUSTOM_HEADERS`
|
|
29
|
+
system prompt, and a guard that denies Bash commands which plainly set or unset
|
|
30
|
+
`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN` or `ANTHROPIC_CUSTOM_HEADERS` (a text match on the
|
|
31
|
+
command, not a sandbox).
|
|
31
32
|
|
|
32
33
|
A session that was NOT launched by `mnemom agent` shows an amber "not governed" band. Nothing is
|
|
33
34
|
recorded for it. Tell the person to launch with `mnemom agent` (install: `npm i -g @mnemom/mnemom`,
|
|
@@ -79,9 +80,10 @@ server at `https://docs.mnemom.ai/mcp` answers questions from the published docs
|
|
|
79
80
|
|
|
80
81
|
- **"Why is there a ◆ on the spinner?"** The session is governed; every model call is being
|
|
81
82
|
checkpointed through the Mnemom gateway. `/mnemom status` shows by whom.
|
|
82
|
-
- **"Can I turn the gateway off for one command?"** Not
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
- **"Can I turn the gateway off for one command?"** Not from this session: the plugin denies
|
|
84
|
+
commands that set or unset those variables, by design, and you must not work around it. Use
|
|
85
|
+
another terminal, or `mnemom agent --no-plugin` for a session without the guard (the routing
|
|
86
|
+
itself still comes from the launcher).
|
|
85
87
|
- **"Is my score good?"** Grades A and up are strong; NR means not enough history yet, not a
|
|
86
88
|
bad score. Point them at the public page for the breakdown.
|
|
87
89
|
- **"Who sees what?"** The public rating and its chain attestation are public. Alignment and
|