@erclx/canon 4.85.0 → 4.87.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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/SKILL.md +6 -6
- package/claude/skills/canon-feedback-file/SKILL.md +2 -2
- package/claude/skills/design-extract/SKILL.md +3 -3
- package/claude/skills/docs-fold/SKILL.md +3 -3
- package/claude/skills/draft-and-pick/REQUIREMENT.md +2 -1
- package/claude/skills/draft-and-pick/SKILL.md +4 -3
- package/claude/skills/draft-and-pick/references/live-arms.md +19 -0
- package/claude/skills/draft-diagram/SKILL.md +2 -2
- package/claude/skills/draft-slides/SKILL.md +1 -1
- package/claude/skills/git-ship/SKILL.md +1 -1
- package/claude/skills/memory-capture/SKILL.md +1 -1
- package/claude/skills/memory-review/SKILL.md +13 -13
- package/claude/skills/memory-review/references/receipt-format.md +1 -1
- package/claude/skills/review-branch/SKILL.md +3 -3
- package/claude/skills/role-worker/SKILL.md +2 -1
- package/claude/skills/sketch-design/SKILL.md +4 -4
- package/claude/skills/ux-walkthrough/SKILL.md +2 -2
- package/claude/skills/ux-walkthrough/references/candidate-pages.md +1 -16
- package/docs/agents/commands.md +100 -95
- package/docs/agents/design-board.md +7 -7
- package/docs/workflow/ai-workflow.md +3 -3
- package/docs/workflow/visual-design-workflow.md +1 -1
- package/governance/rules/claude/563-ready.md +11 -0
- package/governance/rules/core/045-memory.md +1 -1
- package/package.json +1 -1
- package/src/cli.ts +1 -1
- package/src/commands/design.ts +3 -3
- package/src/commands/feedback.ts +12 -12
- package/src/commands/migrate.ts +168 -3
- package/src/commands/slides.ts +2 -2
- package/src/design/board.ts +17 -10
- package/src/migrate/evidence-ordinal.ts +79 -0
- package/src/migrate/record-layout.ts +599 -0
- package/src/migrate/record-tree.ts +1 -1
- package/src/migrate/scratch-evidence.ts +57 -30
- package/src/record-root.ts +4 -0
- package/standards/index.md +1 -0
- package/standards/memory.md +1 -1
- package/standards/plan.md +2 -0
- package/standards/publish.md +1 -1
- package/standards/ready.md +105 -0
- package/standards/skill.md +1 -1
- package/tooling/claude/seeds/.claude/hooks/memory-index.sh +10 -0
package/src/commands/migrate.ts
CHANGED
|
@@ -30,6 +30,13 @@ import {
|
|
|
30
30
|
planSurfaceRootsMove,
|
|
31
31
|
type SurfaceRootsPlan,
|
|
32
32
|
} from '@/migrate/surface-roots'
|
|
33
|
+
import {
|
|
34
|
+
applyRecordLayout,
|
|
35
|
+
planRecordLayout,
|
|
36
|
+
readRecordLayoutCorpus,
|
|
37
|
+
type RecordLayoutPlan,
|
|
38
|
+
walkRecordLayoutCorpus,
|
|
39
|
+
} from '@/migrate/record-layout'
|
|
33
40
|
import {
|
|
34
41
|
applyScratchEvidence,
|
|
35
42
|
planScratchEvidence,
|
|
@@ -664,7 +671,7 @@ interface ScratchEvidenceOptions {
|
|
|
664
671
|
|
|
665
672
|
/**
|
|
666
673
|
* Moves the nine cited-and-unwritten folders under `.canon/tmp/` to
|
|
667
|
-
* `.canon/
|
|
674
|
+
* `.canon/evidence/`, and repoints the citations that name them,
|
|
668
675
|
* archives included.
|
|
669
676
|
*/
|
|
670
677
|
async function runScratchEvidence(
|
|
@@ -746,6 +753,108 @@ function toScratchEvidenceRecord(
|
|
|
746
753
|
}
|
|
747
754
|
}
|
|
748
755
|
|
|
756
|
+
interface RecordLayoutOptions {
|
|
757
|
+
readonly json?: boolean
|
|
758
|
+
readonly write?: boolean
|
|
759
|
+
readonly root?: string
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Moves every row of `RECORD_LAYOUT_MOVES`: the memory pen's receipts and
|
|
764
|
+
* retired entries under `.canon/memory/`, and everything under
|
|
765
|
+
* `.canon/review/` that is not a review out to its own folder, then repoints
|
|
766
|
+
* the citations that name any of them.
|
|
767
|
+
*/
|
|
768
|
+
async function runRecordLayout(opts: RecordLayoutOptions): Promise<number> {
|
|
769
|
+
const root = opts.root ?? process.cwd()
|
|
770
|
+
|
|
771
|
+
const files = await walkRecordLayoutCorpus(root)
|
|
772
|
+
const sources = await readRecordLayoutCorpus(files)
|
|
773
|
+
const plan = planRecordLayout(root, sources)
|
|
774
|
+
|
|
775
|
+
if (opts.json) {
|
|
776
|
+
process.stdout.write(
|
|
777
|
+
`${JSON.stringify(toRecordLayoutRecord(plan, opts.write))}\n`,
|
|
778
|
+
)
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
reportRecordLayout(plan)
|
|
782
|
+
|
|
783
|
+
if (plan.collisions.length > 0) {
|
|
784
|
+
logError(
|
|
785
|
+
`${plural(plan.collisions.length, 'destination')} already occupied. Neither side moved.`,
|
|
786
|
+
)
|
|
787
|
+
for (const collision of plan.collisions) logError(` ${collision}`)
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
if (plan.moves.length === 0 && plan.entries.length === 0) {
|
|
791
|
+
return plan.collisions.length > 0 ? 1 : 0
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
if (!opts.write) {
|
|
795
|
+
logWarn('Nothing was written. Pass --write to apply this plan.')
|
|
796
|
+
return 2
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const applied = await applyRecordLayout(plan)
|
|
800
|
+
logStep(
|
|
801
|
+
`Moved ${plural(applied.moved, 'path')} and rewrote ${plural(applied.written, 'file')}.`,
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
if (applied.failed.length > 0) {
|
|
805
|
+
logError(`Could not write ${plural(applied.failed.length, 'file')}.`)
|
|
806
|
+
for (const path of applied.failed) logError(` ${path}`)
|
|
807
|
+
return 1
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
return plan.collisions.length > 0 ? 1 : 0
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function reportRecordLayout(plan: RecordLayoutPlan): void {
|
|
814
|
+
logInfo(`${plural(plan.moves.length, 'path')} to move.`)
|
|
815
|
+
for (const move of plan.moves) {
|
|
816
|
+
const label = move.classified === undefined ? '' : ` (${move.classified})`
|
|
817
|
+
logInfo(` ${move.from} -> ${move.to}${label}`)
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
logInfo(
|
|
821
|
+
`${plural(plan.entries.length, 'file')} to change, ${plural(plan.rewritten, 'citation')} to rewrite.`,
|
|
822
|
+
)
|
|
823
|
+
for (const entry of plan.entries) {
|
|
824
|
+
logInfo(` ${entry.path}: ${plural(entry.rewritten, 'citation')}`)
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
if (plan.strays.length > 0) {
|
|
828
|
+
logWarn(
|
|
829
|
+
`${plural(plan.strays.length, 'stray receipt')} at the flat review/ root. Not moved, move by hand into memory/review/.`,
|
|
830
|
+
)
|
|
831
|
+
for (const stray of plan.strays) logWarn(` ${stray}`)
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function toRecordLayoutRecord(
|
|
836
|
+
plan: RecordLayoutPlan,
|
|
837
|
+
wrote: boolean | undefined,
|
|
838
|
+
): unknown {
|
|
839
|
+
return {
|
|
840
|
+
ok: true,
|
|
841
|
+
wrote: wrote === true,
|
|
842
|
+
moves: plan.moves.map((move) => ({
|
|
843
|
+
from: move.from,
|
|
844
|
+
to: move.to,
|
|
845
|
+
...(move.classified === undefined ? {} : { classified: move.classified }),
|
|
846
|
+
})),
|
|
847
|
+
collisions: plan.collisions,
|
|
848
|
+
files: plan.entries.length,
|
|
849
|
+
rewritten: plan.rewritten,
|
|
850
|
+
strays: plan.strays,
|
|
851
|
+
paths: plan.entries.map((entry) => ({
|
|
852
|
+
path: entry.path,
|
|
853
|
+
rewritten: entry.rewritten,
|
|
854
|
+
})),
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
749
858
|
interface RuleLayoutOptions {
|
|
750
859
|
readonly json?: boolean
|
|
751
860
|
readonly write?: boolean
|
|
@@ -994,7 +1103,7 @@ export function register(program: Command): void {
|
|
|
994
1103
|
|
|
995
1104
|
migrate
|
|
996
1105
|
.command('scratch-evidence')
|
|
997
|
-
.description('Promote cited measurement folders out of tmp into
|
|
1106
|
+
.description('Promote cited measurement folders out of tmp into evidence')
|
|
998
1107
|
.helpOption('-h, --help', 'Show this help message')
|
|
999
1108
|
.option('--json', 'Add a machine-readable record on stdout')
|
|
1000
1109
|
.option('--write', 'Apply the plan rather than reporting it')
|
|
@@ -1007,7 +1116,7 @@ export function register(program: Command): void {
|
|
|
1007
1116
|
[
|
|
1008
1117
|
'',
|
|
1009
1118
|
'Moves nine folders cited as measurement evidence from .canon/tmp/ to',
|
|
1010
|
-
'.canon/
|
|
1119
|
+
'.canon/evidence/<nn>-<folder>/, numbered by first appearance, and',
|
|
1011
1120
|
'repoints every citation that names one, live or archived.',
|
|
1012
1121
|
'',
|
|
1013
1122
|
'A promoted folder is one a durable record cites and no source file',
|
|
@@ -1031,6 +1140,62 @@ export function register(program: Command): void {
|
|
|
1031
1140
|
process.exitCode = await runScratchEvidence(opts)
|
|
1032
1141
|
})
|
|
1033
1142
|
|
|
1143
|
+
migrate
|
|
1144
|
+
.command('record-layout')
|
|
1145
|
+
.description('Move record folders to their batch layout')
|
|
1146
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
1147
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
1148
|
+
.option('--write', 'Apply the plan rather than reporting it')
|
|
1149
|
+
.option(
|
|
1150
|
+
'--root <path>',
|
|
1151
|
+
'Project root, defaulting to the working directory',
|
|
1152
|
+
)
|
|
1153
|
+
.addHelpText(
|
|
1154
|
+
'after',
|
|
1155
|
+
[
|
|
1156
|
+
'',
|
|
1157
|
+
'Moves review receipts from .canon/review/memory/ to',
|
|
1158
|
+
'.canon/memory/review/, and retired entries from',
|
|
1159
|
+
'.canon/tmp/memory-archive/ to .canon/memory/archive/, backed for',
|
|
1160
|
+
'the first time.',
|
|
1161
|
+
'',
|
|
1162
|
+
'Leaves .canon/review/ holding only reviews:',
|
|
1163
|
+
' review/feedback/ -> feedback/',
|
|
1164
|
+
' review/{design,board,slides,diagrams}/ -> tmp/render/<kind>/',
|
|
1165
|
+
' review/references/ -> picks/references/',
|
|
1166
|
+
' review/branch/review-<slug>.md -> review/branch-<slug>.md',
|
|
1167
|
+
' review/ui-checklist-<slug>.md -> tmp/ui-checklist/<slug>.md',
|
|
1168
|
+
' review/evidence/<slug>/ -> picks/<slug>/ or evidence/<nn>-<slug>/',
|
|
1169
|
+
'',
|
|
1170
|
+
'An evidence folder is a pick when it directly holds an arm-<id>',
|
|
1171
|
+
'capture or a design-handoff.md, and evidence otherwise. Evidence',
|
|
1172
|
+
'folders are numbered by the oldest file each holds, continuing past',
|
|
1173
|
+
'any ordinal evidence/ already carries. The dry run labels every',
|
|
1174
|
+
'derived destination, so check the split before passing --write.',
|
|
1175
|
+
'',
|
|
1176
|
+
'A citation reaching into a moved folder or naming a moved file is',
|
|
1177
|
+
'repointed, live or archived. A bare mention of an emptied folder, such',
|
|
1178
|
+
'as review/evidence/ with no slug, matches no row and stays as written.',
|
|
1179
|
+
'',
|
|
1180
|
+
'A receipt sitting at the flat review/ root, the shape memory-review',
|
|
1181
|
+
'wrote before review/memory/ existed, is reported rather than moved.',
|
|
1182
|
+
'',
|
|
1183
|
+
'Exit codes:',
|
|
1184
|
+
' 0 nothing to move, or --write applied the whole plan',
|
|
1185
|
+
' 1 a write failed, or an unresolved collision remains',
|
|
1186
|
+
' 2 a plan exists and --write was not passed',
|
|
1187
|
+
'',
|
|
1188
|
+
'Examples:',
|
|
1189
|
+
' canon migrate record-layout',
|
|
1190
|
+
' canon migrate record-layout --write',
|
|
1191
|
+
' canon migrate record-layout --json',
|
|
1192
|
+
'',
|
|
1193
|
+
].join('\n'),
|
|
1194
|
+
)
|
|
1195
|
+
.action(async (opts: RecordLayoutOptions) => {
|
|
1196
|
+
process.exitCode = await runRecordLayout(opts)
|
|
1197
|
+
})
|
|
1198
|
+
|
|
1034
1199
|
migrate
|
|
1035
1200
|
.command('rename')
|
|
1036
1201
|
.description('Rewrite every unprotected aitk token to canon')
|
package/src/commands/slides.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { resolve } from 'node:path'
|
|
3
3
|
import type { Command } from 'commander'
|
|
4
|
-
import { creationRel } from '@/record-root'
|
|
4
|
+
import { creationRel, SCRATCH } from '@/record-root'
|
|
5
5
|
import { LAYOUTS } from '@/slides/layouts'
|
|
6
6
|
import { openDeck } from '@/slides/open'
|
|
7
7
|
import { renderSlidesDoc } from '@/slides/render'
|
|
@@ -20,7 +20,7 @@ export function register(program: Command): void {
|
|
|
20
20
|
.option(
|
|
21
21
|
'-o, --out <path>',
|
|
22
22
|
'Output directory',
|
|
23
|
-
creationRel(process.cwd(), '
|
|
23
|
+
creationRel(process.cwd(), SCRATCH, 'render', 'slides'),
|
|
24
24
|
)
|
|
25
25
|
.option('-v, --variant <variant>', 'Override variant (light or dark)')
|
|
26
26
|
.option(
|
package/src/design/board.ts
CHANGED
|
@@ -399,7 +399,7 @@ function isImage(name: string): boolean {
|
|
|
399
399
|
return IMAGE_EXTENSIONS.some((ext) => name.toLowerCase().endsWith(ext))
|
|
400
400
|
}
|
|
401
401
|
|
|
402
|
-
/** Every image file directly inside
|
|
402
|
+
/** Every image file directly inside a pick folder, one level deep. */
|
|
403
403
|
function imagesIn(dir: string): string[] {
|
|
404
404
|
return readdirSync(dir, { withFileTypes: true })
|
|
405
405
|
.filter((entry) => entry.isFile() && isImage(entry.name))
|
|
@@ -407,32 +407,39 @@ function imagesIn(dir: string): string[] {
|
|
|
407
407
|
.sort()
|
|
408
408
|
}
|
|
409
409
|
|
|
410
|
+
/**
|
|
411
|
+
* The folder under `picks/` holding operator-supplied reference images, which
|
|
412
|
+
* the References panel reads. It sits beside the captures of the choices it
|
|
413
|
+
* fed, so the candidates panel steps over it rather than listing it as a pick.
|
|
414
|
+
*/
|
|
415
|
+
const REFERENCES_FOLDER = 'references'
|
|
416
|
+
|
|
410
417
|
function writeCandidatesPanel(root: string, outDir: string): void {
|
|
411
418
|
const dir = join(outDir, 'candidates')
|
|
412
419
|
mkdirSync(dir, { recursive: true })
|
|
413
420
|
|
|
414
|
-
const
|
|
415
|
-
if (!existsSync(
|
|
421
|
+
const picksDir = recordDir(root, 'picks')
|
|
422
|
+
if (!existsSync(picksDir)) {
|
|
416
423
|
writeFileSync(
|
|
417
424
|
join(dir, 'index.html'),
|
|
418
425
|
panelPage(
|
|
419
426
|
'Past candidates',
|
|
420
|
-
`<p class="empty">No ${relative(root,
|
|
427
|
+
`<p class="empty">No ${relative(root, picksDir)} folder yet.</p>`,
|
|
421
428
|
),
|
|
422
429
|
)
|
|
423
430
|
return
|
|
424
431
|
}
|
|
425
432
|
|
|
426
|
-
const folders = readdirSync(
|
|
427
|
-
.filter((entry) => entry.isDirectory())
|
|
433
|
+
const folders = readdirSync(picksDir, { withFileTypes: true })
|
|
434
|
+
.filter((entry) => entry.isDirectory() && entry.name !== REFERENCES_FOLDER)
|
|
428
435
|
.map((entry) => entry.name)
|
|
429
436
|
.sort()
|
|
430
437
|
|
|
431
438
|
const found: Array<{ folder: string; images: string[] }> = []
|
|
432
439
|
for (const folder of folders) {
|
|
433
|
-
const images = imagesIn(join(
|
|
440
|
+
const images = imagesIn(join(picksDir, folder))
|
|
434
441
|
if (images.length > 0) {
|
|
435
|
-
cpSync(join(
|
|
442
|
+
cpSync(join(picksDir, folder), join(dir, folder), { recursive: true })
|
|
436
443
|
found.push({ folder, images })
|
|
437
444
|
}
|
|
438
445
|
}
|
|
@@ -442,7 +449,7 @@ function writeCandidatesPanel(root: string, outDir: string): void {
|
|
|
442
449
|
join(dir, 'index.html'),
|
|
443
450
|
panelPage(
|
|
444
451
|
'Past candidates',
|
|
445
|
-
`<p class="empty">${folders.length} folders under ${relative(root,
|
|
452
|
+
`<p class="empty">${folders.length} folders under ${relative(root, picksDir)}/ and none carries a draft-and-pick arm capture. The archival capture step has not run since it shipped.</p>`,
|
|
446
453
|
),
|
|
447
454
|
)
|
|
448
455
|
return
|
|
@@ -462,7 +469,7 @@ function writeReferencesPanel(root: string, outDir: string): void {
|
|
|
462
469
|
const dir = join(outDir, 'references')
|
|
463
470
|
mkdirSync(dir, { recursive: true })
|
|
464
471
|
|
|
465
|
-
const referencesDir = recordDir(root, '
|
|
472
|
+
const referencesDir = recordDir(root, 'picks', REFERENCES_FOLDER)
|
|
466
473
|
if (!existsSync(referencesDir)) {
|
|
467
474
|
writeFileSync(
|
|
468
475
|
join(dir, 'index.html'),
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a folder under `.canon/evidence/` is numbered, shared by the two
|
|
3
|
+
* migrations that land folders there.
|
|
4
|
+
*
|
|
5
|
+
* A folder takes `<nn>-<slug>` in the order folders first appeared, and a new
|
|
6
|
+
* one continues past the highest ordinal already present. No record folder is
|
|
7
|
+
* tracked in git, so file modification times are the only history a target
|
|
8
|
+
* holds to order by.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
import { extractOrdinal } from '@/intake/folder'
|
|
14
|
+
|
|
15
|
+
const ORDINAL_WIDTH = 2
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* When a folder first appeared, read as the oldest modification time of any
|
|
19
|
+
* file under it. A folder holding no file falls back to its own.
|
|
20
|
+
*/
|
|
21
|
+
export function firstAppearance(dir: string): number {
|
|
22
|
+
let oldest = Number.POSITIVE_INFINITY
|
|
23
|
+
|
|
24
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
25
|
+
const path = join(dir, entry.name)
|
|
26
|
+
const time = entry.isDirectory()
|
|
27
|
+
? firstAppearance(path)
|
|
28
|
+
: statSync(path).mtimeMs
|
|
29
|
+
oldest = Math.min(oldest, time)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return Number.isFinite(oldest) ? oldest : statSync(dir).mtimeMs
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The folder names directly inside `dir`, sorted, or none when it is absent. */
|
|
36
|
+
export function folderNames(dir: string): string[] {
|
|
37
|
+
if (!existsSync(dir)) return []
|
|
38
|
+
|
|
39
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
40
|
+
.filter((entry) => entry.isDirectory())
|
|
41
|
+
.map((entry) => entry.name)
|
|
42
|
+
.sort()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A folder name with any leading ordinal removed. */
|
|
46
|
+
export function slugOf(name: string): string {
|
|
47
|
+
const ordinal = extractOrdinal(name)
|
|
48
|
+
return ordinal === '' ? name : name.slice(ordinal.length + 1)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The ordinal a new folder takes after every name already present. */
|
|
52
|
+
export function nextOrdinal(existing: readonly string[]): number {
|
|
53
|
+
return (
|
|
54
|
+
existing
|
|
55
|
+
.map((name) => Number(extractOrdinal(name) || 0))
|
|
56
|
+
.reduce((carry, ordinal) => Math.max(carry, ordinal), 0) + 1
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The folder name an ordinal and a slug make together. */
|
|
61
|
+
export function numberedName(ordinal: number, slug: string): string {
|
|
62
|
+
return `${String(ordinal).padStart(ORDINAL_WIDTH, '0')}-${slug}`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Orders candidate folders by first appearance, breaking an exact tie on the
|
|
67
|
+
* name so two runs over one tree number it the same way.
|
|
68
|
+
*/
|
|
69
|
+
export function byFirstAppearance<T extends { name: string; dir: string }>(
|
|
70
|
+
folders: readonly T[],
|
|
71
|
+
): T[] {
|
|
72
|
+
return folders
|
|
73
|
+
.map((folder) => ({ folder, appeared: firstAppearance(folder.dir) }))
|
|
74
|
+
.sort(
|
|
75
|
+
(a, b) =>
|
|
76
|
+
a.appeared - b.appeared || a.folder.name.localeCompare(b.folder.name),
|
|
77
|
+
)
|
|
78
|
+
.map(({ folder }) => folder)
|
|
79
|
+
}
|