@friedbotstudio/create-baseline 0.6.0 → 0.7.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.
Files changed (52) hide show
  1. package/README.md +14 -10
  2. package/bin/cli.js +16 -12
  3. package/obj/template/.claude/commands/init-project-doctor.md +74 -0
  4. package/obj/template/.claude/hooks/lib/resume_writer.py +14 -1
  5. package/obj/template/.claude/hooks/track_guard.sh +11 -1
  6. package/obj/template/.claude/manifest.json +29 -97
  7. package/obj/template/.claude/schemas/workflow-track.v1.json +64 -0
  8. package/obj/template/.claude/skills/audit-baseline/audit.sh +2 -2
  9. package/obj/template/.claude/skills/chore/SKILL.md +2 -2
  10. package/obj/template/.claude/skills/harness/SKILL.md +15 -6
  11. package/obj/template/.claude/skills/intake/SKILL.md +1 -1
  12. package/obj/template/.claude/skills/swarm-plan/SKILL.md +2 -0
  13. package/obj/template/.claude/skills/tdd/SKILL.md +2 -2
  14. package/obj/template/.claude/skills/triage/SKILL.md +29 -6
  15. package/obj/template/.claude/skills/triage/seed-tasklist.mjs +107 -0
  16. package/obj/template/.claude/workflows.jsonl +6 -0
  17. package/obj/template/CLAUDE.md +8 -14
  18. package/obj/template/docs/init/seed.md +148 -3
  19. package/package.json +1 -1
  20. package/src/.claude/workflows.template.jsonl +6 -0
  21. package/src/CLAUDE.template.md +8 -14
  22. package/src/cli/install.js +5 -1
  23. package/src/cli/merge.js +28 -1
  24. package/src/cli/track-tasklist-materializer.js +223 -0
  25. package/src/cli/tui/upgrade.js +14 -8
  26. package/src/cli/upgrade-tiers.js +22 -0
  27. package/src/cli/workflow-migrator.js +40 -0
  28. package/src/cli/workflows-validator-invariants.js +417 -0
  29. package/src/cli/workflows-validator-predicates.js +19 -0
  30. package/src/cli/workflows-validator.js +156 -0
  31. package/src/seed.template.md +148 -3
  32. package/obj/template/.claude/skills/google-analytics/SKILL.md +0 -129
  33. package/obj/template/.claude/skills/google-analytics/references/audiences.md +0 -389
  34. package/obj/template/.claude/skills/google-analytics/references/bigquery.md +0 -470
  35. package/obj/template/.claude/skills/google-analytics/references/custom-dimensions.md +0 -355
  36. package/obj/template/.claude/skills/google-analytics/references/custom-events.md +0 -383
  37. package/obj/template/.claude/skills/google-analytics/references/data-management.md +0 -416
  38. package/obj/template/.claude/skills/google-analytics/references/debugview.md +0 -364
  39. package/obj/template/.claude/skills/google-analytics/references/events-fundamentals.md +0 -398
  40. package/obj/template/.claude/skills/google-analytics/references/gtag.md +0 -502
  41. package/obj/template/.claude/skills/google-analytics/references/gtm-integration.md +0 -483
  42. package/obj/template/.claude/skills/google-analytics/references/measurement-protocol.md +0 -519
  43. package/obj/template/.claude/skills/google-analytics/references/privacy.md +0 -441
  44. package/obj/template/.claude/skills/google-analytics/references/recommended-events.md +0 -464
  45. package/obj/template/.claude/skills/google-analytics/references/reporting.md +0 -397
  46. package/obj/template/.claude/skills/google-analytics/references/setup.md +0 -344
  47. package/obj/template/.claude/skills/google-analytics/references/user-tracking.md +0 -417
  48. package/obj/template/.claude/skills/optimize-seo/SKILL.md +0 -313
  49. package/obj/template/.claude/skills/optimize-seo/scripts/pagespeed.mjs +0 -197
  50. package/obj/template/.claude/skills/pagespeed-insights/LICENSE.md +0 -37
  51. package/obj/template/.claude/skills/pagespeed-insights/SKILL.md +0 -446
  52. package/obj/template/.claude/skills/pagespeed-insights/reference.md +0 -50
@@ -0,0 +1,417 @@
1
+ // Domain — Article IV invariant checks (I1..I11). Each `check*` function
2
+ // returns an array of named-error objects; empty array means the invariant
3
+ // holds. `checkAllInvariants` runs them in order and returns the union.
4
+ //
5
+ // Named-error shape:
6
+ // { kind: 'invariant_iN', track_id?, node_id?, message, ...details }
7
+
8
+ import { isKnownPredicate } from './workflows-validator-predicates.js';
9
+
10
+ // ---------- I1 ----------
11
+
12
+ export function checkI1_uniqueTrackIds(tracks) {
13
+ const seen = new Map();
14
+ const errors = [];
15
+ for (let i = 0; i < tracks.length; i++) {
16
+ const t = tracks[i];
17
+ if (seen.has(t.track_id)) {
18
+ errors.push({
19
+ kind: 'invariant_i1',
20
+ track_id: t.track_id,
21
+ first_line: seen.get(t.track_id) + 1,
22
+ second_line: i + 1,
23
+ message: `Duplicate track_id '${t.track_id}' at index ${i + 1} (first seen at index ${seen.get(t.track_id) + 1}).`,
24
+ });
25
+ } else {
26
+ seen.set(t.track_id, i);
27
+ }
28
+ }
29
+ return errors;
30
+ }
31
+
32
+ // ---------- I2 ----------
33
+
34
+ export function checkI2_uniqueNodeIdsWithinTrack(tracks) {
35
+ const errors = [];
36
+ for (const t of tracks) {
37
+ const seen = new Set();
38
+ for (const node of t.nodes) {
39
+ if (seen.has(node.id)) {
40
+ errors.push({
41
+ kind: 'invariant_i2',
42
+ track_id: t.track_id,
43
+ node_id: node.id,
44
+ message: `Track '${t.track_id}' has duplicate node id '${node.id}'.`,
45
+ });
46
+ } else {
47
+ seen.add(node.id);
48
+ }
49
+ }
50
+ }
51
+ return errors;
52
+ }
53
+
54
+ // ---------- I3 ----------
55
+
56
+ export function checkI3_skillOrSubTrackXor(tracks) {
57
+ const errors = [];
58
+ for (const t of tracks) {
59
+ for (const node of t.nodes) {
60
+ if (node.type === 'selector') {
61
+ if (!Array.isArray(node.alternates) || node.alternates.length === 0) {
62
+ errors.push({
63
+ kind: 'invariant_i3',
64
+ track_id: t.track_id,
65
+ node_id: node.id,
66
+ message: `Selector node '${node.id}' in track '${t.track_id}' has empty alternates[]. Selector nodes require non-empty alternates.`,
67
+ });
68
+ }
69
+ } else {
70
+ const hasSkill = typeof node.skill === 'string' && node.skill.length > 0;
71
+ const hasSubTrack = typeof node.sub_track === 'string' && node.sub_track.length > 0;
72
+ if (hasSkill && hasSubTrack) {
73
+ errors.push({
74
+ kind: 'invariant_i3',
75
+ track_id: t.track_id,
76
+ node_id: node.id,
77
+ message: `Task node '${node.id}' in track '${t.track_id}' has BOTH skill and sub_track set. Exactly one of {skill, sub_track} is required.`,
78
+ });
79
+ } else if (!hasSkill && !hasSubTrack) {
80
+ errors.push({
81
+ kind: 'invariant_i3',
82
+ track_id: t.track_id,
83
+ node_id: node.id,
84
+ message: `Task node '${node.id}' in track '${t.track_id}' has NEITHER skill nor sub_track set. Exactly one is required.`,
85
+ });
86
+ }
87
+ }
88
+ }
89
+ }
90
+ return errors;
91
+ }
92
+
93
+ // ---------- I4 ----------
94
+
95
+ export function checkI4_edgeResolution(tracks) {
96
+ const errors = [];
97
+ for (const t of tracks) {
98
+ const nodeIds = new Set(t.nodes.map((n) => n.id));
99
+ for (const node of t.nodes) {
100
+ for (const dep of node.depends_on || []) {
101
+ if (!nodeIds.has(dep)) {
102
+ errors.push({
103
+ kind: 'invariant_i4',
104
+ track_id: t.track_id,
105
+ node_id: node.id,
106
+ message: `Track '${t.track_id}' node '${node.id}' depends_on '${dep}' which does not exist in the track. (I4: edge resolution)`,
107
+ });
108
+ }
109
+ }
110
+ for (const blk of node.blocks || []) {
111
+ if (!nodeIds.has(blk)) {
112
+ errors.push({
113
+ kind: 'invariant_i4',
114
+ track_id: t.track_id,
115
+ node_id: node.id,
116
+ message: `Track '${t.track_id}' node '${node.id}' blocks '${blk}' which does not exist in the track. (I4: edge resolution)`,
117
+ });
118
+ }
119
+ }
120
+ }
121
+ }
122
+ return errors;
123
+ }
124
+
125
+ // ---------- I5 ----------
126
+
127
+ export function checkI5_dagAcyclic(tracks) {
128
+ const errors = [];
129
+ for (const t of tracks) {
130
+ const cycle = detectCycle(t.nodes);
131
+ if (cycle) {
132
+ errors.push({
133
+ kind: 'invariant_i5',
134
+ track_id: t.track_id,
135
+ cycle,
136
+ message: `Track '${t.track_id}' has a cycle in its dependency DAG: ${cycle.join(' -> ')}.`,
137
+ });
138
+ }
139
+ }
140
+ return errors;
141
+ }
142
+
143
+ function detectCycle(nodes) {
144
+ const byId = new Map(nodes.map((n) => [n.id, n]));
145
+ const WHITE = 0, GRAY = 1, BLACK = 2;
146
+ const color = new Map(nodes.map((n) => [n.id, WHITE]));
147
+ const stack = [];
148
+ function dfs(id) {
149
+ color.set(id, GRAY);
150
+ stack.push(id);
151
+ const node = byId.get(id);
152
+ for (const dep of node?.depends_on || []) {
153
+ if (!byId.has(dep)) continue;
154
+ const c = color.get(dep);
155
+ if (c === GRAY) {
156
+ const idx = stack.indexOf(dep);
157
+ return stack.slice(idx).concat(dep);
158
+ }
159
+ if (c === WHITE) {
160
+ const found = dfs(dep);
161
+ if (found) return found;
162
+ }
163
+ }
164
+ stack.pop();
165
+ color.set(id, BLACK);
166
+ return null;
167
+ }
168
+ for (const n of nodes) {
169
+ if (color.get(n.id) === WHITE) {
170
+ const found = dfs(n.id);
171
+ if (found) return found;
172
+ }
173
+ }
174
+ return null;
175
+ }
176
+
177
+ // ---------- I6 ----------
178
+
179
+ export function checkI6_commitsTrackHasGrantCommit(tracks) {
180
+ const errors = [];
181
+ for (const t of tracks) {
182
+ if (!Array.isArray(t.invariants) || !t.invariants.includes('commits')) continue;
183
+ const commitNode = t.nodes.find((n) => n.skill === 'commit');
184
+ if (!commitNode) {
185
+ errors.push({
186
+ kind: 'invariant_i6',
187
+ track_id: t.track_id,
188
+ message: `Track '${t.track_id}' declares 'commits' invariant but contains no node with skill='commit'.`,
189
+ });
190
+ continue;
191
+ }
192
+ const grantCommitNode = t.nodes.find(
193
+ (n) => n.needs_user === true && (n.skill === 'grant-commit' || n.id === 'grant-commit')
194
+ );
195
+ if (!grantCommitNode) {
196
+ errors.push({
197
+ kind: 'invariant_i6',
198
+ track_id: t.track_id,
199
+ message: `Track '${t.track_id}' declares 'commits' invariant but has no needs_user 'grant-commit' node before commit.`,
200
+ });
201
+ continue;
202
+ }
203
+ if (!nodeOrderedBefore(t, grantCommitNode.id, commitNode.id)) {
204
+ errors.push({
205
+ kind: 'invariant_i6',
206
+ track_id: t.track_id,
207
+ message: `Track '${t.track_id}' grant-commit node is not ordered before commit in the dependency DAG.`,
208
+ });
209
+ }
210
+ }
211
+ return errors;
212
+ }
213
+
214
+ function nodeOrderedBefore(track, predecessorId, successorId) {
215
+ const byId = new Map(track.nodes.map((n) => [n.id, n]));
216
+ const visited = new Set();
217
+ function reaches(fromId) {
218
+ if (fromId === successorId) return true;
219
+ if (visited.has(fromId)) return false;
220
+ visited.add(fromId);
221
+ const node = byId.get(fromId);
222
+ for (const blocked of node?.blocks || []) {
223
+ if (reaches(blocked)) return true;
224
+ }
225
+ return false;
226
+ }
227
+ return reaches(predecessorId);
228
+ }
229
+
230
+ // ---------- I7 ----------
231
+
232
+ export function checkI7_subTrackResolves(tracks) {
233
+ const errors = [];
234
+ const trackMap = new Map(tracks.map((t) => [t.track_id, t]));
235
+ for (const t of tracks) {
236
+ for (const node of t.nodes) {
237
+ const subTrackRefs = collectSubTrackRefs(node);
238
+ for (const ref of subTrackRefs) {
239
+ const target = trackMap.get(ref);
240
+ if (!target) {
241
+ errors.push({
242
+ kind: 'invariant_i7',
243
+ track_id: t.track_id,
244
+ node_id: node.id,
245
+ message: `Track '${t.track_id}' node '${node.id}' references sub_track '${ref}' which does not exist.`,
246
+ });
247
+ continue;
248
+ }
249
+ if (target.selectable === true) {
250
+ errors.push({
251
+ kind: 'invariant_i7',
252
+ track_id: t.track_id,
253
+ node_id: node.id,
254
+ message: `Track '${t.track_id}' node '${node.id}' references sub_track '${ref}' whose selectable=true. Sub-tracks must have selectable=false.`,
255
+ });
256
+ }
257
+ }
258
+ }
259
+ }
260
+ return errors;
261
+ }
262
+
263
+ function collectSubTrackRefs(node) {
264
+ const refs = [];
265
+ if (node.sub_track) refs.push(node.sub_track);
266
+ if (Array.isArray(node.alternates)) {
267
+ for (const alt of node.alternates) {
268
+ if (alt.sub_track) refs.push(alt.sub_track);
269
+ }
270
+ }
271
+ return refs;
272
+ }
273
+
274
+ // ---------- I8 ----------
275
+
276
+ export function checkI8_skillResolves(tracks, { knownSkills }) {
277
+ const errors = [];
278
+ for (const t of tracks) {
279
+ for (const node of t.nodes) {
280
+ const skillRefs = collectSkillRefs(node);
281
+ for (const skill of skillRefs) {
282
+ if (!knownSkills.has(skill)) {
283
+ errors.push({
284
+ kind: 'invariant_i8',
285
+ track_id: t.track_id,
286
+ node_id: node.id,
287
+ message: `Track '${t.track_id}' node '${node.id}' references skill '${skill}' which does not exist on disk.`,
288
+ });
289
+ }
290
+ }
291
+ }
292
+ }
293
+ return errors;
294
+ }
295
+
296
+ function collectSkillRefs(node) {
297
+ const refs = [];
298
+ if (node.skill) refs.push(node.skill);
299
+ if (Array.isArray(node.alternates)) {
300
+ for (const alt of node.alternates) {
301
+ if (alt.skill) refs.push(alt.skill);
302
+ }
303
+ }
304
+ return refs;
305
+ }
306
+
307
+ // ---------- I9 ----------
308
+
309
+ export function checkI9_consentGateOrdering(tracks) {
310
+ const errors = [];
311
+ for (const t of tracks) {
312
+ const gates = t.nodes.filter((n) => n.needs_user === true);
313
+ for (const gate of gates) {
314
+ const hasDependents = t.nodes.some((n) =>
315
+ (n.depends_on || []).includes(gate.id)
316
+ );
317
+ if (!hasDependents && gate.id !== lastNodeId(t)) {
318
+ errors.push({
319
+ kind: 'invariant_i9',
320
+ track_id: t.track_id,
321
+ node_id: gate.id,
322
+ message: `Track '${t.track_id}' consent gate '${gate.id}' has no dependent nodes. Consent gates must be followed by at least one dependent unless they terminate the track.`,
323
+ });
324
+ }
325
+ }
326
+ }
327
+ return errors;
328
+ }
329
+
330
+ function lastNodeId(track) {
331
+ return track.nodes[track.nodes.length - 1]?.id;
332
+ }
333
+
334
+ // ---------- I10 ----------
335
+
336
+ export function checkI10_alternatesCongruent(tracks) {
337
+ const errors = [];
338
+ for (const t of tracks) {
339
+ for (const node of t.nodes) {
340
+ if (node.type !== 'selector') continue;
341
+ const alternates = node.alternates || [];
342
+ if (alternates.length < 2) continue;
343
+ const firstShape = describeAlternate(alternates[0]);
344
+ for (let i = 1; i < alternates.length; i++) {
345
+ const otherShape = describeAlternate(alternates[i]);
346
+ if (otherShape !== firstShape) {
347
+ errors.push({
348
+ kind: 'invariant_i10',
349
+ track_id: t.track_id,
350
+ node_id: node.id,
351
+ message: `Selector node '${node.id}' in track '${t.track_id}' has alternates with divergent shapes. Alternates must be interchangeable in the DAG (same skill vs sub_track distribution).`,
352
+ });
353
+ break;
354
+ }
355
+ }
356
+ }
357
+ }
358
+ return errors;
359
+ }
360
+
361
+ function describeAlternate(alt) {
362
+ return JSON.stringify({
363
+ hasSubTrack: !!alt.sub_track,
364
+ hasSkill: !!alt.skill,
365
+ });
366
+ }
367
+
368
+ // ---------- I11 ----------
369
+
370
+ export function checkI11_predicateNamesResolve(tracks) {
371
+ const errors = [];
372
+ for (const t of tracks) {
373
+ for (const pred of t.preconditions || []) {
374
+ if (!isKnownPredicate(pred.name)) {
375
+ errors.push({
376
+ kind: 'invariant_i11',
377
+ track_id: t.track_id,
378
+ message: `Track '${t.track_id}' precondition uses unknown predicate '${pred.name}'. Not in v1 vocabulary.`,
379
+ });
380
+ }
381
+ }
382
+ for (const node of t.nodes) {
383
+ if (!Array.isArray(node.alternates)) continue;
384
+ for (const alt of node.alternates) {
385
+ for (const pred of alt.preconditions || []) {
386
+ if (!isKnownPredicate(pred.name)) {
387
+ errors.push({
388
+ kind: 'invariant_i11',
389
+ track_id: t.track_id,
390
+ node_id: node.id,
391
+ message: `Track '${t.track_id}' node '${node.id}' alternate uses unknown predicate '${pred.name}'. Not in v1 vocabulary.`,
392
+ });
393
+ }
394
+ }
395
+ }
396
+ }
397
+ }
398
+ return errors;
399
+ }
400
+
401
+ // ---------- Orchestration ----------
402
+
403
+ export function checkAllInvariants(tracks, ctx) {
404
+ return [
405
+ ...checkI1_uniqueTrackIds(tracks),
406
+ ...checkI2_uniqueNodeIdsWithinTrack(tracks),
407
+ ...checkI3_skillOrSubTrackXor(tracks),
408
+ ...checkI4_edgeResolution(tracks),
409
+ ...checkI5_dagAcyclic(tracks),
410
+ ...checkI6_commitsTrackHasGrantCommit(tracks),
411
+ ...checkI7_subTrackResolves(tracks),
412
+ ...checkI8_skillResolves(tracks, ctx),
413
+ ...checkI9_consentGateOrdering(tracks),
414
+ ...checkI10_alternatesCongruent(tracks),
415
+ ...checkI11_predicateNamesResolve(tracks),
416
+ ];
417
+ }
@@ -0,0 +1,19 @@
1
+ // Foundation — v1 predicate vocabulary for workflows.jsonl Track preconditions
2
+ // and selector-node alternate preconditions. The set is closed; unknown
3
+ // predicates fail Article IV invariant I11 at validate time. Adding a new
4
+ // predicate is a constitutional change (seed.md §18.4 + this module + the
5
+ // CLAUDE.md Article IV invariant list).
6
+
7
+ export const V1_PREDICATES = Object.freeze(
8
+ new Set([
9
+ 'requires_git',
10
+ 'requires_user_override',
11
+ 'requires_min_components',
12
+ 'requires_phase_completed',
13
+ 'requires_skill_present',
14
+ ])
15
+ );
16
+
17
+ export function isKnownPredicate(name) {
18
+ return V1_PREDICATES.has(name);
19
+ }
@@ -0,0 +1,156 @@
1
+ // Orchestration — validate `.claude/workflows.jsonl` line-by-line + run
2
+ // Article IV invariant checks. Returns { ok: true, tracks } on success or
3
+ // { ok: false, errors: [...] } on any failure (parse, schema-shape, schema-
4
+ // version, or invariant violation). See docs/init/seed.md §18 for the
5
+ // full contract.
6
+
7
+ import { readFile, readdir, stat } from 'node:fs/promises';
8
+ import { existsSync } from 'node:fs';
9
+ import { join, dirname, resolve } from 'node:path';
10
+ import { checkAllInvariants } from './workflows-validator-invariants.js';
11
+
12
+ const SUPPORTED_SCHEMAS = new Set(['./schemas/workflow-track.v1.json']);
13
+
14
+ const REQUIRED_TRACK_FIELDS = [
15
+ '$schema', 'track_id', 'name', 'description', 'selectable',
16
+ 'selector_hints', 'preconditions', 'invariants', 'nodes',
17
+ ];
18
+
19
+ const KNOWN_TRACK_FIELDS = new Set([...REQUIRED_TRACK_FIELDS]);
20
+
21
+ export async function validateWorkflowsJsonl(filePath) {
22
+ const projectRoot = await findProjectRoot(filePath);
23
+ const knownSkills = await loadKnownInvokables(projectRoot);
24
+ const text = await readFile(filePath, 'utf8');
25
+ const lines = text.split('\n');
26
+
27
+ const tracks = [];
28
+ for (let i = 0; i < lines.length; i++) {
29
+ const raw = lines[i];
30
+ if (raw.trim().length === 0) continue;
31
+ const parsed = tryParseJson(raw, i + 1);
32
+ if (parsed.error) {
33
+ return { ok: false, errors: [parsed.error] };
34
+ }
35
+ const shapeError = checkSchemaShape(parsed.value, i + 1);
36
+ if (shapeError) {
37
+ return { ok: false, errors: [shapeError] };
38
+ }
39
+ const versionError = checkSchemaVersion(parsed.value, i + 1);
40
+ if (versionError) {
41
+ return { ok: false, errors: [versionError] };
42
+ }
43
+ tracks.push(parsed.value);
44
+ }
45
+
46
+ const allTracksMap = new Map(tracks.map((t) => [t.track_id, t]));
47
+ for (const t of tracks) {
48
+ Object.defineProperty(t, '_allTracks', { value: allTracksMap, enumerable: false });
49
+ }
50
+
51
+ const invariantErrors = checkAllInvariants(tracks, { knownSkills });
52
+ if (invariantErrors.length > 0) {
53
+ return { ok: false, errors: invariantErrors };
54
+ }
55
+ return { ok: true, tracks };
56
+ }
57
+
58
+ function tryParseJson(raw, lineNo) {
59
+ try {
60
+ return { value: JSON.parse(raw) };
61
+ } catch (err) {
62
+ const colMatch = err.message.match(/position\s+(\d+)/);
63
+ return {
64
+ error: {
65
+ kind: 'parse_failure',
66
+ line: lineNo,
67
+ col: colMatch ? parseInt(colMatch[1], 10) : 0,
68
+ message: `Line ${lineNo}: JSON parse failed — ${err.message}`,
69
+ },
70
+ };
71
+ }
72
+ }
73
+
74
+ function checkSchemaShape(track, lineNo) {
75
+ if (track === null || typeof track !== 'object' || Array.isArray(track)) {
76
+ return {
77
+ kind: 'schema_shape',
78
+ line: lineNo,
79
+ message: `Line ${lineNo}: Track record must be a JSON object.`,
80
+ };
81
+ }
82
+ for (const field of REQUIRED_TRACK_FIELDS) {
83
+ if (!(field in track)) {
84
+ return {
85
+ kind: 'schema_shape',
86
+ line: lineNo,
87
+ track_id: track.track_id,
88
+ message: `Line ${lineNo}: Track record missing required field '${field}'.`,
89
+ };
90
+ }
91
+ }
92
+ for (const key of Object.keys(track)) {
93
+ if (!KNOWN_TRACK_FIELDS.has(key)) {
94
+ return {
95
+ kind: 'schema_shape',
96
+ line: lineNo,
97
+ track_id: track.track_id,
98
+ message: `Line ${lineNo}: Track '${track.track_id}' has unknown field '${key}' (strict schema; v1 fields only).`,
99
+ };
100
+ }
101
+ }
102
+ return null;
103
+ }
104
+
105
+ function checkSchemaVersion(track, lineNo) {
106
+ if (!SUPPORTED_SCHEMAS.has(track.$schema)) {
107
+ return {
108
+ kind: 'unknown_schema_version',
109
+ line: lineNo,
110
+ track_id: track.track_id,
111
+ message: `Line ${lineNo}: Track '${track.track_id}' references unknown $schema='${track.$schema}'. Supported versions: ${[...SUPPORTED_SCHEMAS].join(', ')}.`,
112
+ };
113
+ }
114
+ return null;
115
+ }
116
+
117
+ async function findProjectRoot(startPath) {
118
+ let dir = resolve(startPath);
119
+ try {
120
+ const st = await stat(dir);
121
+ if (!st.isDirectory()) dir = dirname(dir);
122
+ } catch {
123
+ dir = dirname(dir);
124
+ }
125
+ while (dir !== dirname(dir)) {
126
+ if (existsSync(join(dir, '.claude/skills'))) return dir;
127
+ dir = dirname(dir);
128
+ }
129
+ return dirname(startPath);
130
+ }
131
+
132
+ async function loadKnownInvokables(projectRoot) {
133
+ // Skills live at .claude/skills/<slug>/SKILL.md (slug = directory name).
134
+ // Commands live at .claude/commands/<slug>.md (slug = filename without .md).
135
+ // Both surfaces are valid `skill:` references in a workflows.jsonl Track
136
+ // node — commands are consent gates the user types; skills are Claude-
137
+ // invokable. The Track schema does not distinguish; both resolve here.
138
+ const known = new Set();
139
+ const skillsDir = join(projectRoot, '.claude/skills');
140
+ if (existsSync(skillsDir)) {
141
+ const entries = await readdir(skillsDir, { withFileTypes: true });
142
+ for (const entry of entries) {
143
+ if (entry.isDirectory()) known.add(entry.name);
144
+ }
145
+ }
146
+ const commandsDir = join(projectRoot, '.claude/commands');
147
+ if (existsSync(commandsDir)) {
148
+ const entries = await readdir(commandsDir, { withFileTypes: true });
149
+ for (const entry of entries) {
150
+ if (entry.isFile() && entry.name.endsWith('.md')) {
151
+ known.add(entry.name.slice(0, -3));
152
+ }
153
+ }
154
+ }
155
+ return known;
156
+ }