@erclx/canon 4.87.0 → 4.88.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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/SKILL.md +1 -1
- package/claude/skills/docs-fold/SKILL.md +4 -4
- package/claude/skills/draft-and-pick/REQUIREMENT.md +10 -1
- package/claude/skills/draft-and-pick/SKILL.md +7 -3
- package/claude/skills/git-followup/SKILL.md +1 -1
- package/claude/skills/git-pr/SKILL.md +8 -8
- package/claude/skills/git-split/SKILL.md +19 -19
- package/claude/skills/memory-capture/SKILL.md +2 -2
- package/claude/skills/memory-review/SKILL.md +2 -2
- package/claude/skills/plan-groundwork/SKILL.md +1 -1
- package/claude/skills/review-address/SKILL.md +11 -11
- package/claude/skills/review-pr/SKILL.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -1
- package/claude/skills/role-orchestrator/scripts/poll.sh +1 -1
- package/claude/skills/teach-workspace/SKILL.md +2 -2
- package/claude/skills/ui-test/REQUIREMENT.md +1 -1
- package/claude/skills/ui-test/SKILL.md +2 -2
- package/docs/agents/commands.md +2 -1
- package/docs/agents/records.md +27 -0
- package/docs/agents/sandbox.md +1 -1
- package/docs/workflow/ai-workflow.md +4 -2
- package/governance/rules/core/055-scratch.md +1 -0
- package/package.json +1 -1
- package/scripts/tooling/verify.sh +2 -2
- package/src/claude/skills-headings.ts +1 -1
- package/src/commands/migrate.ts +1 -1
- package/src/commands/records.ts +159 -0
- package/src/migrate/record-layout.ts +2 -2
- package/src/migrate/scratch-evidence.ts +3 -5
- package/src/records/prune.ts +488 -0
- package/src/records/size.ts +24 -1
- package/tooling/claude/seeds/.claude/hooks/index-reminder.sh +2 -2
- package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +2 -2
- package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +2 -2
- package/tooling/claude/seeds/CLAUDE.md +2 -6
|
@@ -63,7 +63,7 @@ Work in Claude Code directly. It reads `CLAUDE.md` automatically and has full fi
|
|
|
63
63
|
|
|
64
64
|
The routing test is whether the repository can answer an item today. A session grepping handles the yes, and a groundwork track handles the no.
|
|
65
65
|
|
|
66
|
-
A groundwork track may run experiments to settle a question, writing a fixture it reads itself under `.canon/tmp/groundwork-fixtures/<slug>/` and spawning up to three billed headless runs before it asks. A fixture a headless run is pointed at sits outside the repository, since a session started under the project root inherits that project's `CLAUDE.md` and rules and would measure them instead of the arm.
|
|
66
|
+
A groundwork track may run experiments to settle a question, writing a fixture it reads itself under `.canon/tmp/runs/groundwork-fixtures/<slug>/` and spawning up to three billed headless runs before it asks. A fixture a headless run is pointed at sits outside the repository, since a session started under the project root inherits that project's `CLAUDE.md` and rules and would measure them instead of the arm.
|
|
67
67
|
|
|
68
68
|
What a spike produces goes somewhere else again. An input the run reads is re-runnable and cited by nothing, so the scratch path above is the right lifetime for it, while a recording or a render the track cites as evidence for a finding is what a later reader opens to check the claim. Evidence therefore lives in `evidence/` inside the track beside the file citing it, since the scratch tree holds only what can be deleted without loss.
|
|
69
69
|
|
|
@@ -81,7 +81,7 @@ When features are independent, run them in parallel instead of sequentially. Use
|
|
|
81
81
|
|
|
82
82
|
- Create a worktree per feature, then start a Claude Code session in each
|
|
83
83
|
- Invoke `canon:plan-feature` in each session. Plans land at the main worktree root as `.canon/plans/feature-<slug>.md`, one per feature, no collisions. Small features stay in chat and skip the file.
|
|
84
|
-
- Implement, verify, and review each feature independently. `review-branch` writes a per-branch report at the main worktree root (`review/branch-<slug>.md`), and `ui-test` writes a per-branch checklist handoff there too (`tmp/ui-checklist/<slug>.md`) that `git-pr` posts to the pull request and removes, so parallel sessions do not overwrite each other. The slug is the branch name with any leading type segment dropped, so `feat/jwt-expiration` and the plan at `feature-jwt-expiration.md` meet on one name
|
|
84
|
+
- Implement, verify, and review each feature independently. `review-branch` writes a per-branch report at the main worktree root (`review/branch-<slug>.md`), and `ui-test` writes a per-branch checklist handoff there too (`tmp/handoff/ui-checklist/<slug>.md`) that `git-pr` posts to the pull request and removes, so parallel sessions do not overwrite each other. The slug is the branch name with any leading type segment dropped, so `feat/jwt-expiration` and the plan at `feature-jwt-expiration.md` meet on one name
|
|
85
85
|
- Ship each worktree separately with `canon:git-ship`
|
|
86
86
|
- For full autonomy per worktree, invoke `canon:auto-ship` instead of the manual chain. Approve the plan, walk away, come back to a pull request the chain marked as a draft and then read the flag back on. The mark says the work has had no review yet, and it holds no window, since readying a pull request to merge lifts it directly, an act reserved to the operator or to the controlling session that closed the review.
|
|
87
87
|
|
|
@@ -111,6 +111,8 @@ A person points it at a private repository once and both verbs refuse until they
|
|
|
111
111
|
|
|
112
112
|
`canon records size` reports what each of these folders holds, heaviest first, along with `.canon/tmp`. Each row carries the file count, the bytes, how many files were written in the last 7 and 30 days, and the dates of the least and most recently written one. Nothing fails on a number, because a record folder has no correct size. What the verb replaces is a reading somebody had to remember to take: the memory pen went from 44 entries to 236 between two counts made by hand a fortnight apart, and nothing reported the rate in between.
|
|
113
113
|
|
|
114
|
+
`canon records prune-tmp` reports scratch under `.canon/tmp` nobody has touched inside an age window, and deletes it only with `--write`. A `tmp/handoff/` folder and the live `tmp/pr/poll/` baseline are never offered, since a reader deletes a handoff once it is read and a poll baseline is state a session still needs. See [records](../agents/records.md) for the candidate shapes and the exit codes.
|
|
115
|
+
|
|
114
116
|
A plan that ships is archived, never deleted. `canon tasks archive` moves it to `.canon/plans/archive/` alongside the task it belonged to and retargets that task's `Plan:` line at the new location, so a completed task still leads to the reasoning behind it. An archive sits inside the record folder it archives rather than beside it, so one ignore entry and one backed-folder entry cover a record and everything it has retired. The folder is gitignored, which is why a deleted plan had no recovery path. A plan cited by more than one task stays put until the last of them closes, since moving it early would strand every other pointer.
|
|
115
117
|
|
|
116
118
|
A branch review report takes the other route and is swept rather than archived. `review-branch` writes it flat into `.canon/review/` as `branch-<slug>.md`, the session addressing it reads it once, and the durable record of what a review found is the comment `review-pr` posts on the pull request, so `docs-fold` deletes any report whose branch is gone. The body that writes a report owns how long it lives, which leaves the shipping branch's own report on disk through the run that cites it and collects it a branch later. What that loses is a local-only review on a branch that never opened a pull request, which is why the report says so where a reader meets it.
|
|
@@ -9,3 +9,4 @@ description: Write temporary files to the scratch folder structure
|
|
|
9
9
|
- Write temporary files to `.canon/tmp/<slug>/<file>.md` in the project root, a nested `<slug>/` folder with a kebab-slug tied to the topic, not a flat `<slug>-<file>.md`. The scratch-guard hook enforces the location.
|
|
10
10
|
- Write to `.claude/.tmp/<slug>/` instead in a project that carries no `.canon/` root, which is one the record move has not reached. The hook accepts either, but the ignore file is the source: `.gitignore` and its manifest counterpart are what keep the fallback spelling out of `git status`, and the hook and this rule both defer to it. `canon migrate records` is what moves a project from this second spelling to the first. <!-- canon-keep-record-root -->
|
|
11
11
|
- "The project root" above means the current worktree by default. `085-worktrees.md` states the same mixed default for `.canon/tmp/` and defers to each skill's own body for which write resolves at the main root instead.
|
|
12
|
+
- `runs/`, `hooks/`, `handoff/`, `pr/`, and `render/` are reserved under `tmp/` for build and command output that can be regenerated, hook state, skill-to-skill handoff, pull request scratch, and render output. A session slug must not take one of these five names.
|
package/package.json
CHANGED
|
@@ -16,7 +16,7 @@ show_help() {
|
|
|
16
16
|
echo -e "${GREY}│${NC} --keep ${GREY}# Keep tmp dir after run for inspection${NC}"
|
|
17
17
|
echo -e "${GREY}│${NC} -h, --help ${GREY}# Show this help message${NC}"
|
|
18
18
|
echo -e "${GREY}│${NC}"
|
|
19
|
-
echo -e "${GREY}│${NC} Scaffolds <stack> into .canon/tmp/verify-<stack>/, syncs the full"
|
|
19
|
+
echo -e "${GREY}│${NC} Scaffolds <stack> into .canon/tmp/runs/verify-<stack>/, syncs the full"
|
|
20
20
|
echo -e "${GREY}│${NC} layer chain, and runs check + test:e2e + screenshot."
|
|
21
21
|
echo -e "${GREY}└${NC}"
|
|
22
22
|
exit 0
|
|
@@ -102,7 +102,7 @@ main() {
|
|
|
102
102
|
open_timeline "canon tooling verify $stack"
|
|
103
103
|
trap close_timeline EXIT
|
|
104
104
|
|
|
105
|
-
local tmp_root="$PROJECT_ROOT/.canon/tmp"
|
|
105
|
+
local tmp_root="$PROJECT_ROOT/.canon/tmp/runs"
|
|
106
106
|
local tmp_dir="$tmp_root/verify-$stack"
|
|
107
107
|
|
|
108
108
|
mkdir -p "$tmp_root"
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* `markdown.md` has a body backtick a named thing, so the natural spelling of
|
|
22
22
|
* the citation is `` See the `Parallel sessions` heading in `CLAUDE.md` `` and
|
|
23
23
|
* a class stopping at the first backtick never reaches the file. That heading
|
|
24
|
-
*
|
|
24
|
+
* was real in this repository's own root file when the check was written. Both classes returned the same
|
|
25
25
|
* twenty lines over `claude/skills/` at the stamp, so the backtick exclusion
|
|
26
26
|
* was carrying nothing and hiding the spelling an author would reach for.
|
|
27
27
|
*
|
package/src/commands/migrate.ts
CHANGED
|
@@ -1164,7 +1164,7 @@ export function register(program: Command): void {
|
|
|
1164
1164
|
' review/{design,board,slides,diagrams}/ -> tmp/render/<kind>/',
|
|
1165
1165
|
' review/references/ -> picks/references/',
|
|
1166
1166
|
' review/branch/review-<slug>.md -> review/branch-<slug>.md',
|
|
1167
|
-
' review/ui-checklist-<slug>.md -> tmp/ui-checklist/<slug>.md',
|
|
1167
|
+
' review/ui-checklist-<slug>.md -> tmp/handoff/ui-checklist/<slug>.md',
|
|
1168
1168
|
' review/evidence/<slug>/ -> picks/<slug>/ or evidence/<nn>-<slug>/',
|
|
1169
1169
|
'',
|
|
1170
1170
|
'An evidence folder is a pick when it directly holds an arm-<id>',
|
package/src/commands/records.ts
CHANGED
|
@@ -10,6 +10,12 @@ import {
|
|
|
10
10
|
isOrdinalKind,
|
|
11
11
|
ORDINAL_KINDS,
|
|
12
12
|
} from '@/records/ordinal'
|
|
13
|
+
import {
|
|
14
|
+
DEFAULT_OLDER_THAN_DAYS,
|
|
15
|
+
type PruneReport,
|
|
16
|
+
type PruneUnit,
|
|
17
|
+
pruneScratch,
|
|
18
|
+
} from '@/records/prune'
|
|
13
19
|
import {
|
|
14
20
|
type FolderSize,
|
|
15
21
|
formatBytes,
|
|
@@ -50,6 +56,9 @@ const EXIT_MIGRATABLE = 2
|
|
|
50
56
|
/** Returned when `--claim` loses every retry to a collision. */
|
|
51
57
|
const EXIT_CONTENDED = 2
|
|
52
58
|
|
|
59
|
+
/** Returned when candidates exist and `--write` was not passed. */
|
|
60
|
+
const EXIT_PRUNABLE = 2
|
|
61
|
+
|
|
53
62
|
interface ValidateCommandOptions {
|
|
54
63
|
readonly json?: boolean
|
|
55
64
|
readonly root?: string
|
|
@@ -61,6 +70,11 @@ interface MigrateCommandOptions extends ValidateCommandOptions {
|
|
|
61
70
|
readonly write?: boolean
|
|
62
71
|
}
|
|
63
72
|
|
|
73
|
+
interface PruneCommandOptions extends ValidateCommandOptions {
|
|
74
|
+
readonly write?: boolean
|
|
75
|
+
readonly olderThan?: string
|
|
76
|
+
}
|
|
77
|
+
|
|
64
78
|
interface OrdinalCommandOptions extends ValidateCommandOptions {
|
|
65
79
|
readonly claim?: boolean
|
|
66
80
|
}
|
|
@@ -233,6 +247,55 @@ export function register(program: Command): void {
|
|
|
233
247
|
process.exitCode = await runSize(opts)
|
|
234
248
|
})
|
|
235
249
|
|
|
250
|
+
records
|
|
251
|
+
.command('prune-tmp')
|
|
252
|
+
.description('Report scratch nobody has touched in a while, and delete it')
|
|
253
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
254
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
255
|
+
.option('--write', 'Delete every candidate the report lists')
|
|
256
|
+
.option(
|
|
257
|
+
'--older-than <days>',
|
|
258
|
+
`Age in days a unit's newest file must clear to be offered (default ${DEFAULT_OLDER_THAN_DAYS})`,
|
|
259
|
+
)
|
|
260
|
+
.option('--root <path>', 'Project root, defaulting to the main worktree')
|
|
261
|
+
.addHelpText(
|
|
262
|
+
'after',
|
|
263
|
+
[
|
|
264
|
+
'',
|
|
265
|
+
'A candidate is a unit whose newest file is older than --older-than:',
|
|
266
|
+
' a tmp/<slug>/ folder, a folder one level inside runs/, render/, or',
|
|
267
|
+
' pr/, or a single marker file inside hooks/<hook>/. A unit with no',
|
|
268
|
+
' files is offered whatever its age.',
|
|
269
|
+
'',
|
|
270
|
+
'tmp/handoff/ and tmp/pr/poll/ are never offered, since a reader deletes',
|
|
271
|
+
'a handoff itself and a poll baseline is live state. The pre-split names',
|
|
272
|
+
'the same two folders replaced (memory-routing/, teach-promotion/,',
|
|
273
|
+
'ui-checklist/, pr-poll/) are skipped the same way rather than offered',
|
|
274
|
+
'as ordinary slugs.',
|
|
275
|
+
'',
|
|
276
|
+
'Exit codes:',
|
|
277
|
+
' 0 nothing to prune, or --write deleted every candidate',
|
|
278
|
+
' 1 refused, with the reason on stderr or in the JSON record, or a',
|
|
279
|
+
' delete failed',
|
|
280
|
+
' 2 candidates exist and --write was not passed',
|
|
281
|
+
'',
|
|
282
|
+
'It writes nothing until --write is passed, matching canon records',
|
|
283
|
+
'migrate: a session record has no history to undo a wrong delete from.',
|
|
284
|
+
'It reads mtime like canon records size, so a machine restored by canon',
|
|
285
|
+
'records pull reads everything as new, which fails safe by offering',
|
|
286
|
+
'nothing.',
|
|
287
|
+
'',
|
|
288
|
+
'Examples:',
|
|
289
|
+
' canon records prune-tmp',
|
|
290
|
+
' canon records prune-tmp --write',
|
|
291
|
+
' canon records prune-tmp --older-than 30 --json',
|
|
292
|
+
'',
|
|
293
|
+
].join('\n'),
|
|
294
|
+
)
|
|
295
|
+
.action(async (opts: PruneCommandOptions) => {
|
|
296
|
+
process.exitCode = await runPruneTmp(opts)
|
|
297
|
+
})
|
|
298
|
+
|
|
236
299
|
records
|
|
237
300
|
.command('push')
|
|
238
301
|
.description(
|
|
@@ -390,6 +453,102 @@ function reportSize(outcome: Extract<SizeOutcome, { ok: true }>): void {
|
|
|
390
453
|
outro()
|
|
391
454
|
}
|
|
392
455
|
|
|
456
|
+
async function runPruneTmp(opts: PruneCommandOptions): Promise<number> {
|
|
457
|
+
const emitJson = opts.json ?? false
|
|
458
|
+
const olderThan = Number(opts.olderThan ?? DEFAULT_OLDER_THAN_DAYS)
|
|
459
|
+
|
|
460
|
+
if (!Number.isFinite(olderThan) || olderThan <= 0) {
|
|
461
|
+
return reportRefusal(
|
|
462
|
+
'canon records prune-tmp',
|
|
463
|
+
{
|
|
464
|
+
reason: 'bad-older-than',
|
|
465
|
+
message: `--older-than must be a positive number of days, got ${opts.olderThan}.`,
|
|
466
|
+
},
|
|
467
|
+
emitJson,
|
|
468
|
+
)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const root = opts.root ?? (await mainWorktreeRoot())
|
|
472
|
+
const write = opts.write ?? false
|
|
473
|
+
const outcome = await pruneScratch(root, olderThan, write)
|
|
474
|
+
|
|
475
|
+
if (!outcome.ok)
|
|
476
|
+
return reportRefusal('canon records prune-tmp', outcome, emitJson)
|
|
477
|
+
|
|
478
|
+
return reportPrune(outcome, emitJson)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function pruneRow(entry: PruneUnit): string[] {
|
|
482
|
+
return [
|
|
483
|
+
entry.path,
|
|
484
|
+
String(entry.files),
|
|
485
|
+
formatBytes(entry.bytes),
|
|
486
|
+
entry.newest ?? '',
|
|
487
|
+
]
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function pruneExitCode(outcome: PruneReport): number {
|
|
491
|
+
if (outcome.failed.length > 0) return 1
|
|
492
|
+
if (outcome.candidates.length > 0 && outcome.deleted.length === 0)
|
|
493
|
+
return EXIT_PRUNABLE
|
|
494
|
+
return 0
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function reportPrune(outcome: PruneReport, emitJson: boolean): number {
|
|
498
|
+
const exit = pruneExitCode(outcome)
|
|
499
|
+
|
|
500
|
+
if (emitJson) {
|
|
501
|
+
process.stdout.write(`${JSON.stringify(outcome)}\n`)
|
|
502
|
+
return exit
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
intro('canon records prune-tmp')
|
|
506
|
+
|
|
507
|
+
if (outcome.candidates.length === 0) {
|
|
508
|
+
logStep('Prunable')
|
|
509
|
+
logInfo(`none, older than ${plural(outcome.olderThan, 'day')}`)
|
|
510
|
+
} else {
|
|
511
|
+
logStep(outcome.deleted.length > 0 ? 'Deleted' : 'Prunable')
|
|
512
|
+
|
|
513
|
+
const headers = ['path', 'files', 'size', 'newest']
|
|
514
|
+
const rows = outcome.candidates.map(pruneRow)
|
|
515
|
+
const widths = headers.map((header, column) =>
|
|
516
|
+
columnWidth(
|
|
517
|
+
header,
|
|
518
|
+
rows.map((row) => row[column]),
|
|
519
|
+
),
|
|
520
|
+
)
|
|
521
|
+
const render = (cells: readonly string[]): string =>
|
|
522
|
+
cells
|
|
523
|
+
.map((cell, column) =>
|
|
524
|
+
column === 0
|
|
525
|
+
? cell.padEnd(widths[column])
|
|
526
|
+
: cell.padStart(widths[column]),
|
|
527
|
+
)
|
|
528
|
+
.join(' ')
|
|
529
|
+
.trimEnd()
|
|
530
|
+
|
|
531
|
+
pipeOutput([render(headers), ...rows.map(render)].join('\n'))
|
|
532
|
+
|
|
533
|
+
if (outcome.deleted.length === 0) logInfo('Re-run with --write to delete.')
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (outcome.skipped.length > 0) {
|
|
537
|
+
logStep('Skipped')
|
|
538
|
+
for (const entry of outcome.skipped)
|
|
539
|
+
logInfo(`${entry.path}: ${entry.reason}`)
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (outcome.failed.length > 0) {
|
|
543
|
+
logStep('Failed')
|
|
544
|
+
for (const entry of outcome.failed)
|
|
545
|
+
logWarn(`${entry.path}: ${entry.message}`)
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
outro()
|
|
549
|
+
return exit
|
|
550
|
+
}
|
|
551
|
+
|
|
393
552
|
async function runPush(opts: BackupCommandOptions): Promise<number> {
|
|
394
553
|
const root = opts.root ?? (await mainWorktreeRoot())
|
|
395
554
|
const outcome = await pushRecords(root)
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* feedback moves to `.canon/feedback/`, option captures to `.canon/picks/`,
|
|
7
7
|
* claim-backing folders to a numbered `.canon/evidence/`, renders rebuilt from
|
|
8
8
|
* committed sources to `.canon/tmp/render/`, a branch report flattens to
|
|
9
|
-
* `review/branch-<slug>.md`, and a flat checklist joins the `tmp/ui-checklist/`
|
|
9
|
+
* `review/branch-<slug>.md`, and a flat checklist joins the `tmp/handoff/ui-checklist/`
|
|
10
10
|
* handoff folder. `canon records push` carries every top-level `.canon/`
|
|
11
11
|
* entry except `EXCLUDED_ENTRIES`, so the new root folders are backed with no
|
|
12
12
|
* list edit, and `tmp/render/` is deliberately not.
|
|
@@ -120,7 +120,7 @@ export const RECORD_LAYOUT_MOVES: readonly RecordLayoutMove[] = [
|
|
|
120
120
|
kind: 'files',
|
|
121
121
|
from: ['review'],
|
|
122
122
|
prefix: 'ui-checklist-',
|
|
123
|
-
to: [SCRATCH, 'ui-checklist'],
|
|
123
|
+
to: [SCRATCH, 'handoff', 'ui-checklist'],
|
|
124
124
|
renamed: '',
|
|
125
125
|
prune: false,
|
|
126
126
|
},
|
|
@@ -48,11 +48,9 @@ import { recordDir, SCRATCH } from '@/record-root'
|
|
|
48
48
|
* `verify-astro` and `verify-vite-react` pass the same test and are excluded
|
|
49
49
|
* by name: both are scaffolds a command generates rather than records a
|
|
50
50
|
* session wrote, and each is 100+ MB, which the review remote is not sized
|
|
51
|
-
* for. `ablation`, `
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* a skill body names each of those paths, so moving one needs a code change
|
|
55
|
-
* first rather than a promotion.
|
|
51
|
+
* for. `ablation`, `runs`, `memory-archive`, `hooks`, `pr`, and `handoff`
|
|
52
|
+
* fail the second clause: a script or a skill body names each of those
|
|
53
|
+
* paths, so moving one needs a code change first rather than a promotion.
|
|
56
54
|
*/
|
|
57
55
|
export const PROMOTED_FOLDERS: readonly string[] = [
|
|
58
56
|
'hero-probe',
|