@erclx/canon 4.112.0 → 4.113.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/canon-feedback/SKILL.md +3 -3
- package/docs/agents/teach.md +2 -0
- package/package.json +3 -1
- package/src/claude/skills-audit.ts +2 -19
- package/src/commands/feedback-format.ts +24 -0
- package/src/commands/feedback.ts +2 -1
- package/src/commands/teach.ts +41 -1
- package/src/indexes/frontmatter.ts +10 -1
- package/src/pr/evidence.ts +3 -1
- package/src/teach/workspace.ts +14 -6
- package/tooling/base/seeds/cspell.json +1 -1
- package/tooling/claude/seeds/.claude/hooks/silent-turn.sh +7 -8
- package/tooling/nextjs/configs/vitest.config.ts +1 -1
- package/tooling/python/manifest.toml +1 -1
- package/tooling/python/reference.md +3 -4
- package/tooling/vite-react/configs/vite.config.ts +1 -1
|
@@ -5,7 +5,7 @@ description: Format a report about something broken, missing, or off in canon an
|
|
|
5
5
|
|
|
6
6
|
# Canon feedback
|
|
7
7
|
|
|
8
|
-
Format a `## Toolkit feedback` block from the current session, then open it as a GitHub issue labeled `feedback`
|
|
8
|
+
Format a `## Toolkit feedback` block from the current session, then open it as a GitHub issue on the toolkit repo, labeled `feedback` plus the domain label its surface type names. `canon-feedback-triage` reads that queue.
|
|
9
9
|
|
|
10
10
|
## Guards
|
|
11
11
|
|
|
@@ -18,7 +18,7 @@ Format a `## Toolkit feedback` block from the current session, then open it as a
|
|
|
18
18
|
From the conversation so far, identify:
|
|
19
19
|
|
|
20
20
|
- Target project name or kind, never its full path. The path names a folder on one machine and says nothing a triage session can route on, where the project's own name does. Name a private project by its kind instead, such as `a Next.js app`, since the report leaves the machine.
|
|
21
|
-
- Toolkit surface and its type (plugin skill, snippet, tooling config, governance rule, seed, or CLI)
|
|
21
|
+
- Toolkit surface and its type (plugin skill, snippet, tooling config, governance rule, seed, or CLI). The type leads the `### Surface` field, ahead of the first comma, because the CLI picks the issue's domain labels from that text alone.
|
|
22
22
|
- Specific toolkit file or name when the session cites one
|
|
23
23
|
- Observed behavior
|
|
24
24
|
- Expected behavior, or `unclear`
|
|
@@ -81,7 +81,7 @@ cat <<'EOF' | canon feedback --github
|
|
|
81
81
|
EOF
|
|
82
82
|
```
|
|
83
83
|
|
|
84
|
-
The CLI opens a GitHub issue labeled `feedback`
|
|
84
|
+
The CLI opens a GitHub issue on the toolkit repo, labeled `feedback` and each domain label the surface type names, such as `skills` for a plugin skill, and prints the issue URL, which this skill reports back on its own line. It needs `gh` authenticated. `git-issue` is not the route, since it files on the current repository and this report belongs on the toolkit's.
|
|
85
85
|
|
|
86
86
|
From a toolkit checkout, a call that produces no URL falls back to `.canon/feedback/feedback-<slug>-<ts>.md` and prints the reason on stderr. Say so in the same reply, naming the file and the reason, since a silent fallback leaves the report where nothing reads it. A target project has no such folder, so a failed call there prints the block in chat.
|
|
87
87
|
|
package/docs/agents/teach.md
CHANGED
|
@@ -198,6 +198,8 @@ No `canon teach` verb serves the workspace. `canon serve` does, taking the teach
|
|
|
198
198
|
canon serve .canon/teach --entry 03-fde-system-design/index.html --json
|
|
199
199
|
```
|
|
200
200
|
|
|
201
|
+
`canon teach list <topic>` ends its human output with this line for that workspace, with the teach folder written relative to the cwd, or absolute when it sits outside it, since `canon serve` resolves its directory against the cwd while the list verb reads the main worktree root. The JSON record carries no such field.
|
|
202
|
+
|
|
201
203
|
It stays general rather than becoming `canon teach serve`, because nothing about serving a directory is specific to a learning workspace, and the same verb carries a slide render and a design preview.
|
|
202
204
|
|
|
203
205
|
Read `url` off the record rather than building one from the port that was asked for. The verb walks forward past a port already in use, which is routine when a second workspace is already open, and the port it took is the one thing a composed URL gets wrong.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@erclx/canon",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "4.
|
|
4
|
+
"version": "4.113.0",
|
|
5
5
|
"description": "Infrastructure and quality tooling for developer workflows",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
@@ -49,6 +49,8 @@
|
|
|
49
49
|
"web:previews": "bun scripts/core/regen-web-previews.ts",
|
|
50
50
|
"web:build": "bun run web:tokens && bun run web:favicon && bun run web:previews && cd web && astro check && astro build",
|
|
51
51
|
"web:gallery": "cd web && astro build --config gallery.config.mjs",
|
|
52
|
+
"web:card": "cd web && astro dev --config card.config.mjs",
|
|
53
|
+
"web:card:check": "PROJECT_ROOT=web bash tooling/astro/configs/scripts/check-card-exclusion.sh",
|
|
52
54
|
"web:preview": "cd web && astro preview",
|
|
53
55
|
"web:e2e": "cd web && playwright test"
|
|
54
56
|
},
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { dirname, join } from 'node:path'
|
|
3
|
-
import {
|
|
4
|
-
type Frontmatter,
|
|
5
|
-
parseFrontmatter,
|
|
6
|
-
readField,
|
|
7
|
-
} from '@/indexes/frontmatter'
|
|
3
|
+
import { parseFrontmatter, readField } from '@/indexes/frontmatter'
|
|
8
4
|
|
|
9
5
|
/** Returned when a skill folder carries no `REQUIREMENT.md`, the gating check. */
|
|
10
6
|
export const EXIT_MISSING_REQUIREMENT = 2
|
|
@@ -149,7 +145,7 @@ async function readSkill(
|
|
|
149
145
|
: Promise.resolve(undefined),
|
|
150
146
|
])
|
|
151
147
|
|
|
152
|
-
const fields =
|
|
148
|
+
const fields = parseFrontmatter(body)
|
|
153
149
|
|
|
154
150
|
return {
|
|
155
151
|
rel: join(rel, folder),
|
|
@@ -162,19 +158,6 @@ async function readSkill(
|
|
|
162
158
|
}
|
|
163
159
|
}
|
|
164
160
|
|
|
165
|
-
/**
|
|
166
|
-
* Returns undefined rather than throwing on unparseable YAML, so one malformed
|
|
167
|
-
* body reports as missing its required fields instead of ending the audit
|
|
168
|
-
* before it reaches the rest of the corpus.
|
|
169
|
-
*/
|
|
170
|
-
function readFields(body: string): Frontmatter | undefined {
|
|
171
|
-
try {
|
|
172
|
-
return parseFrontmatter(body)
|
|
173
|
-
} catch {
|
|
174
|
-
return undefined
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
161
|
/**
|
|
179
162
|
* A key carrying an empty value declares nothing, so blank reads as absent
|
|
180
163
|
* rather than as a name that disagrees with every folder.
|
|
@@ -89,6 +89,30 @@ export function deriveSlug(body: string): string {
|
|
|
89
89
|
return slug || SLUG_FALLBACK
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Each label must exist on the toolkit repository, because `gh issue create`
|
|
94
|
+
* refuses the whole issue on an unknown label rather than dropping it.
|
|
95
|
+
*/
|
|
96
|
+
const DOMAIN_LABELS: readonly { pattern: RegExp; label: string }[] = [
|
|
97
|
+
{ pattern: /\bskills?\b/, label: 'skills' },
|
|
98
|
+
{ pattern: /\b(?:cli)s?\b/, label: 'cli' },
|
|
99
|
+
{ pattern: /\b(?:tooling|seeds?)\b/, label: 'tooling' },
|
|
100
|
+
{ pattern: /\b(?:governance)s?\b/, label: 'governance' },
|
|
101
|
+
{ pattern: /\bsnippets?\b/, label: 'snippets' },
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Reads only the surface type, the text before the first comma, so a path or
|
|
106
|
+
* name after it never adds a label.
|
|
107
|
+
*/
|
|
108
|
+
export function deriveDomainLabels(body: string): string[] {
|
|
109
|
+
const type = (surfaceField(body) ?? '').split(',')[0]?.toLowerCase() ?? ''
|
|
110
|
+
const labels = DOMAIN_LABELS.filter(({ pattern }) => pattern.test(type)).map(
|
|
111
|
+
({ label }) => label,
|
|
112
|
+
)
|
|
113
|
+
return [...new Set(labels)]
|
|
114
|
+
}
|
|
115
|
+
|
|
92
116
|
export function deriveTitle(body: string): string {
|
|
93
117
|
const surface = surfaceField(body)
|
|
94
118
|
const title = surface ? `feedback: ${surface}` : 'toolkit feedback'
|
package/src/commands/feedback.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import type { Command } from 'commander'
|
|
4
4
|
import {
|
|
5
|
+
deriveDomainLabels,
|
|
5
6
|
deriveSlug,
|
|
6
7
|
deriveTitle,
|
|
7
8
|
missingField,
|
|
@@ -107,7 +108,7 @@ export function register(program: Command): void {
|
|
|
107
108
|
const result = await createGithubIssue({
|
|
108
109
|
title: deriveTitle(body),
|
|
109
110
|
body,
|
|
110
|
-
labels: ['feedback'],
|
|
111
|
+
labels: ['feedback', ...deriveDomainLabels(body)],
|
|
111
112
|
})
|
|
112
113
|
if (result.ok) {
|
|
113
114
|
frameSuccess('canon feedback', result.url)
|
package/src/commands/teach.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { relative } from 'node:path'
|
|
1
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path'
|
|
2
2
|
import type { Command } from 'commander'
|
|
3
3
|
import { type LessonOutcome, planLesson } from '@/teach/lesson'
|
|
4
4
|
import { type NavOutcome, generateNav } from '@/teach/nav'
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type TeachRefused,
|
|
19
19
|
type Term,
|
|
20
20
|
type TermOutcome,
|
|
21
|
+
teachDir,
|
|
21
22
|
type WorkspaceSummary,
|
|
22
23
|
writeStylesheet,
|
|
23
24
|
} from '@/teach/workspace'
|
|
@@ -91,6 +92,18 @@ export function register(program: Command): void {
|
|
|
91
92
|
.command('teach')
|
|
92
93
|
.description('Manage learning workspaces in .canon/teach/')
|
|
93
94
|
.helpOption('-h, --help', 'Show this help message')
|
|
95
|
+
.addHelpText(
|
|
96
|
+
'after',
|
|
97
|
+
[
|
|
98
|
+
'',
|
|
99
|
+
'Opening a workspace:',
|
|
100
|
+
' canon serve .canon/teach --entry <nn>-<topic>/index.html',
|
|
101
|
+
'',
|
|
102
|
+
'canon teach list <topic> prints the exact line, with the teach folder',
|
|
103
|
+
'written from where you stand rather than as this literal.',
|
|
104
|
+
'',
|
|
105
|
+
].join('\n'),
|
|
106
|
+
)
|
|
94
107
|
|
|
95
108
|
teach
|
|
96
109
|
.command('list')
|
|
@@ -118,6 +131,9 @@ export function register(program: Command): void {
|
|
|
118
131
|
'A workspace not named NN-<topic> is still listed. It sorts last and',
|
|
119
132
|
'moves no ordinal, since dropping it hides the folder needing a fix.',
|
|
120
133
|
'',
|
|
134
|
+
'The human output ends with the canon serve line that opens a',
|
|
135
|
+
'workspace, a template with no topic and the exact line with one.',
|
|
136
|
+
'',
|
|
121
137
|
'Examples:',
|
|
122
138
|
' canon teach list',
|
|
123
139
|
' canon teach list regular-expressions --json',
|
|
@@ -852,6 +868,22 @@ function describe(workspace: WorkspaceSummary): string {
|
|
|
852
868
|
return `${workspace.slug}: ${workspace.lessons} lesson(s), ${workspace.records} record(s), ${workspace.reference} reference page(s), ${workspace.terms} term(s)`
|
|
853
869
|
}
|
|
854
870
|
|
|
871
|
+
/**
|
|
872
|
+
* `canon serve` resolves its folder against the caller's cwd, while the list
|
|
873
|
+
* verb reads the teach folder at the main worktree root, so the line carries
|
|
874
|
+
* the folder actually read. A literal `.canon/teach` serves an absent folder
|
|
875
|
+
* from a linked worktree.
|
|
876
|
+
*/
|
|
877
|
+
function serveLine(root: string, entry: string): string {
|
|
878
|
+
const dir = resolve(teachDir(root))
|
|
879
|
+
const fromCwd = relative(process.cwd(), dir)
|
|
880
|
+
const isOutsideCwd =
|
|
881
|
+
fromCwd === '..' || fromCwd.startsWith(`..${sep}`) || isAbsolute(fromCwd)
|
|
882
|
+
const printed = fromCwd === '' ? '.' : isOutsideCwd ? dir : fromCwd
|
|
883
|
+
|
|
884
|
+
return `canon serve ${printed} --entry ${entry}`
|
|
885
|
+
}
|
|
886
|
+
|
|
855
887
|
function reportList(
|
|
856
888
|
outcome: ListOutcome,
|
|
857
889
|
emitJson: boolean,
|
|
@@ -890,6 +922,12 @@ function reportList(
|
|
|
890
922
|
|
|
891
923
|
logStep('Next ordinal')
|
|
892
924
|
logInfo(outcome.next)
|
|
925
|
+
|
|
926
|
+
if (outcome.workspaces.length > 0) {
|
|
927
|
+
logStep('Open')
|
|
928
|
+
logInfo(serveLine(root, '<nn>-<topic>/index.html'))
|
|
929
|
+
}
|
|
930
|
+
|
|
893
931
|
outro()
|
|
894
932
|
|
|
895
933
|
return 0
|
|
@@ -940,6 +978,8 @@ function reportWorkspace(
|
|
|
940
978
|
logWarn(`no ${workspace.missing.join(' and no ')}`)
|
|
941
979
|
}
|
|
942
980
|
|
|
981
|
+
logStep('Open')
|
|
982
|
+
logInfo(serveLine(root, `${workspace.slug}/index.html`))
|
|
943
983
|
outro()
|
|
944
984
|
|
|
945
985
|
return 0
|
|
@@ -11,12 +11,21 @@ export interface Frontmatter {
|
|
|
11
11
|
* `raw` keeps the delimiters so an index can be rewritten with its own
|
|
12
12
|
* frontmatter untouched, including comments and key order that a parse and
|
|
13
13
|
* re-emit would lose.
|
|
14
|
+
*
|
|
15
|
+
* A block that will not parse reads as absent rather than throwing, so one
|
|
16
|
+
* malformed file reports through the path a caller already takes for a
|
|
17
|
+
* missing block instead of ending the caller's whole run.
|
|
14
18
|
*/
|
|
15
19
|
export function parseFrontmatter(source: string): Frontmatter | undefined {
|
|
16
20
|
const match = source.match(FRONTMATTER)
|
|
17
21
|
if (!match) return undefined
|
|
18
22
|
|
|
19
|
-
|
|
23
|
+
let parsed: unknown
|
|
24
|
+
try {
|
|
25
|
+
parsed = Bun.YAML.parse(match[1])
|
|
26
|
+
} catch {
|
|
27
|
+
return undefined
|
|
28
|
+
}
|
|
20
29
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
21
30
|
return undefined
|
|
22
31
|
}
|
package/src/pr/evidence.ts
CHANGED
|
@@ -106,8 +106,10 @@ export async function groupEvidence(
|
|
|
106
106
|
return { kind: 'read', states }
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
// The raw host serves nothing to a private repository's browser, while the
|
|
110
|
+
// blob form redirects to the bytes carrying the viewer's session.
|
|
109
111
|
function rawUrl(repo: string, sha: string, path: string): string {
|
|
110
|
-
return `https://
|
|
112
|
+
return `https://github.com/${repo}/blob/${sha}/${path}?raw=true`
|
|
111
113
|
}
|
|
112
114
|
|
|
113
115
|
export function evidenceMarker(head: string): string {
|
package/src/teach/workspace.ts
CHANGED
|
@@ -579,8 +579,8 @@ function missionText(
|
|
|
579
579
|
|
|
580
580
|
return [
|
|
581
581
|
'---',
|
|
582
|
-
`title: ${title}`,
|
|
583
|
-
`description: ${request.subject}`,
|
|
582
|
+
`title: ${yamlValue(title)}`,
|
|
583
|
+
`description: ${yamlValue(request.subject)}`,
|
|
584
584
|
`date: ${date}`,
|
|
585
585
|
'---',
|
|
586
586
|
'',
|
|
@@ -606,8 +606,8 @@ function missionText(
|
|
|
606
606
|
function resourcesText(title: string): string {
|
|
607
607
|
return [
|
|
608
608
|
'---',
|
|
609
|
-
`title: Sources for ${title}`,
|
|
610
|
-
`description: Sources read for ${title}, and leads found but not opened`,
|
|
609
|
+
`title: ${yamlValue(`Sources for ${title}`)}`,
|
|
610
|
+
`description: ${yamlValue(`Sources read for ${title}, and leads found but not opened`)}`,
|
|
611
611
|
'---',
|
|
612
612
|
'',
|
|
613
613
|
`# Sources for ${title}`,
|
|
@@ -628,8 +628,8 @@ function resourcesText(title: string): string {
|
|
|
628
628
|
function glossaryText(title: string): string {
|
|
629
629
|
return [
|
|
630
630
|
'---',
|
|
631
|
-
`title: Glossary for ${title}`,
|
|
632
|
-
`description: Terms ${title} defines, one entry each`,
|
|
631
|
+
`title: ${yamlValue(`Glossary for ${title}`)}`,
|
|
632
|
+
`description: ${yamlValue(`Terms ${title} defines, one entry each`)}`,
|
|
633
633
|
'---',
|
|
634
634
|
'',
|
|
635
635
|
`# Glossary for ${title}`,
|
|
@@ -641,6 +641,14 @@ function glossaryText(title: string): string {
|
|
|
641
641
|
].join('\n')
|
|
642
642
|
}
|
|
643
643
|
|
|
644
|
+
/**
|
|
645
|
+
* Serializes a frontmatter value, quoting only what YAML would otherwise read
|
|
646
|
+
* as something other than the string, so a plain title stays byte-identical.
|
|
647
|
+
*/
|
|
648
|
+
function yamlValue(value: string): string {
|
|
649
|
+
return Bun.YAML.stringify(value)
|
|
650
|
+
}
|
|
651
|
+
|
|
644
652
|
/** Today as `YYYY-MM-DD`, which is the one field the mission dates. */
|
|
645
653
|
function today(): string {
|
|
646
654
|
return new Date().toISOString().slice(0, DATE_LENGTH)
|
|
@@ -49,19 +49,18 @@ paths=$(tail -n +"$line_start" "$transcript_path" | jq -r '
|
|
|
49
49
|
|
|
50
50
|
# Two written paths can share a basename, and a bare basename match would then
|
|
51
51
|
# read one mention as covering both. Counting first is what lets a collision
|
|
52
|
-
# fall back to the full path, which the ordinary case never needs.
|
|
53
|
-
|
|
54
|
-
while IFS= read -r path; do
|
|
52
|
+
# fall back to the full path, which the ordinary case never needs. The count
|
|
53
|
+
# avoids an associative array, since macOS ships bash 3.2.
|
|
54
|
+
colliding=$(while IFS= read -r path; do
|
|
55
55
|
[ -n "$path" ] || continue
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
done <<<"$paths"
|
|
56
|
+
printf '%s\n' "${path##*/}"
|
|
57
|
+
done <<<"$paths" | sort | uniq -d)
|
|
59
58
|
|
|
60
59
|
unmentioned=()
|
|
61
60
|
while IFS= read -r path; do
|
|
62
61
|
[ -n "$path" ] || continue
|
|
63
|
-
base=$
|
|
64
|
-
if [ "$
|
|
62
|
+
base=${path##*/}
|
|
63
|
+
if [ -n "$colliding" ] && printf '%s\n' "$colliding" | grep -Fxq -- "$base"; then
|
|
65
64
|
needle="$path"
|
|
66
65
|
else
|
|
67
66
|
needle="$base"
|
|
@@ -10,14 +10,13 @@ Configs ship as sidecar files (`ruff.toml`, `mypy.ini`, `pytest.ini`, `.coverage
|
|
|
10
10
|
|
|
11
11
|
## Scaffold checklist
|
|
12
12
|
|
|
13
|
-
1. Scaffold with `uv init --
|
|
13
|
+
1. Scaffold with `uv init --package <name>`. This creates `pyproject.toml` with a `uv_build` backend, `.python-version` pinned to 3.14, and `src/<name with underscores>/__init__.py` holding an annotated `def main() -> None:`, so source lands under `src/` where `mypy.ini`, `pytest.ini`, and `.coveragerc` look for it. `uv init` defaults `requires-python` to `>=3.14`, which matches the `.python-version` pin this stack ships.
|
|
14
14
|
2. Seed `package.json` so the base layer's bun-side tools (husky, prettier, cspell, commitlint) have a target to install into: `bun init -y`. Without this the sync drops base configs but skips the dep install, since `resolve_missing_deps` short-circuits when `package.json` is absent.
|
|
15
15
|
3. Install base tooling: `canon tooling sync base . --write`
|
|
16
16
|
4. Install python tooling: `canon tooling sync python . --write`
|
|
17
17
|
5. Install Python tooling deps: `uv add --dev ruff mypy pytest pytest-cov`. v1 of this stack does not declare these in `[dependencies.dev]` because manifest injection hardcodes `bun add -D`, which can not install Python packages. Until the injector branches on `runtime`, this step is manual.
|
|
18
18
|
6. Sync the lockfile and create the venv: `uv sync`.
|
|
19
|
-
7.
|
|
20
|
-
8. Run `bun run lint:fix` then `bun run check`.
|
|
19
|
+
7. Run `bun run lint:fix` then `bun run check`.
|
|
21
20
|
|
|
22
21
|
The python stack also ships `tests/test_smoke.py` as a copy-once seed. `pytest` collects at least one test on first run, so `bun run test:run` exits 0 instead of the empty-collection exit code 5. Delete or replace the smoke test with real tests.
|
|
23
22
|
|
|
@@ -27,7 +26,7 @@ The python stack also ships `tests/test_smoke.py` as a copy-once seed. `pytest`
|
|
|
27
26
|
- `mypy.ini`: strict mode, `python_version = 3.14`, `mypy_path = src`, excludes `tests/` and `docs/`.
|
|
28
27
|
- `pytest.ini`: tests live under `tests/`, source under `src/`.
|
|
29
28
|
- `.coveragerc`: branch coverage on `src/`, html report under `.coverage_cache/html`.
|
|
30
|
-
- `.python-version`: pinned to `3.14` to match `uv init
|
|
29
|
+
- `.python-version`: pinned to `3.14` to match `uv init` defaults.
|
|
31
30
|
- `scripts/verify.sh`: overrides base verify to add Typecheck (`mypy`), Lint (`ruff check && ruff format --check`), and Tests (`pytest -v`) phases.
|
|
32
31
|
|
|
33
32
|
## Hybrid project shape
|