@stage5/lumine 0.2.36 → 0.2.38
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/lib/admin.js +26 -17
- package/lib/commands.js +39 -12
- package/lib/constants.js +7 -7
- package/lib/workspace.js +35 -21
- package/package.json +1 -1
- package/sdk/LUMINE_ADMIN.md +24 -0
package/lib/admin.js
CHANGED
|
@@ -4,19 +4,17 @@ import { assertAuthScope, resolveAuth } from "./auth.js";
|
|
|
4
4
|
import { requestJson } from "./http.js";
|
|
5
5
|
|
|
6
6
|
const MAX_EDITORIAL_FILE_BYTES = 256 * 1024;
|
|
7
|
-
const
|
|
8
|
-
const
|
|
7
|
+
const MAX_COMPOSED_TEXT_FILE_BYTES = 64 * 1024;
|
|
8
|
+
const MAX_COMPOSED_TEXT_LENGTH = 10_000;
|
|
9
9
|
const MAX_NOTABLE_NOTE_LENGTH = 2_000;
|
|
10
10
|
|
|
11
|
-
// Operator-composed persona
|
|
12
|
-
//
|
|
11
|
+
// Operator-composed persona text (plain UTF-8, not JSON). The agent writes
|
|
12
|
+
// the content in the bot's persona itself; the server never invokes
|
|
13
13
|
// its model and no AI Energy is spent.
|
|
14
|
-
function
|
|
14
|
+
function readComposedTextFile(filePath) {
|
|
15
15
|
const normalizedPath = String(filePath || "").trim();
|
|
16
16
|
if (!normalizedPath) {
|
|
17
|
-
throw cliValidationError(
|
|
18
|
-
"Pass composed comment text with --file <comment.md>.",
|
|
19
|
-
);
|
|
17
|
+
throw cliValidationError("Pass composed text with --file <file.md>.");
|
|
20
18
|
}
|
|
21
19
|
let contents;
|
|
22
20
|
try {
|
|
@@ -24,18 +22,18 @@ function readComposedCommentFile(filePath) {
|
|
|
24
22
|
} catch {
|
|
25
23
|
throw cliValidationError(`Could not read ${normalizedPath}.`);
|
|
26
24
|
}
|
|
27
|
-
if (Buffer.byteLength(contents, "utf8") >
|
|
28
|
-
throw cliValidationError("The composed
|
|
25
|
+
if (Buffer.byteLength(contents, "utf8") > MAX_COMPOSED_TEXT_FILE_BYTES) {
|
|
26
|
+
throw cliValidationError("The composed text file must be under 64KB.");
|
|
29
27
|
}
|
|
30
28
|
const normalized = contents.trim();
|
|
31
29
|
if (!normalized) {
|
|
32
30
|
throw cliValidationError(
|
|
33
|
-
`${normalizedPath} is empty;
|
|
31
|
+
`${normalizedPath} is empty; composed text is required.`,
|
|
34
32
|
);
|
|
35
33
|
}
|
|
36
|
-
if (normalized.length >
|
|
34
|
+
if (normalized.length > MAX_COMPOSED_TEXT_LENGTH) {
|
|
37
35
|
throw cliValidationError(
|
|
38
|
-
`
|
|
36
|
+
`Composed text must be at most ${MAX_COMPOSED_TEXT_LENGTH} characters.`,
|
|
39
37
|
);
|
|
40
38
|
}
|
|
41
39
|
return normalized;
|
|
@@ -724,6 +722,17 @@ export function parseAdminOperation(options) {
|
|
|
724
722
|
);
|
|
725
723
|
}
|
|
726
724
|
|
|
725
|
+
if (namespace === "announcement" && action === "post") {
|
|
726
|
+
return writeOperation(
|
|
727
|
+
"announcement.post",
|
|
728
|
+
"POST",
|
|
729
|
+
"/cli/admin/announcements",
|
|
730
|
+
{
|
|
731
|
+
content: readComposedTextFile(options.adminFile),
|
|
732
|
+
},
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
|
|
727
736
|
if (namespace === "chat" && action === "send") {
|
|
728
737
|
const rawTarget = String(target || "").trim();
|
|
729
738
|
if (!rawTarget) {
|
|
@@ -735,7 +744,7 @@ export function parseAdminOperation(options) {
|
|
|
735
744
|
// the bot's voice; the server never invokes a model for it.
|
|
736
745
|
return writeOperation("chat.send", "POST", "/cli/admin/chat-messages", {
|
|
737
746
|
target: rawTarget,
|
|
738
|
-
content:
|
|
747
|
+
content: readComposedTextFile(options.adminFile),
|
|
739
748
|
});
|
|
740
749
|
}
|
|
741
750
|
|
|
@@ -821,7 +830,7 @@ export function parseAdminOperation(options) {
|
|
|
821
830
|
? parseIdentity(options.adminIdentity)
|
|
822
831
|
: undefined,
|
|
823
832
|
...(options.adminFile
|
|
824
|
-
? { content:
|
|
833
|
+
? { content: readComposedTextFile(options.adminFile) }
|
|
825
834
|
: {}),
|
|
826
835
|
...(reviewedBuildVersionId ? { reviewedBuildVersionId } : {}),
|
|
827
836
|
...(buildReviewMethod ? { buildReviewMethod } : {}),
|
|
@@ -849,7 +858,7 @@ export function parseAdminOperation(options) {
|
|
|
849
858
|
"comment.edit",
|
|
850
859
|
"PUT",
|
|
851
860
|
`/cli/admin/comments/${commentId}`,
|
|
852
|
-
{ content:
|
|
861
|
+
{ content: readComposedTextFile(options.adminFile) },
|
|
853
862
|
);
|
|
854
863
|
}
|
|
855
864
|
if (action === "post") {
|
|
@@ -868,7 +877,7 @@ export function parseAdminOperation(options) {
|
|
|
868
877
|
}
|
|
869
878
|
|
|
870
879
|
throw cliValidationError(
|
|
871
|
-
"Usage: lumine admin identity|daily-run|recommendations|builds|post|subjects|subject|featured|comment|chat|news|audit|brief|bot-output|notable ...",
|
|
880
|
+
"Usage: lumine admin identity|daily-run|recommendations|builds|post|subjects|subject|featured|comment|announcement|chat|news|audit|brief|bot-output|notable ...",
|
|
872
881
|
);
|
|
873
882
|
}
|
|
874
883
|
|
package/lib/commands.js
CHANGED
|
@@ -908,7 +908,7 @@ export async function save(options) {
|
|
|
908
908
|
buildId = Number(build?.id || buildId);
|
|
909
909
|
const dir = resolveProjectDirForSave({ options, localProject });
|
|
910
910
|
const files = await collectProjectFiles(dir);
|
|
911
|
-
assertProjectFilesWithinLimits(files);
|
|
911
|
+
assertProjectFilesWithinLimits(files, build?.projectLimits);
|
|
912
912
|
// Only claim a base snapshot when this workspace was pulled from the same
|
|
913
913
|
// build we are saving to. Missing filesHash used to mean "unguarded save",
|
|
914
914
|
// which let stale checkouts silently rewind newer server versions — refuse
|
|
@@ -975,12 +975,25 @@ export async function save(options) {
|
|
|
975
975
|
}
|
|
976
976
|
throw error;
|
|
977
977
|
}
|
|
978
|
-
build = result.build
|
|
979
|
-
|
|
980
|
-
|
|
978
|
+
build = result.build ? { ...build, ...result.build } : build;
|
|
979
|
+
if (result.copilotPolicy?.limits) {
|
|
980
|
+
build = {
|
|
981
|
+
...build,
|
|
982
|
+
projectLimits: {
|
|
983
|
+
maxProjectBytes: result.copilotPolicy.limits.maxProjectBytes,
|
|
984
|
+
maxFilesPerProject: result.copilotPolicy.limits.maxFilesPerProject,
|
|
985
|
+
maxFileLines: result.copilotPolicy.limits.maxFileLines,
|
|
986
|
+
},
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
if (!build) {
|
|
990
|
+
build = (await loadBuildMetadata({ options, auth, buildId }).catch(
|
|
991
|
+
() => null,
|
|
992
|
+
)) || {
|
|
981
993
|
id: buildId,
|
|
982
994
|
title: `Build ${buildId}`,
|
|
983
995
|
};
|
|
996
|
+
}
|
|
984
997
|
await saveSelectedBuild({ options, auth, build });
|
|
985
998
|
await writeProjectMetadata({
|
|
986
999
|
dir,
|
|
@@ -1010,9 +1023,10 @@ export async function save(options) {
|
|
|
1010
1023
|
}
|
|
1011
1024
|
|
|
1012
1025
|
export async function check(options) {
|
|
1013
|
-
await reportLocalProjectFindings(options);
|
|
1014
1026
|
const auth = await resolveAuth(options);
|
|
1015
1027
|
const buildId = await resolveRequiredBuildIdOrSelected(options, auth);
|
|
1028
|
+
const canonicalBuild = await loadBuildMetadata({ options, auth, buildId });
|
|
1029
|
+
await reportLocalProjectFindings(options, canonicalBuild?.projectLimits);
|
|
1016
1030
|
const result = await requestJson({
|
|
1017
1031
|
url: `${options.apiUrl}/cli/build/${buildId}/launch-check`,
|
|
1018
1032
|
authToken: auth.token,
|
|
@@ -1022,9 +1036,13 @@ export async function check(options) {
|
|
|
1022
1036
|
if (!result.ok) process.exitCode = 1;
|
|
1023
1037
|
}
|
|
1024
1038
|
|
|
1025
|
-
// Local
|
|
1026
|
-
//
|
|
1027
|
-
|
|
1039
|
+
// Local half of `lumine check`: validate the workspace against the canonical
|
|
1040
|
+
// project limits fetched by check(), so a newly approved allowance works
|
|
1041
|
+
// immediately without requiring another pull just to refresh local metadata.
|
|
1042
|
+
export async function reportLocalProjectFindings(
|
|
1043
|
+
options,
|
|
1044
|
+
canonicalProjectLimits,
|
|
1045
|
+
) {
|
|
1028
1046
|
const localProject = await findLocalProjectMetadata(
|
|
1029
1047
|
path.resolve(options.dir || process.cwd()),
|
|
1030
1048
|
);
|
|
@@ -1037,7 +1055,10 @@ export async function reportLocalProjectFindings(options) {
|
|
|
1037
1055
|
process.exitCode = 1;
|
|
1038
1056
|
return;
|
|
1039
1057
|
}
|
|
1040
|
-
const { errors, warnings } = collectProjectLimitFindings(
|
|
1058
|
+
const { errors, warnings } = collectProjectLimitFindings(
|
|
1059
|
+
files,
|
|
1060
|
+
canonicalProjectLimits || localProject?.metadata?.build?.projectLimits,
|
|
1061
|
+
);
|
|
1041
1062
|
for (const warning of warnings) {
|
|
1042
1063
|
console.error(`Local check warning: ${warning}`);
|
|
1043
1064
|
}
|
|
@@ -1171,10 +1192,14 @@ export async function resolveBuildForSave({
|
|
|
1171
1192
|
const localBuild = localProject?.metadata?.build;
|
|
1172
1193
|
const localBuildId =
|
|
1173
1194
|
Number(localBuild?.id || 0) || Number(localProject?.metadata?.buildId || 0);
|
|
1195
|
+
// Save is already an online operation. Refresh the server-owned Build
|
|
1196
|
+
// metadata before enforcing permissions and project limits so a newly
|
|
1197
|
+
// approved Main allowance (inherited by branches) works without re-pulling.
|
|
1198
|
+
const canonicalBuild = await loadBuildMetadata({ options, auth, buildId });
|
|
1174
1199
|
const build =
|
|
1175
1200
|
localBuild && localBuildId === Number(buildId)
|
|
1176
|
-
? { ...localBuild, id: buildId }
|
|
1177
|
-
:
|
|
1201
|
+
? { ...localBuild, ...canonicalBuild, id: buildId }
|
|
1202
|
+
: canonicalBuild;
|
|
1178
1203
|
return await resolveEditableWorkspaceBuild({
|
|
1179
1204
|
options,
|
|
1180
1205
|
auth,
|
|
@@ -1281,6 +1306,7 @@ export function normalizeContributionBranchBuild({ branch, sourceBuild }) {
|
|
|
1281
1306
|
Number(branch?.contributionContributorId || branch?.userId || 0) || null,
|
|
1282
1307
|
contributionBranchNumber:
|
|
1283
1308
|
Number(branch?.contributionBranchNumber || 0) || null,
|
|
1309
|
+
projectLimits: branch?.projectLimits || sourceBuild?.projectLimits || null,
|
|
1284
1310
|
};
|
|
1285
1311
|
}
|
|
1286
1312
|
|
|
@@ -2541,6 +2567,7 @@ export function printHelp() {
|
|
|
2541
2567
|
lumine admin comment edit <comment-id> --file <comment.md> [--json]
|
|
2542
2568
|
lumine admin brief [--days <1..30>] [--json]
|
|
2543
2569
|
lumine admin bot-output [--days <1..30>] [--json]
|
|
2570
|
+
lumine admin announcement post --file <announcement.md> [--json]
|
|
2544
2571
|
lumine admin chat send <user-id|username> --file <message.md> [--json]
|
|
2545
2572
|
lumine admin notable add <user-id|username> --note <text> [--json]
|
|
2546
2573
|
lumine admin audit [list] [--run current|last|<run-id>] [--target <target>] [--actions <a,b>] [--full] [--cursor <cursor>] [--json]
|
|
@@ -2626,7 +2653,7 @@ Options:
|
|
|
2626
2653
|
--reward-twinkles 3 Pair a recommendation with exactly 3 Twinkles
|
|
2627
2654
|
--twinkles 3 Give exactly 3 Twinkles through the normal economy
|
|
2628
2655
|
--draft-id <id> Canonical delegated comment draft ID
|
|
2629
|
-
--file <path> Editorial JSON or composed
|
|
2656
|
+
--file <path> Editorial JSON or composed text file
|
|
2630
2657
|
--reviewed-version <id> Published Build artifact version actually reviewed
|
|
2631
2658
|
--reviewed-via <method> Build review method: runtime or code
|
|
2632
2659
|
--reason <text> Reason when marking an admin run failed
|
package/lib/constants.js
CHANGED
|
@@ -69,14 +69,12 @@ export const ASSET_MIME_BY_EXTENSION = {
|
|
|
69
69
|
".bin": "application/octet-stream",
|
|
70
70
|
".drc": "application/octet-stream",
|
|
71
71
|
};
|
|
72
|
-
//
|
|
73
|
-
// limits
|
|
74
|
-
// env-overridable server-side, so the CLI only warns on it.
|
|
72
|
+
// Default platform project-file limits. A pulled workspace can carry larger
|
|
73
|
+
// canonical per-project limits approved on Main in lumine-project.json.
|
|
75
74
|
export const PROJECT_MAX_FILES = 100;
|
|
76
75
|
export const PROJECT_MAX_EFFECTIVE_FILE_LINES = 500;
|
|
77
76
|
export const PROJECT_EFFECTIVE_LINE_MAX_COLUMNS = 160;
|
|
78
77
|
export const PROJECT_MAX_TOTAL_BYTES_DEFAULT = 1024 * 1024;
|
|
79
|
-
export const PROJECT_WARN_TOTAL_BYTES = 500 * 1024;
|
|
80
78
|
// Matches asset references in project files: absolute CloudFront URLs and
|
|
81
79
|
// relative /attachments/build-runtime|optimized/... paths.
|
|
82
80
|
export const RUNTIME_ASSET_REFERENCE_PATTERN =
|
|
@@ -233,10 +231,12 @@ lumine save --summary "Describe the change"
|
|
|
233
231
|
|
|
234
232
|
## Project File Limits
|
|
235
233
|
|
|
236
|
-
-
|
|
234
|
+
- Default max ${PROJECT_MAX_FILES} files and ${Math.floor(PROJECT_MAX_TOTAL_BYTES_DEFAULT / 1024)} KB per project. Read
|
|
235
|
+
\`.twinkle/lumine-project.json\` for this project's canonical \`projectLimits\`;
|
|
236
|
+
an approved Main project can allow up to 500 files and 5 MB, inherited by branches.
|
|
237
|
+
- Each file max ${PROJECT_MAX_EFFECTIVE_FILE_LINES} effective lines
|
|
237
238
|
(a physical line counts once per ${PROJECT_EFFECTIVE_LINE_MAX_COLUMNS} characters, so minified one-liners
|
|
238
|
-
blow the limit).
|
|
239
|
-
with a soft warning from ${Math.floor(PROJECT_WARN_TOTAL_BYTES / 1024)} KB.
|
|
239
|
+
blow the limit).
|
|
240
240
|
- Plan file splits BEFORE writing code; \`lumine check\` validates the local
|
|
241
241
|
workspace against these limits without saving, and \`lumine save\` fails fast
|
|
242
242
|
locally listing every violation.
|
package/lib/workspace.js
CHANGED
|
@@ -15,7 +15,6 @@ import {
|
|
|
15
15
|
PROJECT_MAX_EFFECTIVE_FILE_LINES,
|
|
16
16
|
PROJECT_MAX_FILES,
|
|
17
17
|
PROJECT_MAX_TOTAL_BYTES_DEFAULT,
|
|
18
|
-
PROJECT_WARN_TOTAL_BYTES,
|
|
19
18
|
PROJECT_METADATA_DIR,
|
|
20
19
|
PROJECT_METADATA_FILE,
|
|
21
20
|
SDK_REFERENCE_FALLBACK,
|
|
@@ -342,16 +341,30 @@ export function countEffectiveLines(value) {
|
|
|
342
341
|
);
|
|
343
342
|
}
|
|
344
343
|
|
|
345
|
-
// Local mirror of the
|
|
346
|
-
// the full list (the server stops at the first).
|
|
347
|
-
|
|
348
|
-
// is env-overridable server-side -> warning only, the server stays the gate.
|
|
349
|
-
export function collectProjectLimitFindings(files) {
|
|
344
|
+
// Local mirror of the canonical per-project limits returned by the server so
|
|
345
|
+
// violations fail fast with the full list (the server stops at the first).
|
|
346
|
+
export function collectProjectLimitFindings(files, projectLimits = {}) {
|
|
350
347
|
const errors = [];
|
|
351
348
|
const warnings = [];
|
|
352
|
-
|
|
349
|
+
const canonicalMaxFiles = Math.floor(
|
|
350
|
+
Number(projectLimits?.maxFilesPerProject || 0),
|
|
351
|
+
);
|
|
352
|
+
const canonicalMaxProjectBytes = Math.floor(
|
|
353
|
+
Number(projectLimits?.maxProjectBytes || 0),
|
|
354
|
+
);
|
|
355
|
+
const maxFilesPerProject =
|
|
356
|
+
canonicalMaxFiles > 0 ? canonicalMaxFiles : PROJECT_MAX_FILES;
|
|
357
|
+
const maxProjectBytes =
|
|
358
|
+
canonicalMaxProjectBytes > 0
|
|
359
|
+
? canonicalMaxProjectBytes
|
|
360
|
+
: PROJECT_MAX_TOTAL_BYTES_DEFAULT;
|
|
361
|
+
const warnProjectBytes = Math.min(
|
|
362
|
+
Math.floor(maxProjectBytes * 0.8),
|
|
363
|
+
maxProjectBytes - 1,
|
|
364
|
+
);
|
|
365
|
+
if (files.length > maxFilesPerProject) {
|
|
353
366
|
errors.push(
|
|
354
|
-
`Project exceeds the ${
|
|
367
|
+
`Project exceeds the ${maxFilesPerProject}-file limit (${files.length} files). Split or consolidate files before saving.`,
|
|
355
368
|
);
|
|
356
369
|
}
|
|
357
370
|
for (const file of files) {
|
|
@@ -369,26 +382,23 @@ export function collectProjectLimitFindings(files) {
|
|
|
369
382
|
total + Buffer.byteLength(String(file.content || ""), "utf8"),
|
|
370
383
|
0,
|
|
371
384
|
);
|
|
372
|
-
if (totalBytes >
|
|
373
|
-
|
|
374
|
-
`Project files total ${formatBytes(totalBytes)}, above
|
|
375
|
-
PROJECT_MAX_TOTAL_BYTES_DEFAULT / 1024,
|
|
376
|
-
)} KB project limit — the server may reject this save.`,
|
|
385
|
+
if (totalBytes > maxProjectBytes) {
|
|
386
|
+
errors.push(
|
|
387
|
+
`Project files total ${formatBytes(totalBytes)}, above this project's ${formatBytes(maxProjectBytes)} limit.`,
|
|
377
388
|
);
|
|
378
|
-
} else if (totalBytes >
|
|
389
|
+
} else if (totalBytes > warnProjectBytes) {
|
|
379
390
|
warnings.push(
|
|
380
|
-
`Project files total ${formatBytes(totalBytes)} — approaching
|
|
381
|
-
PROJECT_MAX_TOTAL_BYTES_DEFAULT / 1024,
|
|
382
|
-
)} KB project cap (soft warning from ${Math.floor(
|
|
383
|
-
PROJECT_WARN_TOTAL_BYTES / 1024,
|
|
384
|
-
)} KB). Keep files lean where structure allows.`,
|
|
391
|
+
`Project files total ${formatBytes(totalBytes)} — approaching this project's ${formatBytes(maxProjectBytes)} cap. Keep files lean where structure allows.`,
|
|
385
392
|
);
|
|
386
393
|
}
|
|
387
394
|
return { errors, warnings };
|
|
388
395
|
}
|
|
389
396
|
|
|
390
|
-
export function assertProjectFilesWithinLimits(files) {
|
|
391
|
-
const { errors, warnings } = collectProjectLimitFindings(
|
|
397
|
+
export function assertProjectFilesWithinLimits(files, projectLimits = {}) {
|
|
398
|
+
const { errors, warnings } = collectProjectLimitFindings(
|
|
399
|
+
files,
|
|
400
|
+
projectLimits,
|
|
401
|
+
);
|
|
392
402
|
for (const warning of warnings) {
|
|
393
403
|
console.error(`lumine: warning — ${warning}`);
|
|
394
404
|
}
|
|
@@ -587,6 +597,10 @@ export async function writeProjectMetadata({
|
|
|
587
597
|
build.canPublish !== undefined
|
|
588
598
|
? Boolean(build.canPublish)
|
|
589
599
|
: build.role === "owner",
|
|
600
|
+
projectLimits:
|
|
601
|
+
build.projectLimits && typeof build.projectLimits === "object"
|
|
602
|
+
? build.projectLimits
|
|
603
|
+
: null,
|
|
590
604
|
}
|
|
591
605
|
: null,
|
|
592
606
|
apiUrl: options.apiUrl,
|
package/package.json
CHANGED
package/sdk/LUMINE_ADMIN.md
CHANGED
|
@@ -1621,6 +1621,30 @@ Ordinary warm follow-ups (checking on a member the bots already know after
|
|
|
1621
1621
|
something the run surfaced) are within a run's judgment, sparingly, and are
|
|
1622
1622
|
always reported in the run report.
|
|
1623
1623
|
|
|
1624
|
+
## Official announcements
|
|
1625
|
+
|
|
1626
|
+
```bash
|
|
1627
|
+
lumine admin announcement post --file announcement.md --json
|
|
1628
|
+
```
|
|
1629
|
+
|
|
1630
|
+
The run's selected bot posts one composed message to General's announcement
|
|
1631
|
+
subchannel (`channelId` 2, `subchannelId` 2). This is the public official
|
|
1632
|
+
board, not a DM and not a Home comment. Mechanics:
|
|
1633
|
+
|
|
1634
|
+
- requires the `chat:post` scope of a comment-mode `post` run;
|
|
1635
|
+
- composed-only (`--file`, same 10,000-character limit as `chat send`);
|
|
1636
|
+
- authors as Zero or Ciel only — the ordinary chat post route and the
|
|
1637
|
+
announcement socket relay now treat those two IDs as allowed announcement
|
|
1638
|
+
authors, same as management-level 3 humans;
|
|
1639
|
+
- persists through the ordinary `msg_chats` insert (channel lock +
|
|
1640
|
+
visibility restore), writes the Twinkle Newspaper `announcement:<messageId>`
|
|
1641
|
+
event, and relays `new_chat_message` on General. No bot socket, session, or
|
|
1642
|
+
presence;
|
|
1643
|
+
- audited as `announcement.post` and idempotent per request key.
|
|
1644
|
+
|
|
1645
|
+
Use this only when Mikey asks for an official announcement. Do not treat a
|
|
1646
|
+
management run as a standing license to post there.
|
|
1647
|
+
|
|
1624
1648
|
## Audit history
|
|
1625
1649
|
|
|
1626
1650
|
```bash
|