@erclx/canon 4.86.0 → 4.88.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 +6 -6
- package/claude/skills/draft-and-pick/SKILL.md +1 -1
- package/claude/skills/draft-diagram/SKILL.md +2 -2
- package/claude/skills/draft-slides/SKILL.md +1 -1
- 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-branch/SKILL.md +3 -3
- 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/sketch-design/SKILL.md +4 -4
- 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 +99 -96
- package/docs/agents/design-board.md +7 -7
- package/docs/agents/records.md +27 -0
- package/docs/agents/sandbox.md +1 -1
- package/docs/workflow/ai-workflow.md +5 -3
- package/docs/workflow/visual-design-workflow.md +1 -1
- 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/cli.ts +1 -1
- package/src/commands/design.ts +3 -3
- package/src/commands/feedback.ts +12 -12
- package/src/commands/migrate.ts +36 -13
- package/src/commands/records.ts +159 -0
- 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 +328 -75
- package/src/migrate/scratch-evidence.ts +60 -35
- package/src/record-root.ts +3 -0
- package/src/records/prune.ts +488 -0
- package/src/records/size.ts +24 -1
- package/standards/publish.md +1 -1
- package/standards/skill.md +1 -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
package/src/cli.ts
CHANGED
|
@@ -72,7 +72,7 @@ function showHelp(): void {
|
|
|
72
72
|
`${GREY}│${NC} demo [cmd] ${GREY}# Record a running app (compile, run)${NC}`,
|
|
73
73
|
`${GREY}│${NC} inventory [subj] ${GREY}# Report one computed property across every route${NC}`,
|
|
74
74
|
`${GREY}│${NC} drive <url> <run> ${GREY}# Walk a page through named interactions and measure each state${NC}`,
|
|
75
|
-
`${GREY}│${NC} feedback ${GREY}# Write toolkit feedback from stdin to .canon/
|
|
75
|
+
`${GREY}│${NC} feedback ${GREY}# Write toolkit feedback from stdin to .canon/feedback/${NC}`,
|
|
76
76
|
`${GREY}│${NC} transcripts <url> ${GREY}# Fetch a YouTube transcript with metadata frontmatter${NC}`,
|
|
77
77
|
`${GREY}│${NC} tasks [cmd] ${GREY}# Task board commands (archive)${NC}`,
|
|
78
78
|
`${GREY}│${NC} intake [cmd] ${GREY}# Intake folders under .canon/intake/ (list, answer)${NC}`,
|
package/src/commands/design.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
isOwnCheckout,
|
|
17
17
|
PROJECT_ROOT,
|
|
18
18
|
} from '@/project-root'
|
|
19
|
-
import { creationRel } from '@/record-root'
|
|
19
|
+
import { creationRel, SCRATCH } from '@/record-root'
|
|
20
20
|
import { surfaceDir } from '@/surface-root'
|
|
21
21
|
import { recordStamp, runDomainSync } from '@/sync/engine'
|
|
22
22
|
import { resolveTarget } from '@/target'
|
|
@@ -113,7 +113,7 @@ export function register(program: Command): void {
|
|
|
113
113
|
.option(
|
|
114
114
|
'-o, --out <path>',
|
|
115
115
|
'Output directory',
|
|
116
|
-
creationRel(process.cwd(), '
|
|
116
|
+
creationRel(process.cwd(), SCRATCH, 'render', 'design'),
|
|
117
117
|
)
|
|
118
118
|
.action((opts: { source: string; out: string }) => {
|
|
119
119
|
const sourcePath = resolve(process.cwd(), opts.source)
|
|
@@ -143,7 +143,7 @@ export function register(program: Command): void {
|
|
|
143
143
|
.option(
|
|
144
144
|
'-o, --out <path>',
|
|
145
145
|
'Output directory',
|
|
146
|
-
creationRel(process.cwd(), '
|
|
146
|
+
creationRel(process.cwd(), SCRATCH, 'render', 'board'),
|
|
147
147
|
)
|
|
148
148
|
.option('--root <path>', 'Project root, defaulting to the main worktree')
|
|
149
149
|
.addHelpText(
|
package/src/commands/feedback.ts
CHANGED
|
@@ -40,20 +40,20 @@ function isToolkitSource(): boolean {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
* One producer, one
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
43
|
+
* One producer, one record folder. Feedback is not a review, so it writes
|
|
44
|
+
* under a root folder of its own rather than inside `review/`, and `canon
|
|
45
|
+
* records push` backs it with no list edit, since it carries every `.canon/`
|
|
46
|
+
* entry it does not exclude.
|
|
47
47
|
*/
|
|
48
48
|
function writeLocal(body: string, mismatch: string | undefined): string {
|
|
49
|
-
// Resolved against the same root the write joins onto
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
const relativeDir = creationRel(PROJECT_ROOT, '
|
|
53
|
-
const
|
|
54
|
-
mkdirSync(
|
|
49
|
+
// Resolved against the same root the write joins onto. `creationRel` reads
|
|
50
|
+
// whichever root already carries a `feedback/` folder and falls back to the
|
|
51
|
+
// creation root when neither does.
|
|
52
|
+
const relativeDir = creationRel(PROJECT_ROOT, 'feedback')
|
|
53
|
+
const feedbackDir = join(PROJECT_ROOT, relativeDir)
|
|
54
|
+
mkdirSync(feedbackDir, { recursive: true })
|
|
55
55
|
const filename = `feedback-${deriveSlug(body)}-${timestamp()}.md`
|
|
56
|
-
const filePath = join(
|
|
56
|
+
const filePath = join(feedbackDir, filename)
|
|
57
57
|
writeFileSync(filePath, `${body}\n`, 'utf8')
|
|
58
58
|
|
|
59
59
|
// Manual frame rather than `frameSuccess`, since the mismatch warning is a
|
|
@@ -72,7 +72,7 @@ export function register(program: Command): void {
|
|
|
72
72
|
program
|
|
73
73
|
.command('feedback')
|
|
74
74
|
.description(
|
|
75
|
-
'Write toolkit feedback from stdin to .canon/
|
|
75
|
+
'Write toolkit feedback from stdin to .canon/feedback/, or open a GitHub issue with --github',
|
|
76
76
|
)
|
|
77
77
|
.option(
|
|
78
78
|
'--github',
|
package/src/commands/migrate.ts
CHANGED
|
@@ -671,7 +671,7 @@ interface ScratchEvidenceOptions {
|
|
|
671
671
|
|
|
672
672
|
/**
|
|
673
673
|
* Moves the nine cited-and-unwritten folders under `.canon/tmp/` to
|
|
674
|
-
* `.canon/
|
|
674
|
+
* `.canon/evidence/`, and repoints the citations that name them,
|
|
675
675
|
* archives included.
|
|
676
676
|
*/
|
|
677
677
|
async function runScratchEvidence(
|
|
@@ -760,9 +760,10 @@ interface RecordLayoutOptions {
|
|
|
760
760
|
}
|
|
761
761
|
|
|
762
762
|
/**
|
|
763
|
-
* Moves
|
|
764
|
-
*
|
|
765
|
-
* `.canon/
|
|
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.
|
|
766
767
|
*/
|
|
767
768
|
async function runRecordLayout(opts: RecordLayoutOptions): Promise<number> {
|
|
768
769
|
const root = opts.root ?? process.cwd()
|
|
@@ -797,7 +798,7 @@ async function runRecordLayout(opts: RecordLayoutOptions): Promise<number> {
|
|
|
797
798
|
|
|
798
799
|
const applied = await applyRecordLayout(plan)
|
|
799
800
|
logStep(
|
|
800
|
-
`Moved ${plural(applied.moved, '
|
|
801
|
+
`Moved ${plural(applied.moved, 'path')} and rewrote ${plural(applied.written, 'file')}.`,
|
|
801
802
|
)
|
|
802
803
|
|
|
803
804
|
if (applied.failed.length > 0) {
|
|
@@ -810,9 +811,10 @@ async function runRecordLayout(opts: RecordLayoutOptions): Promise<number> {
|
|
|
810
811
|
}
|
|
811
812
|
|
|
812
813
|
function reportRecordLayout(plan: RecordLayoutPlan): void {
|
|
813
|
-
logInfo(`${plural(plan.moves.length, '
|
|
814
|
+
logInfo(`${plural(plan.moves.length, 'path')} to move.`)
|
|
814
815
|
for (const move of plan.moves) {
|
|
815
|
-
|
|
816
|
+
const label = move.classified === undefined ? '' : ` (${move.classified})`
|
|
817
|
+
logInfo(` ${move.from} -> ${move.to}${label}`)
|
|
816
818
|
}
|
|
817
819
|
|
|
818
820
|
logInfo(
|
|
@@ -837,7 +839,11 @@ function toRecordLayoutRecord(
|
|
|
837
839
|
return {
|
|
838
840
|
ok: true,
|
|
839
841
|
wrote: wrote === true,
|
|
840
|
-
moves: plan.moves.map((move) => ({
|
|
842
|
+
moves: plan.moves.map((move) => ({
|
|
843
|
+
from: move.from,
|
|
844
|
+
to: move.to,
|
|
845
|
+
...(move.classified === undefined ? {} : { classified: move.classified }),
|
|
846
|
+
})),
|
|
841
847
|
collisions: plan.collisions,
|
|
842
848
|
files: plan.entries.length,
|
|
843
849
|
rewritten: plan.rewritten,
|
|
@@ -1097,7 +1103,7 @@ export function register(program: Command): void {
|
|
|
1097
1103
|
|
|
1098
1104
|
migrate
|
|
1099
1105
|
.command('scratch-evidence')
|
|
1100
|
-
.description('Promote cited measurement folders out of tmp into
|
|
1106
|
+
.description('Promote cited measurement folders out of tmp into evidence')
|
|
1101
1107
|
.helpOption('-h, --help', 'Show this help message')
|
|
1102
1108
|
.option('--json', 'Add a machine-readable record on stdout')
|
|
1103
1109
|
.option('--write', 'Apply the plan rather than reporting it')
|
|
@@ -1110,7 +1116,7 @@ export function register(program: Command): void {
|
|
|
1110
1116
|
[
|
|
1111
1117
|
'',
|
|
1112
1118
|
'Moves nine folders cited as measurement evidence from .canon/tmp/ to',
|
|
1113
|
-
'.canon/
|
|
1119
|
+
'.canon/evidence/<nn>-<folder>/, numbered by first appearance, and',
|
|
1114
1120
|
'repoints every citation that names one, live or archived.',
|
|
1115
1121
|
'',
|
|
1116
1122
|
'A promoted folder is one a durable record cites and no source file',
|
|
@@ -1136,7 +1142,7 @@ export function register(program: Command): void {
|
|
|
1136
1142
|
|
|
1137
1143
|
migrate
|
|
1138
1144
|
.command('record-layout')
|
|
1139
|
-
.description('
|
|
1145
|
+
.description('Move record folders to their batch layout')
|
|
1140
1146
|
.helpOption('-h, --help', 'Show this help message')
|
|
1141
1147
|
.option('--json', 'Add a machine-readable record on stdout')
|
|
1142
1148
|
.option('--write', 'Apply the plan rather than reporting it')
|
|
@@ -1151,8 +1157,25 @@ export function register(program: Command): void {
|
|
|
1151
1157
|
'Moves review receipts from .canon/review/memory/ to',
|
|
1152
1158
|
'.canon/memory/review/, and retired entries from',
|
|
1153
1159
|
'.canon/tmp/memory-archive/ to .canon/memory/archive/, backed for',
|
|
1154
|
-
'the first time
|
|
1155
|
-
'
|
|
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/handoff/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.',
|
|
1156
1179
|
'',
|
|
1157
1180
|
'A receipt sitting at the flat review/ root, the shape memory-review',
|
|
1158
1181
|
'wrote before review/memory/ existed, is reported rather than moved.',
|
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)
|
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
|
+
}
|