@friedbotstudio/create-baseline 0.6.0 → 0.8.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 (55) hide show
  1. package/README.md +14 -10
  2. package/bin/cli.js +19 -13
  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/memory_session_start.sh +24 -0
  6. package/obj/template/.claude/hooks/track_guard.sh +11 -1
  7. package/obj/template/.claude/manifest.json +31 -99
  8. package/obj/template/.claude/schemas/workflow-track.v1.json +64 -0
  9. package/obj/template/.claude/skills/audit-baseline/audit.sh +2 -2
  10. package/obj/template/.claude/skills/chore/SKILL.md +2 -2
  11. package/obj/template/.claude/skills/harness/SKILL.md +15 -6
  12. package/obj/template/.claude/skills/intake/SKILL.md +1 -1
  13. package/obj/template/.claude/skills/swarm-plan/SKILL.md +2 -0
  14. package/obj/template/.claude/skills/tdd/SKILL.md +2 -2
  15. package/obj/template/.claude/skills/triage/SKILL.md +29 -6
  16. package/obj/template/.claude/skills/triage/seed-tasklist.mjs +107 -0
  17. package/obj/template/.claude/skills/upgrade-project/SKILL.md +18 -7
  18. package/obj/template/.claude/workflows.jsonl +6 -0
  19. package/obj/template/CLAUDE.md +8 -14
  20. package/obj/template/docs/init/seed.md +148 -3
  21. package/package.json +1 -1
  22. package/src/.claude/workflows.template.jsonl +6 -0
  23. package/src/CLAUDE.template.md +8 -14
  24. package/src/cli/install.js +5 -1
  25. package/src/cli/merge.js +42 -5
  26. package/src/cli/track-tasklist-materializer.js +223 -0
  27. package/src/cli/tui/upgrade.js +30 -41
  28. package/src/cli/upgrade-tiers.js +42 -4
  29. package/src/cli/workflow-migrator.js +40 -0
  30. package/src/cli/workflows-validator-invariants.js +417 -0
  31. package/src/cli/workflows-validator-predicates.js +19 -0
  32. package/src/cli/workflows-validator.js +156 -0
  33. package/src/seed.template.md +148 -3
  34. package/obj/template/.claude/skills/google-analytics/SKILL.md +0 -129
  35. package/obj/template/.claude/skills/google-analytics/references/audiences.md +0 -389
  36. package/obj/template/.claude/skills/google-analytics/references/bigquery.md +0 -470
  37. package/obj/template/.claude/skills/google-analytics/references/custom-dimensions.md +0 -355
  38. package/obj/template/.claude/skills/google-analytics/references/custom-events.md +0 -383
  39. package/obj/template/.claude/skills/google-analytics/references/data-management.md +0 -416
  40. package/obj/template/.claude/skills/google-analytics/references/debugview.md +0 -364
  41. package/obj/template/.claude/skills/google-analytics/references/events-fundamentals.md +0 -398
  42. package/obj/template/.claude/skills/google-analytics/references/gtag.md +0 -502
  43. package/obj/template/.claude/skills/google-analytics/references/gtm-integration.md +0 -483
  44. package/obj/template/.claude/skills/google-analytics/references/measurement-protocol.md +0 -519
  45. package/obj/template/.claude/skills/google-analytics/references/privacy.md +0 -441
  46. package/obj/template/.claude/skills/google-analytics/references/recommended-events.md +0 -464
  47. package/obj/template/.claude/skills/google-analytics/references/reporting.md +0 -397
  48. package/obj/template/.claude/skills/google-analytics/references/setup.md +0 -344
  49. package/obj/template/.claude/skills/google-analytics/references/user-tracking.md +0 -417
  50. package/obj/template/.claude/skills/optimize-seo/SKILL.md +0 -313
  51. package/obj/template/.claude/skills/optimize-seo/scripts/pagespeed.mjs +0 -197
  52. package/obj/template/.claude/skills/pagespeed-insights/LICENSE.md +0 -37
  53. package/obj/template/.claude/skills/pagespeed-insights/SKILL.md +0 -446
  54. package/obj/template/.claude/skills/pagespeed-insights/reference.md +0 -50
  55. package/src/cli/diff-render.js +0 -54
@@ -2,7 +2,9 @@
2
2
  // Plan/apply split:
3
3
  // 1. detect pending semantic-merge stage (idempotency short-circuit, AC-007)
4
4
  // 2. dry-run threeWayMerge → enumerate SKIP_CUSTOMIZED conflicts (tier-1 only)
5
- // 3. prompt the user once per tier-1 conflict (with Show-diff loop, cap-at-2)
5
+ // 3. prompt the user once per tier-1 conflict: Keep your version / Use new
6
+ // baseline / Merge / Abort. The Merge pick stages incoming bytes for
7
+ // /upgrade-project to reconcile (tier1-merge-option spec).
6
8
  // 4. on cancel/abort: bail before any write
7
9
  // 5. on resolve: real threeWayMerge with onSkipCustomized backed by the Map.
8
10
  // Tier-2 MECHANICAL and tier-3 SEMANTIC files are NOT prompted — they're
@@ -12,11 +14,10 @@ import * as clackModule from '@clack/prompts';
12
14
  import { existsSync } from 'node:fs';
13
15
  import { readdir, readFile } from 'node:fs/promises';
14
16
  import { join, relative, sep } from 'node:path';
15
- import { threeWayMerge, ACTION_KINDS } from '../merge.js';
17
+ import { threeWayMerge, ACTION_KINDS, ACTION_LABELS, ACTION_LABEL_WIDTH } from '../merge.js';
16
18
  import { loadManifest, buildManifestFromDir } from '../manifest.js';
17
19
  import { COPY_EXCLUDE } from '../install.js';
18
- import { findPendingStage } from '../upgrade-tiers.js';
19
- import { renderUnifiedDiff } from '../diff-render.js';
20
+ import { findPendingStage, formatStageTimestamp } from '../upgrade-tiers.js';
20
21
  import { renderBrandStrip } from './splash.js';
21
22
 
22
23
  const SUCCESS = 0;
@@ -29,12 +30,10 @@ const ERR_SEMANTIC_STAGED = 5;
29
30
  const CHOICE_OPTIONS = [
30
31
  { value: 'keep-mine', label: 'Keep your version', hint: 'preserve target file as-is' },
31
32
  { value: 'take-theirs', label: 'Use new baseline', hint: 'overwrite with new template' },
32
- { value: 'show-diff', label: 'Show diff', hint: 'render local vs incoming and re-prompt' },
33
+ { value: 'merge', label: 'Merge', hint: 'stage incoming bytes for /upgrade-project to reconcile' },
33
34
  { value: 'abort', label: 'Abort', hint: 'exit without changes' },
34
35
  ];
35
36
 
36
- const SHOW_DIFF_CONSECUTIVE_CAP = 2;
37
-
38
37
  export async function run({ target, opts = {}, prompts = clackModule } = {}) {
39
38
  if (!target || typeof target !== 'string') {
40
39
  throw new Error('tui.upgrade.run requires a non-empty string target');
@@ -58,14 +57,14 @@ export async function run({ target, opts = {}, prompts = clackModule } = {}) {
58
57
 
59
58
  const { oldManifest, newManifest } = await loadManifests(opts.templateDir, manifestPath);
60
59
  if (isLegacyManifest(oldManifest)) {
61
- prompts.log.warn('legacy manifest_version: 1 detected; BASE-content recovery unavailable. Tier-2 / tier-3 files will fall back to the binary prompt.');
60
+ prompts.log.warn("Your previous install predates version-tracked manifests, so this upgrade can't perform automatic three-way merges on customized files. You'll be prompted to keep your version or take the new baseline for each customized file. To enable three-way merges next time, re-install with the latest baseline.");
62
61
  }
63
62
 
64
63
  const dryReport = await threeWayMerge(opts.templateDir, target, oldManifest, newManifest, { dryRun: true });
65
64
  const conflicts = dryReport.actions.filter((a) => a.kind === ACTION_KINDS.SKIP_CUSTOMIZED);
66
65
 
67
66
  const choices = new Map();
68
- const aborted = await collectUserChoices(prompts, conflicts, opts.templateDir, target, choices);
67
+ const aborted = await collectUserChoices(prompts, conflicts, choices);
69
68
  if (aborted) {
70
69
  prompts.cancel('Upgrade aborted; tree unchanged.');
71
70
  return ERR_ABORT;
@@ -73,7 +72,8 @@ export async function run({ target, opts = {}, prompts = clackModule } = {}) {
73
72
 
74
73
  if (opts.dryRun) {
75
74
  for (const action of dryReport.actions) {
76
- prompts.log.info(`${action.kind.padEnd(28)} ${action.path}`);
75
+ const label = ACTION_LABELS[action.kind] ?? action.kind;
76
+ prompts.log.info(`${label.padEnd(ACTION_LABEL_WIDTH)} ${action.path}`);
77
77
  }
78
78
  prompts.outro('Dry run complete; no changes written.');
79
79
  return SUCCESS;
@@ -84,7 +84,8 @@ export async function run({ target, opts = {}, prompts = clackModule } = {}) {
84
84
 
85
85
  for (const action of finalReport.actions) {
86
86
  if (isReportableAction(action.kind)) {
87
- prompts.log.info(`${action.kind.padEnd(28)} ${action.path}`);
87
+ const label = ACTION_LABELS[action.kind] ?? action.kind;
88
+ prompts.log.info(`${label.padEnd(ACTION_LABEL_WIDTH)} ${action.path}`);
88
89
  }
89
90
  if (action.kind === ACTION_KINDS.MECHANICAL_MERGE_CONFLICTED) {
90
91
  prompts.log.warn(`Merged with conflicts — resolve in ${action.path}`);
@@ -93,19 +94,23 @@ export async function run({ target, opts = {}, prompts = clackModule } = {}) {
93
94
 
94
95
  const stagedCount = finalReport.actions.filter((a) => a.kind === ACTION_KINDS.SEMANTIC_MERGE_STAGED).length;
95
96
  if (stagedCount > 0) {
96
- prompts.log.info(`${stagedCount} file(s) need semantic merge. Open Claude Code and run /upgrade-project to reconcile.`);
97
+ prompts.log.info(`${stagedCount} file(s) staged. Open Claude Code and run /upgrade-project to reconcile.`);
97
98
  }
98
99
 
99
100
  const applied = finalReport.actions.filter((a) => isApplied(a.kind)).length;
100
101
  const skipped = finalReport.actions.filter((a) => a.kind === ACTION_KINDS.SKIP_CUSTOMIZED).length;
101
- prompts.outro(`Applied ${applied}; ${skipped} skipped.`);
102
+ prompts.outro(
103
+ skipped === 0
104
+ ? `Applied ${applied} update(s).`
105
+ : `Applied ${applied} update(s); kept your version on ${skipped} customized file(s). Re-run \`create-baseline upgrade\` if you want to revisit those choices.`,
106
+ );
102
107
  return mapExitCode(finalReport.exitCode);
103
108
  }
104
109
 
105
110
  function reportPendingStage(prompts, pending) {
106
111
  const fileLines = pending.files.map((f) => ` - ${f}`).join('\n');
107
- prompts.log.warn(`Pending semantic-merge stage at ${pending.stage_ts}.\n${pending.files.length} file(s) awaiting reconciliation:\n${fileLines}\nOpen Claude Code and run /upgrade-project to reconcile.`);
108
- prompts.outro('No new work; existing stage pending.');
112
+ prompts.log.warn(`A previous upgrade staged ${pending.files.length} file(s) for Claude Code review (staged ${formatStageTimestamp(pending.stage_ts)}):\n${fileLines}\nOpen Claude Code and run /upgrade-project to reconcile.`);
113
+ prompts.outro('No new work; previous staged files still need reconciliation.');
109
114
  return ERR_SEMANTIC_STAGED;
110
115
  }
111
116
 
@@ -115,38 +120,22 @@ function isLegacyManifest(m) {
115
120
  return typeof m.baseline_version !== 'string';
116
121
  }
117
122
 
118
- async function collectUserChoices(prompts, conflicts, templateDir, target, choices) {
123
+ async function collectUserChoices(prompts, conflicts, choices) {
119
124
  for (const conflict of conflicts) {
120
- const choice = await pickForFile(prompts, conflict.path, templateDir, target);
125
+ const choice = await pickForFile(prompts, conflict.path);
121
126
  if (choice === 'abort') return true;
122
- if (choice !== null) choices.set(conflict.path, choice);
127
+ choices.set(conflict.path, choice);
123
128
  }
124
129
  return false;
125
130
  }
126
131
 
127
- async function pickForFile(prompts, rel, templateDir, target) {
128
- let consecutiveShowDiff = 0;
129
- while (true) {
130
- const choice = await prompts.select({
131
- message: `${rel} has been customized — choose:`,
132
- options: CHOICE_OPTIONS,
133
- });
134
- if (prompts.isCancel(choice)) return 'abort';
135
- if (choice !== 'show-diff') return choice;
136
- await renderConflictDiff(prompts, rel, templateDir, target);
137
- consecutiveShowDiff++;
138
- if (consecutiveShowDiff >= SHOW_DIFF_CONSECUTIVE_CAP) {
139
- prompts.log.info(`Show-diff picked ${SHOW_DIFF_CONSECUTIVE_CAP} times for ${rel}; falling through (keeping your version). Re-run if you want to choose differently.`);
140
- return null;
141
- }
142
- }
143
- }
144
-
145
- async function renderConflictDiff(prompts, rel, templateDir, target) {
146
- const localBytes = await readFile(join(target, rel), 'utf8');
147
- const incomingBytes = await readFile(join(templateDir, rel), 'utf8');
148
- const diff = renderUnifiedDiff(localBytes, incomingBytes, { colorize: process.stdout.isTTY === true });
149
- prompts.log.info(`Diff for ${rel} (local → incoming):\n${diff}`);
132
+ async function pickForFile(prompts, rel) {
133
+ const choice = await prompts.select({
134
+ message: `${rel} has been customized — choose:`,
135
+ options: CHOICE_OPTIONS,
136
+ });
137
+ if (prompts.isCancel(choice)) return 'abort';
138
+ return choice;
150
139
  }
151
140
 
152
141
  function isReportableAction(kind) {
@@ -41,6 +41,28 @@ export async function resolveBase(rel, baseline_version, target, opts = {}) {
41
41
  return fetched;
42
42
  }
43
43
 
44
+ // Returns true when resolveBase would succeed for this file without throwing
45
+ // NoBaseError. Used by merge.js:dispatchCustomized in dry-run mode so the TUI
46
+ // surfaces files whose BASE is unrecoverable as conflicts (and prompts the
47
+ // user) instead of optimistically classifying them as tier-2/3 merge candidates
48
+ // that will silently fall back to keep-mine at real-run time.
49
+ export function canRecoverBase(rel, baseline_version, target) {
50
+ const cachePath = join(target, '.claude/.baseline-prior', rel);
51
+ if (existsSync(cachePath)) return true;
52
+ return Boolean(baseline_version);
53
+ }
54
+
55
+ // Render a stage_ts (the `stageTimestamp` format: ISO 8601 with every `:` and
56
+ // `.` replaced by `-`, e.g. "2026-05-20T14-49-00-000Z") as a human-readable
57
+ // "YYYY-MM-DD HH:MM UTC". Returns the input unchanged when the pattern doesn't
58
+ // match so we never silently corrupt an unexpected value.
59
+ export function formatStageTimestamp(ts) {
60
+ if (typeof ts !== 'string') return String(ts);
61
+ const m = ts.match(/^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-\d{2}-\d{3}Z$/);
62
+ if (!m) return ts;
63
+ return `${m[1]} ${m[2]}:${m[3]} UTC`;
64
+ }
65
+
44
66
  export async function findPendingStage(target) {
45
67
  const stageRoot = join(target, '.claude/state/upgrade');
46
68
  if (!existsSync(stageRoot)) return null;
@@ -64,14 +86,30 @@ export async function dispatchByTier(rel, tier, ctx) {
64
86
  }
65
87
 
66
88
  export async function writeStage(ctx, rel, baseBuf, incomingBuf, localBuf) {
67
- if (!ctx.stageRunTs) ctx.stageRunTs = stageTimestamp();
68
- const stageDir = join(ctx.target, '.claude/state/upgrade', ctx.stageRunTs);
69
- await mkdir(stageDir, { recursive: true });
89
+ const stageDir = await ensureStageDir(ctx);
70
90
  await writeStageArtifact(stageDir, `${rel}.baseline-base`, baseBuf);
71
91
  await writeStageArtifact(stageDir, `${rel}.baseline-incoming`, incomingBuf);
72
92
  await appendToStageManifest(stageDir, ctx, rel, baseBuf, incomingBuf, localBuf);
73
93
  }
74
94
 
95
+ // BASE-less stage writer used by the tier-1 Merge pick (see
96
+ // docs/specs/tier1-merge-option.md §Behavior #2 + design pick 1A). Unlike
97
+ // writeStage, no BASE artifact is written and the manifest entry carries
98
+ // base_sha256: null — the discriminator /upgrade-project reads to route to
99
+ // two-way reconciliation.
100
+ export async function writeStageBaseless(ctx, rel, incomingBuf, localBuf) {
101
+ const stageDir = await ensureStageDir(ctx);
102
+ await writeStageArtifact(stageDir, `${rel}.baseline-incoming`, incomingBuf);
103
+ await appendToStageManifest(stageDir, ctx, rel, null, incomingBuf, localBuf);
104
+ }
105
+
106
+ async function ensureStageDir(ctx) {
107
+ if (!ctx.stageRunTs) ctx.stageRunTs = stageTimestamp();
108
+ const stageDir = join(ctx.target, '.claude/state/upgrade', ctx.stageRunTs);
109
+ await mkdir(stageDir, { recursive: true });
110
+ return stageDir;
111
+ }
112
+
75
113
  // --- foundation helpers ---
76
114
 
77
115
  function sha256(buf) {
@@ -210,7 +248,7 @@ async function appendToStageManifest(stageDir, ctx, rel, baseBuf, incomingBuf, l
210
248
  : newStageManifest(ctx);
211
249
  manifest.files.push({
212
250
  rel,
213
- base_sha256: sha256(baseBuf),
251
+ base_sha256: baseBuf === null ? null : sha256(baseBuf),
214
252
  incoming_sha256: sha256(incomingBuf),
215
253
  local_sha256: sha256(localBuf),
216
254
  status: 'PENDING',
@@ -0,0 +1,40 @@
1
+ // Foundation — one-shot migrator that rewrites a pre-§18 `workflow.json`
2
+ // (entry_phase set, no track_id) into the post-§18 shape (track_id, plus
3
+ // skipped_alternates[]) in place. Idempotent on already-post-§18 input.
4
+ // Throws a named error when entry_phase is not in the canonical map.
5
+
6
+ import { readFile, writeFile } from 'node:fs/promises';
7
+
8
+ export const ENTRY_PHASE_TO_TRACK_ID = Object.freeze({
9
+ intake: 'intake-full',
10
+ spec: 'spec-entry',
11
+ tdd: 'tdd-quickfix',
12
+ chore: 'chore',
13
+ });
14
+
15
+ export async function migrateWorkflowJsonInPlace(filePath) {
16
+ const text = await readFile(filePath, 'utf8');
17
+ const data = JSON.parse(text);
18
+ if ('track_id' in data && !('entry_phase' in data)) {
19
+ return { migrated: false, reason: 'already post-§18' };
20
+ }
21
+ if (!('entry_phase' in data)) {
22
+ return { migrated: false, reason: 'no entry_phase and no track_id; cannot determine shape' };
23
+ }
24
+ const entryPhase = data.entry_phase;
25
+ const trackId = ENTRY_PHASE_TO_TRACK_ID[entryPhase];
26
+ if (!trackId) {
27
+ throw new Error(
28
+ `Pre-§18 workflow.json has unmapped entry_phase='${entryPhase}'. ` +
29
+ `Canonical map covers ${Object.keys(ENTRY_PHASE_TO_TRACK_ID).join(', ')}. ` +
30
+ `Cannot migrate; run /triage to restart this workflow.`
31
+ );
32
+ }
33
+ const migrated = { ...data };
34
+ migrated.track_id = trackId;
35
+ migrated.skipped_alternates = Array.isArray(data.skipped_alternates) ? data.skipped_alternates : [];
36
+ migrated.updated_at = Math.floor(Date.now() / 1000);
37
+ delete migrated.entry_phase;
38
+ await writeFile(filePath, JSON.stringify(migrated, null, 2) + '\n');
39
+ return { migrated: true, track_id: trackId };
40
+ }
@@ -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
+ }