@erclx/canon 5.26.0 → 5.27.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/README.md +2 -2
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/api-design/SKILL.md +2 -0
- package/claude/skills/auto-ship/SKILL.md +3 -11
- package/claude/skills/auto-ship/references/review-findings.md +20 -0
- package/claude/skills/backlog-triage/SKILL.md +2 -0
- package/claude/skills/bash-cli-script/SKILL.md +2 -0
- package/claude/skills/build-in-slices/SKILL.md +2 -0
- package/claude/skills/canon-cli/SKILL.md +2 -0
- package/claude/skills/canon-feedback/SKILL.md +2 -0
- package/claude/skills/canon-feedback-triage/SKILL.md +2 -0
- package/claude/skills/canon-operator/SKILL.md +2 -0
- package/claude/skills/canon-rollout/SKILL.md +3 -11
- package/claude/skills/canon-rollout/references/failure-recovery.md +20 -0
- package/claude/skills/canvas/SKILL.md +13 -0
- package/claude/skills/ci-workflow/SKILL.md +2 -0
- package/claude/skills/code-craft/SKILL.md +2 -0
- package/claude/skills/codebase-layout/SKILL.md +2 -0
- package/claude/skills/context-fold/SKILL.md +3 -17
- package/claude/skills/context-fold/references/completion-report.md +26 -0
- package/claude/skills/create-rule/SKILL.md +2 -0
- package/claude/skills/create-skill/SKILL.md +2 -0
- package/claude/skills/create-standard/SKILL.md +2 -0
- package/claude/skills/decision-escalate/SKILL.md +2 -0
- package/claude/skills/deploy-app/SKILL.md +2 -0
- package/claude/skills/deprecation-migration/SKILL.md +2 -0
- package/claude/skills/design-extract/SKILL.md +2 -0
- package/claude/skills/design-taste/SKILL.md +2 -0
- package/claude/skills/docs-sync/SKILL.md +2 -0
- package/claude/skills/document-health/SKILL.md +2 -0
- package/claude/skills/draft-and-pick/SKILL.md +2 -0
- package/claude/skills/draft-doc/SKILL.md +2 -0
- package/claude/skills/draft-doc/references/wiki.md +1 -1
- package/claude/skills/draft-figure/SKILL.md +2 -0
- package/claude/skills/draft-identity/SKILL.md +2 -0
- package/claude/skills/draft-ready/SKILL.md +2 -0
- package/claude/skills/draft-screencast/SKILL.md +2 -0
- package/claude/skills/draft-slides/SKILL.md +2 -0
- package/claude/skills/git-branch/SKILL.md +2 -0
- package/claude/skills/git-commit/SKILL.md +2 -0
- package/claude/skills/git-followup/SKILL.md +2 -0
- package/claude/skills/git-issue/SKILL.md +2 -0
- package/claude/skills/git-pr/SKILL.md +2 -0
- package/claude/skills/git-pr/references/evidence.md +6 -6
- package/claude/skills/git-ship/SKILL.md +2 -0
- package/claude/skills/git-split/SKILL.md +2 -0
- package/claude/skills/git-stage/SKILL.md +2 -0
- package/claude/skills/git-worktree/SKILL.md +2 -0
- package/claude/skills/index-lookup/SKILL.md +2 -0
- package/claude/skills/markdown-propose/SKILL.md +2 -0
- package/claude/skills/memory-capture/SKILL.md +2 -0
- package/claude/skills/memory-review/SKILL.md +2 -0
- package/claude/skills/plan-feature/SKILL.md +2 -0
- package/claude/skills/plan-groundwork/SKILL.md +2 -0
- package/claude/skills/plan-intake/SKILL.md +2 -0
- package/claude/skills/plan-intake-answer/SKILL.md +2 -0
- package/claude/skills/project-commands/SKILL.md +2 -0
- package/claude/skills/read-frames/SKILL.md +2 -0
- package/claude/skills/record-screencast/SKILL.md +2 -0
- package/claude/skills/repo-metadata/SKILL.md +2 -0
- package/claude/skills/restate-plainly/SKILL.md +2 -0
- package/claude/skills/review-address/SKILL.md +3 -7
- package/claude/skills/review-address/references/post-review-findings.md +16 -0
- package/claude/skills/review-branch/SKILL.md +2 -0
- package/claude/skills/review-craft/SKILL.md +2 -0
- package/claude/skills/review-craft/references/ui.md +2 -2
- package/claude/skills/review-pr/SKILL.md +4 -8
- package/claude/skills/review-pr/references/body-variants.md +52 -0
- package/claude/skills/review-pr/references/key-changes.md +16 -0
- package/claude/skills/review-pr/references/post.md +15 -43
- package/claude/skills/review-ui/SKILL.md +3 -1
- package/claude/skills/role-orchestrator/SKILL.md +5 -14
- package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +23 -0
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -33
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -21
- package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +38 -0
- package/claude/skills/role-orchestrator/references/orchestrator-watch.md +26 -0
- package/claude/skills/role-planner/SKILL.md +2 -0
- package/claude/skills/role-reviewer/REQUIREMENT.md +1 -1
- package/claude/skills/role-reviewer/SKILL.md +2 -0
- package/claude/skills/role-worker/SKILL.md +2 -0
- package/claude/skills/search-craft/SKILL.md +2 -0
- package/claude/skills/seed-sync/SKILL.md +2 -0
- package/claude/skills/session-compact/SKILL.md +2 -0
- package/claude/skills/session-map/SKILL.md +2 -0
- package/claude/skills/session-relay/SKILL.md +2 -0
- package/claude/skills/session-resume/SKILL.md +2 -0
- package/claude/skills/session-worktree/SKILL.md +3 -11
- package/claude/skills/session-worktree/references/install-command.md +20 -0
- package/claude/skills/sketch-design/SKILL.md +2 -0
- package/claude/skills/systematic-debugging/SKILL.md +2 -0
- package/claude/skills/target-check/SKILL.md +2 -0
- package/claude/skills/target-setup/SKILL.md +2 -0
- package/claude/skills/task-board/SKILL.md +2 -0
- package/claude/skills/teach-workspace/SKILL.md +5 -15
- package/claude/skills/teach-workspace/references/lesson-build.md +75 -0
- package/claude/skills/teach-workspace/references/lesson-craft.md +13 -69
- package/claude/skills/teach-workspace/references/revisit.md +22 -0
- package/claude/skills/test-craft/SKILL.md +2 -0
- package/claude/skills/test-first/SKILL.md +2 -0
- package/claude/skills/ui-checklist/SKILL.md +2 -0
- package/claude/skills/ux-audit/SKILL.md +2 -0
- package/claude/skills/ux-measure/SKILL.md +2 -0
- package/claude/skills/ux-walkthrough/SKILL.md +2 -0
- package/claude/skills/video-craft/SKILL.md +2 -0
- package/claude/skills/write-human/SKILL.md +2 -0
- package/claude/skills/youtube-transcripts/SKILL.md +2 -0
- package/docs/agents/canvas.md +23 -7
- package/docs/agents/capture.md +5 -5
- package/docs/agents/commands.md +2 -2
- package/docs/agents/index.md +3 -3
- package/docs/agents/install-and-sync.md +7 -0
- package/docs/agents/key-changes.md +1 -1
- package/docs/agents/pr-evidence.md +38 -15
- package/docs/agents/pr-local.md +19 -13
- package/docs/agents/pr-preview.md +44 -21
- package/docs/agents/sandbox.md +28 -4
- package/docs/agents/scripting.md +8 -1
- package/docs/agents/skills-audit.md +2 -1
- package/docs/workflow/operating-model.md +5 -6
- package/docs/workflow/parallel-features.md +22 -1
- package/docs/workflow/skill-map.md +3 -1
- package/docs/workflow/zshrc-aliases.md +1 -1
- package/package.json +1 -1
- package/scripts/core/regen-hero.ts +1 -1
- package/scripts/lib/sandbox-path.sh +6 -162
- package/src/audits/run.ts +1 -1
- package/src/canvas/address.ts +12 -3
- package/src/canvas/client/inspector/align-grid.tsx +162 -0
- package/src/canvas/client/inspector/color-field.tsx +201 -171
- package/src/canvas/client/inspector/color-inputs.tsx +139 -0
- package/src/canvas/client/inspector/color-picker.tsx +668 -0
- package/src/canvas/client/inspector/color-space.ts +240 -0
- package/src/canvas/client/inspector/field.tsx +5 -1
- package/src/canvas/client/inspector/fields.css +395 -6
- package/src/canvas/client/inspector/size-field.tsx +69 -0
- package/src/canvas/client/inspector/values.ts +43 -1
- package/src/canvas/client/inspector.tsx +190 -4
- package/src/canvas/client/selection.tsx +197 -0
- package/src/canvas/client/shell.css +104 -12
- package/src/canvas/client/state.ts +69 -9
- package/src/canvas/client/surface.tsx +147 -22
- package/src/canvas/content.ts +50 -1
- package/src/canvas/edit.ts +37 -4
- package/src/canvas/editing.ts +203 -0
- package/src/canvas/global.d.ts +15 -0
- package/src/canvas/server.ts +149 -12
- package/src/canvas/tokens.ts +59 -17
- package/src/census/count.ts +1 -1
- package/src/claude/seeds.ts +1 -1
- package/src/claude/skills-audit.ts +28 -0
- package/src/claude/skills-drift.ts +1 -1
- package/src/claude/skills-families.ts +36 -0
- package/src/claude/skills-list.ts +76 -15
- package/src/commands/audits.ts +2 -2
- package/src/commands/canvas.ts +179 -2
- package/src/commands/claude.ts +67 -9
- package/src/commands/design.ts +142 -2
- package/src/commands/gov.ts +1 -1
- package/src/commands/hooks.ts +1 -1
- package/src/commands/indexes.ts +1 -1
- package/src/commands/init.ts +1 -1
- package/src/commands/intake.ts +1 -1
- package/src/commands/migrate.ts +1 -1
- package/src/commands/pr.ts +105 -58
- package/src/commands/records.ts +1 -1
- package/src/commands/repo.ts +1 -1
- package/src/commands/sandbox.ts +54 -2
- package/src/commands/sessions.ts +1 -1
- package/src/commands/sync.ts +1 -1
- package/src/commands/tasks.ts +2 -2
- package/src/commands/teach.ts +1 -1
- package/src/commands/transcripts.ts +1 -1
- package/src/commands/worktrees.ts +1 -1
- package/src/comments/scan.ts +1 -1
- package/src/comments/trend.ts +1 -1
- package/src/context/citations.ts +1 -1
- package/src/context/classify/extract.ts +2 -2
- package/src/design/project-fonts.ts +392 -0
- package/src/feedback/github.ts +1 -1
- package/src/gate/measures.ts +47 -9
- package/src/gate/sequencer.ts +1 -1
- package/src/gate/stages.ts +9 -0
- package/src/{git-files.ts → git/files.ts} +2 -2
- package/src/{git-ignore.ts → git/ignore.ts} +1 -1
- package/src/{worktree.ts → git/worktree.ts} +1 -1
- package/src/gov/citations.ts +1 -1
- package/src/gov/install.ts +1 -1
- package/src/gov/superseded.ts +1 -1
- package/src/gov/test-order.ts +2 -2
- package/src/help.ts +1 -1
- package/src/hooks/post-merge.ts +1 -1
- package/src/indexes/walk.ts +1 -1
- package/src/labels/audit.ts +1 -1
- package/src/markdown/files.ts +1 -1
- package/src/migrate/apply.ts +1 -1
- package/src/pr/bijection.ts +1 -1
- package/src/pr/evidence.ts +96 -43
- package/src/pr/local.ts +16 -10
- package/src/pr/preview.ts +78 -2
- package/src/records/backup.ts +1 -1
- package/src/records/stale-canonical.ts +1 -1
- package/src/sandbox/headless/dispatch.ts +264 -0
- package/src/sandbox/headless/run.ts +653 -0
- package/src/sandbox/headless/watch.ts +145 -0
- package/src/sandbox/provision.ts +12 -1
- package/src/sandbox/tree.ts +4 -8
- package/src/secrets/scan.ts +1 -1
- package/src/sessions/claim.ts +1 -1
- package/src/sessions/resolve.ts +1 -1
- package/src/sessions/transfer/bundle.ts +1 -1
- package/src/slides/package/fonts.ts +6 -2
- package/src/standards/audit.ts +2 -2
- package/src/sync/check.ts +2 -2
- package/src/sync/engine.ts +2 -2
- package/src/sync/git.ts +1 -1
- package/src/sync/history.ts +1 -1
- package/src/sync/reverse.ts +1 -1
- package/src/sync/target.ts +1 -1
- package/src/sync/workflow.ts +1 -1
- package/src/targets/pulls.ts +2 -2
- package/src/targets/sweep.ts +2 -2
- package/src/tasks/reach.ts +1 -1
- package/src/tasks/trunk.ts +1 -1
- package/src/tooling/inject.ts +1 -1
- package/src/tooling/subfolder.ts +1 -1
- package/src/web/readme-citations.ts +1 -1
- package/src/worktrees/bare-flag.ts +1 -1
- package/src/worktrees/reclaim.ts +2 -2
- package/src/worktrees/remove.ts +1 -1
- package/standards/canvas.md +2 -1
- package/standards/skill.md +1 -0
- package/standards/wiki.md +12 -3
- package/tooling/base/configs/.github/workflows/pr-local-preview.yml +2 -2
- package/scripts/lib/sandbox-dispatch.sh +0 -190
- /package/src/{client-commands.ts → gate/client-commands.ts} +0 -0
- /package/src/{git-base.ts → git/base.ts} +0 -0
- /package/src/{git-env.ts → git/env.ts} +0 -0
- /package/src/{copy.ts → sync/copy.ts} +0 -0
- /package/src/{target.ts → targets/validate.ts} +0 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import {
|
|
4
|
+
type ContentRefused,
|
|
5
|
+
canvasDir,
|
|
6
|
+
isValidName,
|
|
7
|
+
readPage,
|
|
8
|
+
TEMP_SUFFIX,
|
|
9
|
+
withFileLock,
|
|
10
|
+
} from '@/canvas/content'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Which frames a session says it is editing, beside `selection.json`. The
|
|
14
|
+
* server cannot see a write until it lands, so the mark is a record a session
|
|
15
|
+
* writes rather than something inferred, and it gates no edit.
|
|
16
|
+
*/
|
|
17
|
+
export const EDITING_FILE = 'editing.json'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* How long a mark lives unrenewed. A session that crashes or compacts never
|
|
21
|
+
* clears its mark, so this is how long a dead one can mislead the operator.
|
|
22
|
+
*/
|
|
23
|
+
export const EDITING_TTL_MS = 5 * 60_000
|
|
24
|
+
|
|
25
|
+
export interface EditingMark {
|
|
26
|
+
readonly page: string
|
|
27
|
+
readonly frame: string
|
|
28
|
+
readonly by: string
|
|
29
|
+
readonly since: string
|
|
30
|
+
readonly until: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type Stored = Omit<EditingMark, 'page' | 'frame'>
|
|
34
|
+
|
|
35
|
+
export type MarkOutcome =
|
|
36
|
+
| { readonly ok: true; readonly mark: EditingMark }
|
|
37
|
+
| ContentRefused
|
|
38
|
+
|
|
39
|
+
export type ClearOutcome =
|
|
40
|
+
| { readonly ok: true; readonly cleared: boolean }
|
|
41
|
+
| ContentRefused
|
|
42
|
+
|
|
43
|
+
function recordPath(root: string): string {
|
|
44
|
+
return join(canvasDir(root), EDITING_FILE)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isStored(value: unknown): value is Stored {
|
|
48
|
+
if (typeof value !== 'object' || value === null) return false
|
|
49
|
+
const { by, since, until } = value as Record<string, unknown>
|
|
50
|
+
return (
|
|
51
|
+
typeof by === 'string' &&
|
|
52
|
+
typeof since === 'string' &&
|
|
53
|
+
typeof until === 'string' &&
|
|
54
|
+
!Number.isNaN(Date.parse(until))
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Every well-formed entry, with a malformed file read as holding none. */
|
|
59
|
+
function readStored(root: string): Map<string, Stored> {
|
|
60
|
+
const entries = new Map<string, Stored>()
|
|
61
|
+
let parsed: unknown
|
|
62
|
+
try {
|
|
63
|
+
parsed = JSON.parse(readFileSync(recordPath(root), 'utf8'))
|
|
64
|
+
} catch {
|
|
65
|
+
return entries
|
|
66
|
+
}
|
|
67
|
+
if (typeof parsed !== 'object' || parsed === null) return entries
|
|
68
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
69
|
+
if (isStored(value)) entries.set(key, value)
|
|
70
|
+
}
|
|
71
|
+
return entries
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function toMark(key: string, stored: Stored): EditingMark | undefined {
|
|
75
|
+
const [page, frame, ...rest] = key.split('/')
|
|
76
|
+
if (!page || !frame || rest.length > 0) return undefined
|
|
77
|
+
return { page, frame, ...stored }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A mark still inside its expiry, on a frame still on disk. */
|
|
81
|
+
function isLive(root: string, mark: EditingMark, now: Date): boolean {
|
|
82
|
+
if (Date.parse(mark.until) <= now.getTime()) return false
|
|
83
|
+
return (
|
|
84
|
+
readPage(root, mark.page)?.frames.some(
|
|
85
|
+
(candidate) => candidate.name === mark.frame,
|
|
86
|
+
) ?? false
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function liveMarks(
|
|
91
|
+
root: string,
|
|
92
|
+
entries: ReadonlyMap<string, Stored>,
|
|
93
|
+
now: Date,
|
|
94
|
+
): EditingMark[] {
|
|
95
|
+
return [...entries]
|
|
96
|
+
.flatMap(([key, stored]) => toMark(key, stored) ?? [])
|
|
97
|
+
.filter((mark) => isLive(root, mark, now))
|
|
98
|
+
.sort((a, b) =>
|
|
99
|
+
`${a.page}/${a.frame}`.localeCompare(`${b.page}/${b.frame}`),
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Replaces the record through a rename, keeping only live marks, so the file
|
|
105
|
+
* never grows with marks nobody cleared. The caller holds the lock, which is
|
|
106
|
+
* what keeps two sessions marking at once from dropping each other's key.
|
|
107
|
+
*/
|
|
108
|
+
function writeMarks(root: string, marks: readonly EditingMark[]): void {
|
|
109
|
+
const record = Object.fromEntries(
|
|
110
|
+
marks.map(({ page, frame, ...stored }) => [`${page}/${frame}`, stored]),
|
|
111
|
+
)
|
|
112
|
+
const target = recordPath(root)
|
|
113
|
+
const temp = `${target}.${process.pid}${TEMP_SUFFIX}`
|
|
114
|
+
writeFileSync(temp, `${JSON.stringify(record, null, 2)}\n`)
|
|
115
|
+
renameSync(temp, target)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function markEditing(
|
|
119
|
+
root: string,
|
|
120
|
+
page: string,
|
|
121
|
+
frame: string,
|
|
122
|
+
by: string,
|
|
123
|
+
now: Date,
|
|
124
|
+
): MarkOutcome {
|
|
125
|
+
if (!isValidName(page) || !isValidName(frame)) {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
reason: 'invalid-name',
|
|
129
|
+
detail: `${page}/${frame} is not a valid frame`,
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const onPage = readPage(root, page)
|
|
133
|
+
if (!onPage) {
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
reason: 'no-page',
|
|
137
|
+
detail: `page ${page} does not exist`,
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (!onPage.frames.some((candidate) => candidate.name === frame)) {
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
reason: 'no-frame',
|
|
144
|
+
detail: `frame ${frame} does not exist on ${page}`,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const mark: EditingMark = {
|
|
149
|
+
page,
|
|
150
|
+
frame,
|
|
151
|
+
by,
|
|
152
|
+
since: now.toISOString(),
|
|
153
|
+
until: new Date(now.getTime() + EDITING_TTL_MS).toISOString(),
|
|
154
|
+
}
|
|
155
|
+
return withFileLock(recordPath(root), (): MarkOutcome => {
|
|
156
|
+
const entries = readStored(root)
|
|
157
|
+
entries.set(`${page}/${frame}`, {
|
|
158
|
+
by,
|
|
159
|
+
since: mark.since,
|
|
160
|
+
until: mark.until,
|
|
161
|
+
})
|
|
162
|
+
writeMarks(root, liveMarks(root, entries, now))
|
|
163
|
+
return { ok: true, mark }
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Drops one frame's mark. Clearing a frame that holds none succeeds and writes
|
|
169
|
+
* nothing, so a session clearing defensively never fails, and a frame removed
|
|
170
|
+
* while marked still clears.
|
|
171
|
+
*/
|
|
172
|
+
export function clearEditing(
|
|
173
|
+
root: string,
|
|
174
|
+
page: string,
|
|
175
|
+
frame: string,
|
|
176
|
+
): ClearOutcome {
|
|
177
|
+
if (!isValidName(page) || !isValidName(frame)) {
|
|
178
|
+
return {
|
|
179
|
+
ok: false,
|
|
180
|
+
reason: 'invalid-name',
|
|
181
|
+
detail: `${page}/${frame} is not a valid frame`,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (!existsSync(recordPath(root))) return { ok: true, cleared: false }
|
|
185
|
+
|
|
186
|
+
return withFileLock(recordPath(root), (): ClearOutcome => {
|
|
187
|
+
const entries = readStored(root)
|
|
188
|
+
if (!entries.delete(`${page}/${frame}`)) return { ok: true, cleared: false }
|
|
189
|
+
writeMarks(
|
|
190
|
+
root,
|
|
191
|
+
[...entries].flatMap(([key, stored]) => toMark(key, stored) ?? []),
|
|
192
|
+
)
|
|
193
|
+
return { ok: true, cleared: true }
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The live marks, sorted by frame. A mark past its expiry or on a frame since
|
|
199
|
+
* removed reads as absent with no write, so a reader never gets a dead name.
|
|
200
|
+
*/
|
|
201
|
+
export function readEditing(root: string, now: Date): EditingMark[] {
|
|
202
|
+
return liveMarks(root, readStored(root), now)
|
|
203
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The screen color sampler, which only Chromium ships. TypeScript's DOM
|
|
3
|
+
* library carries no type for it, so a window without it reads as undefined.
|
|
4
|
+
*/
|
|
5
|
+
interface EyeDropperResult {
|
|
6
|
+
readonly sRGBHex: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface EyeDropperSampler {
|
|
10
|
+
open(options?: { readonly signal?: AbortSignal }): Promise<EyeDropperResult>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface Window {
|
|
14
|
+
readonly EyeDropper?: new () => EyeDropperSampler
|
|
15
|
+
}
|
package/src/canvas/server.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { type FSWatcher, mkdirSync, watch } from 'node:fs'
|
|
2
2
|
import {
|
|
3
|
+
countElements,
|
|
3
4
|
type ElementAddress,
|
|
4
5
|
HASH_ATTRIBUTE,
|
|
6
|
+
IMPLIED_ATTRIBUTE,
|
|
5
7
|
TOKENS_ATTRIBUTE,
|
|
6
8
|
} from '@/canvas/address'
|
|
7
9
|
import {
|
|
@@ -12,10 +14,12 @@ import {
|
|
|
12
14
|
LOCK_SUFFIX,
|
|
13
15
|
moveFrame,
|
|
14
16
|
readSelection,
|
|
17
|
+
resizeFrame,
|
|
15
18
|
TEMP_SUFFIX,
|
|
16
19
|
writeSelection,
|
|
17
20
|
} from '@/canvas/content'
|
|
18
21
|
import { type EditRefused, editFrame } from '@/canvas/edit'
|
|
22
|
+
import { readEditing } from '@/canvas/editing'
|
|
19
23
|
import {
|
|
20
24
|
resolveFrameTokens,
|
|
21
25
|
type TokenOptions,
|
|
@@ -90,24 +94,125 @@ function styleElement(css: string, hash: string | undefined): string {
|
|
|
90
94
|
return `<style ${TOKENS_ATTRIBUTE}${stamp}>${css.replaceAll('</style', '<\\/style')}</style>`
|
|
91
95
|
}
|
|
92
96
|
|
|
97
|
+
/** The wrappers a browser builds whether or not the file states them. */
|
|
98
|
+
const WRAPPERS = new Set(['html', 'head', 'body'])
|
|
99
|
+
|
|
100
|
+
/** Which of the document wrappers the file states, counted as the address counts. */
|
|
101
|
+
function statedWrappers(html: string): Set<string> {
|
|
102
|
+
const stated = new Set<string>()
|
|
103
|
+
countElements(new HTMLRewriter(), (element) => {
|
|
104
|
+
const tag = element.tagName.toLowerCase()
|
|
105
|
+
if (WRAPPERS.has(tag)) stated.add(tag)
|
|
106
|
+
return undefined
|
|
107
|
+
}).transform(html)
|
|
108
|
+
return stated
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Prepends markup inside the first element a selector matches. */
|
|
112
|
+
function prependFirst(html: string, selector: string, markup: string): string {
|
|
113
|
+
let isDone = false
|
|
114
|
+
return new HTMLRewriter()
|
|
115
|
+
.on(selector, {
|
|
116
|
+
element(element) {
|
|
117
|
+
if (isDone) return
|
|
118
|
+
isDone = true
|
|
119
|
+
element.prepend(markup, { html: true })
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
.transform(html)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* What a browser parses into the head it builds ahead of a fragment's first
|
|
127
|
+
* content. A comment or whitespace among them stays in that head too.
|
|
128
|
+
*/
|
|
129
|
+
const HEAD_CONTENT = new Set([
|
|
130
|
+
'base',
|
|
131
|
+
'link',
|
|
132
|
+
'meta',
|
|
133
|
+
'noscript',
|
|
134
|
+
'script',
|
|
135
|
+
'style',
|
|
136
|
+
'template',
|
|
137
|
+
'title',
|
|
138
|
+
])
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Opens a marked body where the browser would build its own: ahead of the
|
|
142
|
+
* first element or text outside the head content a file leads with, or at the
|
|
143
|
+
* end when the file holds nothing else.
|
|
144
|
+
*/
|
|
145
|
+
function openBodyBeforeContent(html: string, marker: string): string {
|
|
146
|
+
let isOpen = false
|
|
147
|
+
let headDepth = 0
|
|
148
|
+
const open = (target: HTMLRewriterTypes.Element | HTMLRewriterTypes.Text) => {
|
|
149
|
+
isOpen = true
|
|
150
|
+
target.before(marker, { html: true })
|
|
151
|
+
}
|
|
152
|
+
return new HTMLRewriter()
|
|
153
|
+
.on('*', {
|
|
154
|
+
element(element) {
|
|
155
|
+
if (isOpen || headDepth > 0) return
|
|
156
|
+
const tag = element.tagName.toLowerCase()
|
|
157
|
+
if (tag === 'html') return
|
|
158
|
+
if (!HEAD_CONTENT.has(tag)) {
|
|
159
|
+
open(element)
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
if (!element.canHaveContent || element.selfClosing) return
|
|
163
|
+
headDepth += 1
|
|
164
|
+
element.onEndTag(() => {
|
|
165
|
+
headDepth -= 1
|
|
166
|
+
})
|
|
167
|
+
},
|
|
168
|
+
})
|
|
169
|
+
.onDocument({
|
|
170
|
+
text(chunk) {
|
|
171
|
+
if (!isOpen && headDepth === 0 && chunk.text.trim() !== '') open(chunk)
|
|
172
|
+
},
|
|
173
|
+
end(end) {
|
|
174
|
+
if (!isOpen) end.append(marker, { html: true })
|
|
175
|
+
},
|
|
176
|
+
})
|
|
177
|
+
.transform(html)
|
|
178
|
+
}
|
|
179
|
+
|
|
93
180
|
/**
|
|
94
181
|
* Puts the tokens first in the head, so a stylesheet the frame links itself
|
|
95
|
-
* still wins the cascade. A
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
182
|
+
* still wins the cascade. A hash stamps the element even when no tokens
|
|
183
|
+
* resolve, since the shell sends it back with an element pick.
|
|
184
|
+
*
|
|
185
|
+
* A wrapper the file leaves out is written in marked, since the browser would
|
|
186
|
+
* build it anyway and the shell's count would run past the file's. An `html`
|
|
187
|
+
* goes right after the doctype, which keeps the frame out of quirks mode. A
|
|
188
|
+
* marked `head` is left open, so the head content a fragment leads with parses
|
|
189
|
+
* into it the way it would into the head a browser builds, and a file stating
|
|
190
|
+
* neither `head` nor `body` gets a marked body opened where that content ends.
|
|
191
|
+
* A file stating a `head` but no `body` is left to the browser.
|
|
99
192
|
*/
|
|
100
193
|
export function injectTokens(html: string, css: string, hash?: string): string {
|
|
101
194
|
if (css === '' && hash === undefined) return html
|
|
102
|
-
const
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
195
|
+
const style = styleElement(css, hash)
|
|
196
|
+
const stated = statedWrappers(html)
|
|
197
|
+
const isHeadStated = stated.has('head')
|
|
198
|
+
const withBody =
|
|
199
|
+
isHeadStated || stated.has('body')
|
|
200
|
+
? html
|
|
201
|
+
: openBodyBeforeContent(html, `<body ${IMPLIED_ATTRIBUTE}>`)
|
|
202
|
+
if (isHeadStated) {
|
|
203
|
+
const withHead = prependFirst(withBody, 'head', style)
|
|
204
|
+
return stated.has('html') ? withHead : markedHtml(withHead, '')
|
|
108
205
|
}
|
|
109
|
-
const
|
|
110
|
-
return
|
|
206
|
+
const head = `<head ${IMPLIED_ATTRIBUTE}>${style}`
|
|
207
|
+
return stated.has('html')
|
|
208
|
+
? prependFirst(withBody, 'html', head)
|
|
209
|
+
: markedHtml(withBody, head)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Writes a marked `html` and what follows it right after the doctype. */
|
|
213
|
+
function markedHtml(html: string, lead: string): string {
|
|
214
|
+
const doctype = html.match(/^\s*<!doctype[^>]*>/i)?.[0] ?? ''
|
|
215
|
+
return `${doctype}<html ${IMPLIED_ATTRIBUTE}>${lead}${html.slice(doctype.length)}`
|
|
111
216
|
}
|
|
112
217
|
|
|
113
218
|
/**
|
|
@@ -348,6 +453,11 @@ export function startCanvas(
|
|
|
348
453
|
hostname: SERVE_HOST,
|
|
349
454
|
port,
|
|
350
455
|
development: false,
|
|
456
|
+
/*
|
|
457
|
+
* Production mode turns port reuse on, so a second canvas would bind
|
|
458
|
+
* beside one already listening rather than walk past it.
|
|
459
|
+
*/
|
|
460
|
+
reusePort: false,
|
|
351
461
|
/* The change stream stays open for as long as the shell does. */
|
|
352
462
|
idleTimeout: 0,
|
|
353
463
|
routes: {
|
|
@@ -374,6 +484,9 @@ export function startCanvas(
|
|
|
374
484
|
},
|
|
375
485
|
}),
|
|
376
486
|
},
|
|
487
|
+
editing: readEditing(root, new Date()).map(
|
|
488
|
+
({ page, frame, by, until }) => ({ page, frame, by, until }),
|
|
489
|
+
),
|
|
377
490
|
})
|
|
378
491
|
}),
|
|
379
492
|
'/api/frames/move': {
|
|
@@ -396,6 +509,30 @@ export function startCanvas(
|
|
|
396
509
|
}),
|
|
397
510
|
),
|
|
398
511
|
},
|
|
512
|
+
'/api/frames/resize': {
|
|
513
|
+
POST: guarded(
|
|
514
|
+
mutation((body) => {
|
|
515
|
+
if (
|
|
516
|
+
!isRecord(body) ||
|
|
517
|
+
typeof body.page !== 'string' ||
|
|
518
|
+
typeof body.frame !== 'string' ||
|
|
519
|
+
typeof body.x !== 'number' ||
|
|
520
|
+
typeof body.y !== 'number' ||
|
|
521
|
+
typeof body.width !== 'number' ||
|
|
522
|
+
typeof body.height !== 'number'
|
|
523
|
+
) {
|
|
524
|
+
return badBody('send page, frame, x, y, width, and height')
|
|
525
|
+
}
|
|
526
|
+
const outcome = resizeFrame(root, body.page, body.frame, {
|
|
527
|
+
x: body.x,
|
|
528
|
+
y: body.y,
|
|
529
|
+
width: body.width,
|
|
530
|
+
height: body.height,
|
|
531
|
+
})
|
|
532
|
+
return outcome.ok ? json(outcome) : refusal(outcome)
|
|
533
|
+
}),
|
|
534
|
+
),
|
|
535
|
+
},
|
|
399
536
|
'/api/frames/edit': {
|
|
400
537
|
POST: guarded(
|
|
401
538
|
mutation((body) => {
|
package/src/canvas/tokens.ts
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
|
-
import { existsSync,
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { DESIGN_INSTALL_DIR, DESIGN_PROJECT_SUBDIR } from '@/design/adapter'
|
|
4
|
-
import { buildDesignCss } from '@/design/css'
|
|
4
|
+
import { buildDesignCss, fontFaceBlock } from '@/design/css'
|
|
5
|
+
import {
|
|
6
|
+
FONT_FACES,
|
|
7
|
+
HAND_DRAWN_FONT_FACES,
|
|
8
|
+
type FontFace,
|
|
9
|
+
} from '@/design/fonts'
|
|
10
|
+
import {
|
|
11
|
+
type DroppedFace,
|
|
12
|
+
inlineProjectFaces,
|
|
13
|
+
namedFamilies,
|
|
14
|
+
projectSheets,
|
|
15
|
+
} from '@/design/project-fonts'
|
|
5
16
|
import { isOwnCheckout } from '@/project-root'
|
|
6
17
|
|
|
7
18
|
/**
|
|
@@ -17,6 +28,8 @@ export type FrameTokens =
|
|
|
17
28
|
readonly css: string
|
|
18
29
|
/** Relative to the root, in the order the stylesheet concatenates them. */
|
|
19
30
|
readonly files: readonly string[]
|
|
31
|
+
/** Project faces left out of the sheet, present only when one was. */
|
|
32
|
+
readonly dropped?: readonly DroppedFace[]
|
|
20
33
|
}
|
|
21
34
|
| { readonly source: 'none'; readonly css: ''; readonly notice: string }
|
|
22
35
|
|
|
@@ -113,19 +126,44 @@ export function tokenGroups(css: string): TokenGroup[] {
|
|
|
113
126
|
|
|
114
127
|
/** The installed files in cascade order: the base first, then each override. */
|
|
115
128
|
function installedFiles(root: string): string[] {
|
|
116
|
-
const files: string[] = []
|
|
117
129
|
const base = join(DESIGN_INSTALL_DIR, BASE_FILE)
|
|
118
|
-
|
|
130
|
+
return [
|
|
131
|
+
...(existsSync(join(root, base)) ? [base] : []),
|
|
132
|
+
...projectSheets(root),
|
|
133
|
+
]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The vendored faces the installed sheets name and no project rule already
|
|
138
|
+
* declares, so a target renders the base's families on a machine that never
|
|
139
|
+
* installed them, and a project face of the same family wins outright.
|
|
140
|
+
*/
|
|
141
|
+
function toolkitFaces(css: string, declared: ReadonlySet<string>): FontFace[] {
|
|
142
|
+
const named = namedFamilies(css)
|
|
143
|
+
return [...FONT_FACES, ...HAND_DRAWN_FONT_FACES].filter(
|
|
144
|
+
(face) => named.has(face.family) && !declared.has(face.family),
|
|
145
|
+
)
|
|
146
|
+
}
|
|
119
147
|
|
|
148
|
+
/**
|
|
149
|
+
* A project sheet with its local `url()`s inlined, since a frame is served
|
|
150
|
+
* from the content folder and cannot reach a path under `.claude/design/`.
|
|
151
|
+
*/
|
|
152
|
+
function installedSheets(root: string, files: readonly string[]) {
|
|
120
153
|
const projectDir = join(DESIGN_INSTALL_DIR, DESIGN_PROJECT_SUBDIR)
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
154
|
+
const dropped: DroppedFace[] = []
|
|
155
|
+
const declared = new Set<string>()
|
|
156
|
+
const css = files
|
|
157
|
+
.map((file) => {
|
|
158
|
+
const raw = readFileSync(join(root, file), 'utf8')
|
|
159
|
+
if (!file.startsWith(projectDir)) return `/* ${file} */\n${raw}`
|
|
160
|
+
const inlined = inlineProjectFaces(root, file, raw)
|
|
161
|
+
dropped.push(...inlined.dropped)
|
|
162
|
+
for (const family of inlined.declared) declared.add(family)
|
|
163
|
+
return `/* ${file} */\n${inlined.css}`
|
|
164
|
+
})
|
|
165
|
+
.join('\n')
|
|
166
|
+
return { css, dropped, declared }
|
|
129
167
|
}
|
|
130
168
|
|
|
131
169
|
/**
|
|
@@ -141,7 +179,7 @@ export function resolveFrameTokens(
|
|
|
141
179
|
/*
|
|
142
180
|
* Faces embedded, since the type tokens name `Geist Variable` and a
|
|
143
181
|
* machine with only static Geist installed renders every frame in the
|
|
144
|
-
* fallback.
|
|
182
|
+
* fallback.
|
|
145
183
|
*/
|
|
146
184
|
return {
|
|
147
185
|
source: 'toolkit',
|
|
@@ -151,10 +189,14 @@ export function resolveFrameTokens(
|
|
|
151
189
|
|
|
152
190
|
const files = installedFiles(root)
|
|
153
191
|
if (files.length > 0) {
|
|
154
|
-
const css = files
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
192
|
+
const { css, dropped, declared } = installedSheets(root, files)
|
|
193
|
+
const faces = toolkitFaces(css, declared)
|
|
194
|
+
return {
|
|
195
|
+
source: 'installed',
|
|
196
|
+
css: faces.length > 0 ? `${fontFaceBlock(faces)}\n${css}` : css,
|
|
197
|
+
files,
|
|
198
|
+
...(dropped.length > 0 && { dropped }),
|
|
199
|
+
}
|
|
158
200
|
}
|
|
159
201
|
|
|
160
202
|
return { source: 'none', css: '', notice: NONE_NOTICE }
|
package/src/census/count.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises'
|
|
2
2
|
import { extname, join } from 'node:path'
|
|
3
3
|
import { isBinary } from '@/sweep/binary'
|
|
4
|
-
import { listRepositoryFiles } from '@/git
|
|
4
|
+
import { listRepositoryFiles } from '@/git/files'
|
|
5
5
|
|
|
6
6
|
export type CensusRefusal = 'no-git'
|
|
7
7
|
|
package/src/claude/seeds.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { dirname, join, relative } from 'node:path'
|
|
4
|
-
import { copyPreservingMode } from '@/copy'
|
|
4
|
+
import { copyPreservingMode } from '@/sync/copy'
|
|
5
5
|
import { creationRel, isRecordEntry } from '@/record-root'
|
|
6
6
|
import { rewritesOnInstall, stripSeedMarker } from '@/tooling/seed-marker'
|
|
7
7
|
import { SURFACE_ENTRIES, surfaceDir } from '@/surface-root'
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { dirname, join } from 'node:path'
|
|
3
|
+
import { isFamilyKey } from '@/claude/skills-families'
|
|
3
4
|
import { parseFrontmatter, readField } from '@/indexes/frontmatter'
|
|
4
5
|
import { bodyLines, maskDisplayed } from '@/markdown/scan'
|
|
5
6
|
|
|
@@ -128,6 +129,7 @@ export interface SkillsAudit {
|
|
|
128
129
|
readonly datedProvenance: readonly SkillFinding[]
|
|
129
130
|
readonly practiceShape: readonly SkillFinding[]
|
|
130
131
|
readonly referenceContents: readonly SkillFinding[]
|
|
132
|
+
readonly family: readonly SkillFinding[]
|
|
131
133
|
}
|
|
132
134
|
|
|
133
135
|
interface SkillSource {
|
|
@@ -136,6 +138,8 @@ interface SkillSource {
|
|
|
136
138
|
readonly hasReadme: boolean
|
|
137
139
|
readonly name: string | undefined
|
|
138
140
|
readonly description: string | undefined
|
|
141
|
+
/** `metadata.family`, undefined when absent, blank, or not a string. */
|
|
142
|
+
readonly family: string | undefined
|
|
139
143
|
/** Undefined when the folder carries no `REQUIREMENT.md` at all. */
|
|
140
144
|
readonly requirementHeadings: readonly string[] | undefined
|
|
141
145
|
/** Every H2 in `SKILL.md` outside a fence, for the practice shape. */
|
|
@@ -207,6 +211,7 @@ export async function auditSkills(root: string): Promise<SkillsAudit> {
|
|
|
207
211
|
referenceContents: sources.flatMap((source) =>
|
|
208
212
|
source.bodies.flatMap(contentsFindings),
|
|
209
213
|
),
|
|
214
|
+
family: sources.flatMap(familyFindings),
|
|
210
215
|
}
|
|
211
216
|
}
|
|
212
217
|
|
|
@@ -261,6 +266,7 @@ async function readSkill(
|
|
|
261
266
|
hasReadme: existsSync(join(skillDir, 'README.md')),
|
|
262
267
|
name: declared(readField(fields, 'name')),
|
|
263
268
|
description: declared(readField(fields, 'description')),
|
|
269
|
+
family: declaredFamily(fields?.fields.metadata),
|
|
264
270
|
requirementHeadings:
|
|
265
271
|
requirement === undefined ? undefined : headings(requirement),
|
|
266
272
|
sections: sectionsOf(body),
|
|
@@ -285,6 +291,13 @@ function declared(value: string | undefined): string | undefined {
|
|
|
285
291
|
return trimmed === undefined || trimmed === '' ? undefined : trimmed
|
|
286
292
|
}
|
|
287
293
|
|
|
294
|
+
function declaredFamily(metadata: unknown): string | undefined {
|
|
295
|
+
if (typeof metadata !== 'object' || metadata === null) return undefined
|
|
296
|
+
if (Array.isArray(metadata)) return undefined
|
|
297
|
+
const { family } = metadata as Record<string, unknown>
|
|
298
|
+
return typeof family === 'string' ? declared(family) : undefined
|
|
299
|
+
}
|
|
300
|
+
|
|
288
301
|
/** Every H2 outside a fence, the one level both exact-section checks read. */
|
|
289
302
|
function sectionsOf(source: string): string[] {
|
|
290
303
|
return bodyLines(source)
|
|
@@ -375,6 +388,21 @@ function practiceFindings(source: SkillSource): SkillFinding[] {
|
|
|
375
388
|
return [...sections, ...ledger]
|
|
376
389
|
}
|
|
377
390
|
|
|
391
|
+
/**
|
|
392
|
+
* Asks only the shipped corpus for a family, since the family names the skill
|
|
393
|
+
* map group a skill's row sits under and an internal skill takes no row.
|
|
394
|
+
*/
|
|
395
|
+
function familyFindings(source: SkillSource): SkillFinding[] {
|
|
396
|
+
if (dirname(source.rel) !== SHIPPED_CORPUS) return []
|
|
397
|
+
if (source.family === undefined) {
|
|
398
|
+
return [
|
|
399
|
+
{ rel: source.rel, detail: 'frontmatter declares no metadata.family' },
|
|
400
|
+
]
|
|
401
|
+
}
|
|
402
|
+
if (isFamilyKey(source.family)) return []
|
|
403
|
+
return [{ rel: source.rel, detail: `unknown family: ${source.family}` }]
|
|
404
|
+
}
|
|
405
|
+
|
|
378
406
|
/**
|
|
379
407
|
* Reads references alone, since `SKILL.md` carries its own length checkpoint
|
|
380
408
|
* and no contents rule. Frontmatter counts toward the length, so the count a
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { execaSync } from 'execa'
|
|
4
|
-
import { gitEnv } from '@/git
|
|
4
|
+
import { gitEnv } from '@/git/env'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* The shipped corpus alone. A session loads a body from the plugin root, which
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface SkillFamily {
|
|
2
|
+
/** The value a shipped `SKILL.md` declares as `metadata.family`. */
|
|
3
|
+
readonly key: string
|
|
4
|
+
/** The skill map heading the family's rows sit under, verbatim. */
|
|
5
|
+
readonly group: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The one vocabulary a shipped skill's family is drawn from, in the order the
|
|
10
|
+
* skill map reads across its two pages. A heading is the map's prose and a key
|
|
11
|
+
* is what frontmatter carries, so renaming a heading touches this constant and
|
|
12
|
+
* no skill.
|
|
13
|
+
*
|
|
14
|
+
* Import-free on purpose: `skills-list.ts` and `skills-audit.ts` both read it,
|
|
15
|
+
* and the first already imports the second.
|
|
16
|
+
*/
|
|
17
|
+
export const SKILL_FAMILIES: readonly SkillFamily[] = [
|
|
18
|
+
{ key: 'setup', group: 'Set up a project' },
|
|
19
|
+
{ key: 'decide', group: 'Decide what to build' },
|
|
20
|
+
{ key: 'build', group: 'Build the feature' },
|
|
21
|
+
{ key: 'check', group: 'Check the work before it leaves the branch' },
|
|
22
|
+
{ key: 'ship', group: 'Ship it' },
|
|
23
|
+
{ key: 'after-pr', group: 'After the pull request opens' },
|
|
24
|
+
{ key: 'parallel', group: 'Run several tracks at once' },
|
|
25
|
+
{ key: 'upkeep', group: 'Keep the project current with the toolkit' },
|
|
26
|
+
{ key: 'generate', group: 'Generate an artifact on demand' },
|
|
27
|
+
{ key: 'answer', group: 'Answer a question at any point' },
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
export function familyKeys(): string[] {
|
|
31
|
+
return SKILL_FAMILIES.map((family) => family.key)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isFamilyKey(value: string): boolean {
|
|
35
|
+
return SKILL_FAMILIES.some((family) => family.key === value)
|
|
36
|
+
}
|