cabloy 5.1.200 → 5.1.201

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/.cabloy-version CHANGED
@@ -1 +1 @@
1
- 5.1.200
1
+ 5.1.201
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.1.201
4
+
5
+ ### Features
6
+
7
+ - Support scoped spec traceability exceptions.
8
+
9
+ ### Bug Fixes
10
+
11
+ - Fix the CabloyJS Admin SSR video.
12
+
13
+ ### Improvements
14
+
15
+ - Add the CabloyJS Admin SSR video to the demonstrations.
16
+ - Refine the spec parser.
17
+
3
18
  ## 5.1.200
4
19
 
5
20
  ### Features
package/README.md CHANGED
@@ -36,6 +36,12 @@ For Cabloy Start, clone the [Cabloy Start repository](https://github.com/cabloy/
36
36
 
37
37
  See the [AI Spec-Driven Development](https://cabloy.com/ai/ai-spec-driven-development).
38
38
 
39
+ ## Demonstrations(Videos)
40
+
41
+ ### 1. Can an Admin Site Use SSR? CabloyJS in Three Practical Demos (Duration: 1:16)
42
+
43
+ [![CabloyJS Admin SSR video](./repo-docs/assets/img/cabloy-admin-ssr-cover-en.png)](https://youtu.be/786IQhRdr1I)
44
+
39
45
  ## Editions
40
46
 
41
47
  Cabloy is available through two complete project baselines, each maintained in its own repository:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cabloy",
3
- "version": "5.1.200",
3
+ "version": "5.1.201",
4
4
  "gitHead": "2c5c19284bab738e492856189acb6fad74b8a7b7",
5
5
  "description": "A Node.js fullstack framework",
6
6
  "keywords": [
@@ -221,7 +221,7 @@
221
221
  {
222
222
  "adapter": "codex",
223
223
  "category": "skill",
224
- "sha256": "2b59a2f5322befbef51690e77c19d55de2cf690572d5159ca946290b0ec7319e",
224
+ "sha256": "df49ea0a8485de0ea6cf345199481836924eed0b68608abf4e6ed829f620b72e",
225
225
  "source": "skills/cabloy-spec-generation/references/canonical-spec-input.md",
226
226
  "target": ".agents/skills/cabloy-spec-generation/references/canonical-spec-input.md"
227
227
  },
@@ -235,14 +235,14 @@
235
235
  {
236
236
  "adapter": "codex",
237
237
  "category": "skill",
238
- "sha256": "772e83aea0d7a3ad3d5a91d4ecedd09f6cd24ae9eb146399f0834ded3f074185",
238
+ "sha256": "95fb6ad6f1f85373a6565442e4f0f50582acafd2bef5c8f1f0056123b0996bc8",
239
239
  "source": "skills/cabloy-spec-generation/references/repo-specs-document-set.md",
240
240
  "target": ".agents/skills/cabloy-spec-generation/references/repo-specs-document-set.md"
241
241
  },
242
242
  {
243
243
  "adapter": "codex",
244
244
  "category": "skill",
245
- "sha256": "242d3b7dd6f9266fa836918443c06add6bf9d2308d7f4288112412ee3a6224f5",
245
+ "sha256": "45b83917c079306fb56e0c2cf406a894f7c18c9fd8732f97f9902ed02cf32594",
246
246
  "source": "skills/cabloy-spec-generation/references/traceability-and-status-rules.md",
247
247
  "target": ".agents/skills/cabloy-spec-generation/references/traceability-and-status-rules.md"
248
248
  },
@@ -557,7 +557,7 @@
557
557
  {
558
558
  "adapter": "claude",
559
559
  "category": "skill",
560
- "sha256": "2b59a2f5322befbef51690e77c19d55de2cf690572d5159ca946290b0ec7319e",
560
+ "sha256": "df49ea0a8485de0ea6cf345199481836924eed0b68608abf4e6ed829f620b72e",
561
561
  "source": "skills/cabloy-spec-generation/references/canonical-spec-input.md",
562
562
  "target": ".claude/skills/cabloy-spec-generation/references/canonical-spec-input.md"
563
563
  },
@@ -571,14 +571,14 @@
571
571
  {
572
572
  "adapter": "claude",
573
573
  "category": "skill",
574
- "sha256": "772e83aea0d7a3ad3d5a91d4ecedd09f6cd24ae9eb146399f0834ded3f074185",
574
+ "sha256": "95fb6ad6f1f85373a6565442e4f0f50582acafd2bef5c8f1f0056123b0996bc8",
575
575
  "source": "skills/cabloy-spec-generation/references/repo-specs-document-set.md",
576
576
  "target": ".claude/skills/cabloy-spec-generation/references/repo-specs-document-set.md"
577
577
  },
578
578
  {
579
579
  "adapter": "claude",
580
580
  "category": "skill",
581
- "sha256": "242d3b7dd6f9266fa836918443c06add6bf9d2308d7f4288112412ee3a6224f5",
581
+ "sha256": "45b83917c079306fb56e0c2cf406a894f7c18c9fd8732f97f9902ed02cf32594",
582
582
  "source": "skills/cabloy-spec-generation/references/traceability-and-status-rules.md",
583
583
  "target": ".claude/skills/cabloy-spec-generation/references/traceability-and-status-rules.md"
584
584
  },
@@ -907,7 +907,7 @@
907
907
  {
908
908
  "adapter": "cursor",
909
909
  "category": "skill",
910
- "sha256": "2b59a2f5322befbef51690e77c19d55de2cf690572d5159ca946290b0ec7319e",
910
+ "sha256": "df49ea0a8485de0ea6cf345199481836924eed0b68608abf4e6ed829f620b72e",
911
911
  "source": "skills/cabloy-spec-generation/references/canonical-spec-input.md",
912
912
  "target": ".cursor/skills/cabloy-spec-generation/references/canonical-spec-input.md"
913
913
  },
@@ -921,14 +921,14 @@
921
921
  {
922
922
  "adapter": "cursor",
923
923
  "category": "skill",
924
- "sha256": "772e83aea0d7a3ad3d5a91d4ecedd09f6cd24ae9eb146399f0834ded3f074185",
924
+ "sha256": "95fb6ad6f1f85373a6565442e4f0f50582acafd2bef5c8f1f0056123b0996bc8",
925
925
  "source": "skills/cabloy-spec-generation/references/repo-specs-document-set.md",
926
926
  "target": ".cursor/skills/cabloy-spec-generation/references/repo-specs-document-set.md"
927
927
  },
928
928
  {
929
929
  "adapter": "cursor",
930
930
  "category": "skill",
931
- "sha256": "242d3b7dd6f9266fa836918443c06add6bf9d2308d7f4288112412ee3a6224f5",
931
+ "sha256": "45b83917c079306fb56e0c2cf406a894f7c18c9fd8732f97f9902ed02cf32594",
932
932
  "source": "skills/cabloy-spec-generation/references/traceability-and-status-rules.md",
933
933
  "target": ".cursor/skills/cabloy-spec-generation/references/traceability-and-status-rules.md"
934
934
  },
@@ -17,6 +17,8 @@ References in matrices, related-record lists, progress, evidence, templates, wil
17
17
 
18
18
  Use exact instantiated IDs in declaration-body Traceability. A wildcard or abbreviated range is an aggregate summary, not an exact association or substitute for missing definitions. Keep associations explicit even if a downstream summary matrix repeats them. The audit must derive associations from the declaration body, not nearby unrelated sections.
19
19
 
20
+ For a genuinely cross-cutting engineering contract without a product requirement, an atomic SRS bullet may carry `Traceability exception: technical-only — <record-specific rationale>` on its definition line. This exempts only the incoming PRD association; its outgoing WBS mapping remains required. For a documentary planning or release gate with no independent executable scenario, a WBS task may carry `Traceability exception: authority-only — <record-specific rationale>` in its declaration body. This exempts only the outgoing ATP association; its incoming SRS mapping remains required. Use exactly one well-formed field on the correct definition and explain the alternative authority or retained documentary checks. The auditor rejects malformed, empty, duplicated, or wrong-kind exceptions. Neither classification waives the underlying contract, delivery checks, applicable ATPs, revision-scoped evidence, or release approval; never use one merely to silence a missing real link.
21
+
20
22
  ## Minimal connected example
21
23
 
22
24
  These are neutral **syntax examples**, not business requirements to copy into a real suite. All four IDs are exact and unique. Production bodies must describe the approved domain.
@@ -137,7 +137,7 @@ Every WBS entry should state:
137
137
  - primary source areas or ownership boundaries;
138
138
  - bounded tasks;
139
139
  - acceptance/completion checks;
140
- - linked PRD, SRS, and ATP identifiers;
140
+ - linked PRD, SRS, and ATP identifiers where a real adjacent-authority association exists; a genuine documentary planning/release gate without an independent ATP uses the explicit `authority-only` classification and retains its SRS association and review evidence as specified in `canonical-spec-input.md`;
141
141
  - whether it is planned, implemented, or awaiting evidence.
142
142
 
143
143
  Begin with a documentation/decision implementation gate before feature work. For differing Web/Admin strategies, split shared-site integration and independent-site delivery into separate frontend tasks when their source facts, dependencies, or proof differ. An unresolved strategy or exact runtime identifier may block only the affected implementation task; preserve runnable discovery work and unaffected backend or audience work as accurately actionable. Prefer vertical, verifiable increments. Include migration and release hardening as explicit work. Keep `implementation-complete` distinct from `verified`.
@@ -47,7 +47,7 @@ Minimum cardinality for a material in-scope requirement:
47
47
  - at least one ATP scenario;
48
48
  - zero evidence records before execution, and at least one retained evidence record before `verified`.
49
49
 
50
- A single ATP may prove several related requirements, but the matrices must make the relationship explicit. A WBS item may cover several contracts, but it still needs bounded completion checks.
50
+ A single ATP may prove several related requirements, but the matrices must make the relationship explicit. A WBS item may cover several contracts, but it still needs bounded completion checks. The full-chain minimum applies to material in-scope product requirements; a separately sourced engineering obligation or a documentary coordination gate need not invent a product parent or an executable ATP. Use the narrowly scoped declaration-local `Traceability exception` classifications in `canonical-spec-input.md` only for those genuine cases. The unaffected direction still needs an exact-ID link, and review/closure still needs applicable retained proof. An exception is not a deferred status, evidence waiver, acceptance result, or release approval.
51
51
 
52
52
  ## Authority-first updates
53
53
 
@@ -99,6 +99,176 @@ test('ordinary co-occurrence and wildcard matrices cannot manufacture traceabili
99
99
  );
100
100
  });
101
101
 
102
+ test('technical-only SRS exempts its PRD parent, not its WBS successor', async t => {
103
+ const records = {
104
+ ...BASELINE,
105
+ 'srs.md':
106
+ '# Contracts\n\n- **SRS-DEMO-01**: Test hygiene. Traceability exception: technical-only — Repository fixture policy owns cleanup.\n\nTraceability: `WBS-DEMO-10-01`\n\n- **SRS-DEMO-02**: Product contract. Traceability: `PRD-DEMO-01`, `WBS-DEMO-10-01`.\n',
107
+ 'prd.md': BASELINE['prd.md'].replace('`SRS-DEMO-01`', '`SRS-DEMO-02`'),
108
+ };
109
+ const result = await auditPlanning(await fixture(t, records));
110
+ assert.deepEqual(result.diagnostics, []);
111
+ const withoutException = {
112
+ ...records,
113
+ 'srs.md': records['srs.md'].replace(
114
+ ' Traceability exception: technical-only — Repository fixture policy owns cleanup.',
115
+ '',
116
+ ),
117
+ };
118
+ const missingParent = await auditPlanning(await fixture(t, withoutException));
119
+ assert.ok(
120
+ missingParent.diagnostics.some(
121
+ diagnostic => diagnostic.id === 'SRS-DEMO-01' && diagnostic.code === 'traceability-incoming',
122
+ ),
123
+ );
124
+ const missingSuccessor = await auditPlanning(
125
+ await fixture(t, {
126
+ ...records,
127
+ 'srs.md': records['srs.md'].replace('Traceability: `WBS-DEMO-10-01`\n', ''),
128
+ 'pdp-wbs.md': records['pdp-wbs.md'].replace('`SRS-DEMO-01`, ', ''),
129
+ }),
130
+ );
131
+ assert.ok(
132
+ missingSuccessor.diagnostics.some(
133
+ diagnostic => diagnostic.id === 'SRS-DEMO-01' && diagnostic.code === 'traceability-outgoing',
134
+ ),
135
+ );
136
+ });
137
+
138
+ test('authority-only WBS exempts its ATP successor, not its SRS parent', async t => {
139
+ const records = {
140
+ ...BASELINE,
141
+ 'pdp-wbs.md': `${BASELINE['pdp-wbs.md']}\n#### WBS-DEMO-10-02: Review baseline\n\nTraceability exception: authority-only — Retain the baseline review disposition.\n\nTraceability: \`SRS-DEMO-01\`.\n`,
142
+ 'progress.md': `${BASELINE['progress.md']}| WBS-DEMO-10-02 | Review | not-started |\n`,
143
+ };
144
+ const result = await auditPlanning(await fixture(t, records));
145
+ assert.deepEqual(result.diagnostics, []);
146
+ const withoutException = {
147
+ ...records,
148
+ 'pdp-wbs.md': records['pdp-wbs.md'].replace(
149
+ 'Traceability exception: authority-only — Retain the baseline review disposition.\n\n',
150
+ '',
151
+ ),
152
+ };
153
+ const missingSuccessor = await auditPlanning(await fixture(t, withoutException));
154
+ assert.ok(
155
+ missingSuccessor.diagnostics.some(
156
+ diagnostic =>
157
+ diagnostic.id === 'WBS-DEMO-10-02' && diagnostic.code === 'traceability-outgoing',
158
+ ),
159
+ );
160
+ const missingParent = await auditPlanning(
161
+ await fixture(t, {
162
+ ...records,
163
+ 'pdp-wbs.md': records['pdp-wbs.md'].replace('Traceability: `SRS-DEMO-01`.\n', ''),
164
+ }),
165
+ );
166
+ assert.ok(
167
+ missingParent.diagnostics.some(
168
+ diagnostic =>
169
+ diagnostic.id === 'WBS-DEMO-10-02' && diagnostic.code === 'traceability-incoming',
170
+ ),
171
+ );
172
+ });
173
+
174
+ test('malformed, repeated and wrong-kind exceptions fail closed', async t => {
175
+ const invalid = [
176
+ 'Traceability exception: technical-only —',
177
+ 'Traceability exception: technical-only — !',
178
+ 'Traceability exception: unknown — Rationale.',
179
+ 'Traceability exception: authority-only — Rationale.',
180
+ 'Traceability exception: technical-only — Rationale. Traceability exception: technical-only — Again.',
181
+ ];
182
+ for (const field of invalid) {
183
+ const records = {
184
+ ...BASELINE,
185
+ 'srs.md': BASELINE['srs.md'].replace(
186
+ '### SRS-DEMO-01: Contract\n',
187
+ `- **SRS-DEMO-01**: Contract. ${field}\n`,
188
+ ),
189
+ };
190
+ const result = await auditPlanning(await fixture(t, records));
191
+ assert.ok(
192
+ result.diagnostics.some(diagnostic => diagnostic.code === 'traceability-exception'),
193
+ field,
194
+ );
195
+ }
196
+ const wrongWbs = {
197
+ ...BASELINE,
198
+ 'pdp-wbs.md': BASELINE['pdp-wbs.md'].replace(
199
+ 'Completion checks:',
200
+ 'Traceability exception: technical-only — Not an SRS.\n\nCompletion checks:',
201
+ ),
202
+ };
203
+ assert.ok(
204
+ codes(await auditPlanning(await fixture(t, wrongWbs))).includes('traceability-exception'),
205
+ );
206
+ });
207
+
208
+ test('an exception outside its own declaration cannot exempt a missing link', async t => {
209
+ const records = {
210
+ ...BASELINE,
211
+ 'srs.md':
212
+ '# Contracts\n\nTraceability exception: technical-only — Preamble.\n\n- **SRS-DEMO-02**: Unmapped contract.\n- **SRS-DEMO-01**: Contract. Traceability exception: technical-only — Different record.\n\nTraceability: `PRD-DEMO-01`, `WBS-DEMO-10-01`\n',
213
+ 'pdp-wbs.md': `${BASELINE['pdp-wbs.md'].replace(
214
+ 'Dependencies: none.',
215
+ 'Dependencies: none.\n\nTraceability exception: authority-only — Phase preamble.',
216
+ )}\n#### WBS-DEMO-10-02: Unmapped task\n`,
217
+ 'progress.md': `${BASELINE['progress.md']}| WBS-DEMO-10-02 | Unmapped | not-started |\n`,
218
+ };
219
+ const result = await auditPlanning(await fixture(t, records));
220
+ for (const [id, code] of [
221
+ ['SRS-DEMO-02', 'traceability-incoming'],
222
+ ['WBS-DEMO-10-02', 'traceability-outgoing'],
223
+ ]) {
224
+ assert.ok(
225
+ result.diagnostics.some(diagnostic => diagnostic.id === id && diagnostic.code === code),
226
+ );
227
+ }
228
+ assert.ok(result.diagnostics.some(diagnostic => diagnostic.code === 'traceability-exception'));
229
+ });
230
+
231
+ test('classified gates retain reference and completed-prerequisite checks', async t => {
232
+ const records = {
233
+ ...BASELINE,
234
+ 'pdp-wbs.md': `${BASELINE['pdp-wbs.md']}\n#### WBS-DEMO-10-02: Review gate\n\nDependencies: WBS-DEMO-10-01.\n\nTraceability exception: authority-only — Retain the review disposition.\n\nTraceability: \`SRS-DEMO-01\`.\n\nReview \`SRS-MISSING-01\`.\n`,
235
+ 'progress.md': `${BASELINE['progress.md']}| WBS-DEMO-10-02 | Review | verified |\n`,
236
+ };
237
+ const result = await auditPlanning(await fixture(t, records));
238
+ assert.ok(
239
+ result.diagnostics.some(
240
+ diagnostic => diagnostic.code === 'undefined-reference' && diagnostic.id === 'SRS-MISSING-01',
241
+ ),
242
+ );
243
+ assert.ok(
244
+ result.diagnostics.some(
245
+ diagnostic => diagnostic.code === 'dependency-state' && diagnostic.id === 'WBS-DEMO-10-02',
246
+ ),
247
+ );
248
+ assert.ok(
249
+ !result.diagnostics.some(
250
+ diagnostic =>
251
+ diagnostic.code === 'traceability-outgoing' && diagnostic.id === 'WBS-DEMO-10-02',
252
+ ),
253
+ );
254
+ });
255
+
256
+ test('fences and comments cannot annotate a definition', async t => {
257
+ const records = {
258
+ ...BASELINE,
259
+ 'pdp-wbs.md': `${BASELINE['pdp-wbs.md']}\n#### WBS-DEMO-10-02: Review gate\n\nTraceability: \`SRS-DEMO-01\`.\n\n\`\`\`md\nTraceability exception: authority-only — Example only.\n\`\`\`\n\n<!-- Traceability exception: authority-only — Not authority. -->\n`,
260
+ 'progress.md': `${BASELINE['progress.md']}| WBS-DEMO-10-02 | Review | not-started |\n`,
261
+ };
262
+ const result = await auditPlanning(await fixture(t, records));
263
+ assert.ok(
264
+ result.diagnostics.some(
265
+ diagnostic =>
266
+ diagnostic.code === 'traceability-outgoing' && diagnostic.id === 'WBS-DEMO-10-02',
267
+ ),
268
+ );
269
+ assert.ok(!codes(result).includes('traceability-exception'));
270
+ });
271
+
102
272
  test('lightweight records do not require absent baseline files or charts', async t => {
103
273
  const directory = await fixture(t, {
104
274
  'README.md': '# Lightweight\n',
@@ -71,6 +71,46 @@ function addMapping(groups, edges, definitions) {
71
71
  }
72
72
  }
73
73
 
74
+ function traceabilityException(definition, report, annotatedLines) {
75
+ const fields = markdownLines(definition.body ?? '').flatMap(({ text, line }) =>
76
+ Array.from(text.matchAll(/\bTraceability\s+exception\b/gi), match => ({
77
+ text,
78
+ line,
79
+ start: match.index,
80
+ })),
81
+ );
82
+ if (!fields.length) return null;
83
+ for (const field of fields) {
84
+ const line = definition.line + field.line - (definition.kind === 'WBS' ? 0 : 1);
85
+ annotatedLines.add(`${definition.file}:${line}`);
86
+ }
87
+ const field = fields[0];
88
+ const value =
89
+ definition.kind === 'bullet' && field.line === 1
90
+ ? field.text.slice(field.start).trim()
91
+ : field.text.trim();
92
+ const match = value.match(/^Traceability exception:\s*(technical-only|authority-only)\s+—\s+/i);
93
+ const exception = match?.[1].toLowerCase();
94
+ const rationale = match && value.slice(match[0].length);
95
+ const validOwner =
96
+ (exception === 'technical-only' &&
97
+ definition.id.startsWith('SRS-') &&
98
+ definition.kind === 'bullet' &&
99
+ field.line === 1) ||
100
+ (exception === 'authority-only' && definition.id.startsWith('WBS-'));
101
+ if (fields.length !== 1 || !validOwner || !/[\p{L}\p{N}]/u.test(rationale ?? '')) {
102
+ report(
103
+ 'traceability-exception',
104
+ definition.file,
105
+ definition.line + field.line - (definition.kind === 'WBS' ? 0 : 1),
106
+ 'Invalid Traceability exception: use one declaration-local technical-only SRS or authority-only WBS field with a rationale.',
107
+ definition.id,
108
+ );
109
+ return null;
110
+ }
111
+ return exception;
112
+ }
113
+
74
114
  function collectMappings(records, definitions) {
75
115
  const edges = new Map(Array.from(definitions.keys(), id => [id, new Set()]));
76
116
  for (const definition of definitions.values()) {
@@ -271,8 +311,22 @@ export async function auditPlanning(
271
311
  report('declaration', file, 1, error.message);
272
312
  }
273
313
  }
314
+ const exceptions = new Map();
315
+ const annotatedLines = new Set();
316
+ for (const definition of definitions.values()) {
317
+ const exception = traceabilityException(definition, report, annotatedLines);
318
+ if (exception) exceptions.set(definition.id, exception);
319
+ }
274
320
  for (const [file, markdown] of records) {
275
321
  for (const { text, line } of markdownLines(markdown)) {
322
+ if (/\bTraceability\s+exception\b/i.test(text) && !annotatedLines.has(`${file}:${line}`)) {
323
+ report(
324
+ 'traceability-exception',
325
+ file,
326
+ line,
327
+ 'Traceability exception must be in its own formal declaration.',
328
+ );
329
+ }
276
330
  for (const prefix of OWNERS.keys()) {
277
331
  for (const id of findIdentifiers(referenceText(text), prefix)) {
278
332
  if (!definitions.has(id)) {
@@ -347,7 +401,7 @@ export async function auditPlanning(
347
401
  const incoming = new Set([...edges.values()].flatMap(values => [...values]));
348
402
  for (const [id, definition] of definitions) {
349
403
  if (definition.status === 'deferred' || definition.deferred) continue;
350
- if (!id.startsWith('PRD-') && !incoming.has(id)) {
404
+ if (!id.startsWith('PRD-') && !incoming.has(id) && exceptions.get(id) !== 'technical-only') {
351
405
  report(
352
406
  'traceability-incoming',
353
407
  definition.file,
@@ -356,7 +410,11 @@ export async function auditPlanning(
356
410
  id,
357
411
  );
358
412
  }
359
- if (!id.startsWith('ATP-') && !edges.get(id).size) {
413
+ if (
414
+ !id.startsWith('ATP-') &&
415
+ !edges.get(id).size &&
416
+ exceptions.get(id) !== 'authority-only'
417
+ ) {
360
418
  report(
361
419
  'traceability-outgoing',
362
420
  definition.file,
@@ -30,6 +30,12 @@ features:
30
30
  details: Organize capabilities for SSR, SPA, Web, and Admin applications with shared conventions.
31
31
  ---
32
32
 
33
+ ## Demonstrations(Videos)
34
+
35
+ ### 1. Can an Admin Site Use SSR? CabloyJS in Three Practical Demos (Duration: 1:16)
36
+
37
+ [![CabloyJS Admin SSR video](./assets/img/cabloy-admin-ssr-cover-en.png)](https://youtu.be/786IQhRdr1I)
38
+
33
39
  ## Choose a reading path
34
40
 
35
41
  ### Start a project