@friedbotstudio/create-baseline 0.5.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.
- package/README.md +14 -10
- package/bin/cli.js +46 -15
- package/obj/template/.claude/commands/init-project-doctor.md +74 -0
- package/obj/template/.claude/hooks/lib/resume_writer.py +14 -1
- package/obj/template/.claude/hooks/track_guard.sh +11 -1
- package/obj/template/.claude/manifest.json +848 -230
- package/obj/template/.claude/schemas/workflow-track.v1.json +64 -0
- package/obj/template/.claude/skills/audit-baseline/audit.sh +6 -3
- package/obj/template/.claude/skills/chore/SKILL.md +2 -2
- package/obj/template/.claude/skills/harness/SKILL.md +15 -6
- package/obj/template/.claude/skills/intake/SKILL.md +1 -1
- package/obj/template/.claude/skills/swarm-plan/SKILL.md +2 -0
- package/obj/template/.claude/skills/tdd/SKILL.md +2 -2
- package/obj/template/.claude/skills/triage/SKILL.md +29 -6
- package/obj/template/.claude/skills/triage/seed-tasklist.mjs +107 -0
- package/obj/template/.claude/skills/upgrade-project/SKILL.md +121 -0
- package/obj/template/.claude/workflows.jsonl +6 -0
- package/obj/template/CLAUDE.md +14 -19
- package/obj/template/docs/init/seed.md +152 -7
- package/package.json +1 -1
- package/src/.claude/workflows.template.jsonl +6 -0
- package/src/CLAUDE.template.md +14 -19
- package/src/cli/diff-render.js +54 -0
- package/src/cli/install.js +38 -3
- package/src/cli/manifest.js +7 -3
- package/src/cli/merge.js +107 -13
- package/src/cli/track-tasklist-materializer.js +223 -0
- package/src/cli/tui/upgrade.js +130 -27
- package/src/cli/upgrade-tiers.js +256 -0
- package/src/cli/workflow-migrator.js +40 -0
- package/src/cli/workflows-validator-invariants.js +417 -0
- package/src/cli/workflows-validator-predicates.js +19 -0
- package/src/cli/workflows-validator.js +156 -0
- package/src/seed.template.md +152 -7
- package/obj/template/.claude/skills/google-analytics/SKILL.md +0 -129
- package/obj/template/.claude/skills/google-analytics/references/audiences.md +0 -389
- package/obj/template/.claude/skills/google-analytics/references/bigquery.md +0 -470
- package/obj/template/.claude/skills/google-analytics/references/custom-dimensions.md +0 -355
- package/obj/template/.claude/skills/google-analytics/references/custom-events.md +0 -383
- package/obj/template/.claude/skills/google-analytics/references/data-management.md +0 -416
- package/obj/template/.claude/skills/google-analytics/references/debugview.md +0 -364
- package/obj/template/.claude/skills/google-analytics/references/events-fundamentals.md +0 -398
- package/obj/template/.claude/skills/google-analytics/references/gtag.md +0 -502
- package/obj/template/.claude/skills/google-analytics/references/gtm-integration.md +0 -483
- package/obj/template/.claude/skills/google-analytics/references/measurement-protocol.md +0 -519
- package/obj/template/.claude/skills/google-analytics/references/privacy.md +0 -441
- package/obj/template/.claude/skills/google-analytics/references/recommended-events.md +0 -464
- package/obj/template/.claude/skills/google-analytics/references/reporting.md +0 -397
- package/obj/template/.claude/skills/google-analytics/references/setup.md +0 -344
- package/obj/template/.claude/skills/google-analytics/references/user-tracking.md +0 -417
- package/obj/template/.claude/skills/optimize-seo/SKILL.md +0 -313
- package/obj/template/.claude/skills/optimize-seo/scripts/pagespeed.mjs +0 -197
- package/obj/template/.claude/skills/pagespeed-insights/LICENSE.md +0 -37
- package/obj/template/.claude/skills/pagespeed-insights/SKILL.md +0 -446
- package/obj/template/.claude/skills/pagespeed-insights/reference.md +0 -50
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// Domain — tier dispatch + BASE-content recovery + semantic-merge staging.
|
|
2
|
+
// Consumed by src/cli/merge.js's customized-file branch. See
|
|
3
|
+
// docs/specs/upgrade-flow-rework.md §Behavior #2/#3/#4/#5/#6.
|
|
4
|
+
|
|
5
|
+
import { mkdir, mkdtemp, readFile, readdir, unlink, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
11
|
+
|
|
12
|
+
export class NoBaseError extends Error {
|
|
13
|
+
constructor(message, opts = {}) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = 'NoBaseError';
|
|
16
|
+
this.kind = opts.kind ?? 'unknown';
|
|
17
|
+
this.rel = opts.rel ?? null;
|
|
18
|
+
if (opts.cause) this.cause = opts.cause;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function resolveBase(rel, baseline_version, target, opts = {}) {
|
|
23
|
+
const { oldManifest = null, pack = null } = opts;
|
|
24
|
+
const expectedSha = readExpectedSha(oldManifest, rel);
|
|
25
|
+
const cached = await readCacheIfPresent(target, rel);
|
|
26
|
+
if (cached) {
|
|
27
|
+
if (expectedSha && sha256(cached) === expectedSha) return cached;
|
|
28
|
+
if (expectedSha) {
|
|
29
|
+
throw new NoBaseError(`cache sha mismatch for ${rel}`, { kind: 'cache_sha_mismatch', rel });
|
|
30
|
+
}
|
|
31
|
+
return cached;
|
|
32
|
+
}
|
|
33
|
+
if (!baseline_version) {
|
|
34
|
+
throw new NoBaseError(`legacy manifest; cannot recover BASE for ${rel}`, { kind: 'legacy_manifest', rel });
|
|
35
|
+
}
|
|
36
|
+
const fetched = await fetchFromNpm(rel, baseline_version, pack);
|
|
37
|
+
if (expectedSha && sha256(fetched) !== expectedSha) {
|
|
38
|
+
throw new NoBaseError(`npm tarball sha mismatch for ${rel}`, { kind: 'npm_sha_mismatch', rel });
|
|
39
|
+
}
|
|
40
|
+
await writeCacheThrough(target, rel, fetched);
|
|
41
|
+
return fetched;
|
|
42
|
+
}
|
|
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
|
+
|
|
66
|
+
export async function findPendingStage(target) {
|
|
67
|
+
const stageRoot = join(target, '.claude/state/upgrade');
|
|
68
|
+
if (!existsSync(stageRoot)) return null;
|
|
69
|
+
const stages = await listSubdirs(stageRoot);
|
|
70
|
+
for (const ts of stages) {
|
|
71
|
+
const manifestPath = join(stageRoot, ts, 'manifest.json');
|
|
72
|
+
if (!existsSync(manifestPath)) continue;
|
|
73
|
+
const pending = await readPendingFiles(manifestPath);
|
|
74
|
+
if (pending.length > 0) return { stage_ts: ts, files: pending };
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function dispatchByTier(rel, tier, ctx) {
|
|
80
|
+
if (tier === 'BINARY_PROMPT') {
|
|
81
|
+
return { kind: 'SKIP_CUSTOMIZED', path: rel, reason: 'tier BINARY_PROMPT: user prompt deferred' };
|
|
82
|
+
}
|
|
83
|
+
if (tier === 'MECHANICAL') return runMechanicalMerge(rel, ctx);
|
|
84
|
+
if (tier === 'SEMANTIC') return runSemanticStage(rel, ctx);
|
|
85
|
+
throw new Error(`unknown tier: ${tier}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function writeStage(ctx, rel, baseBuf, incomingBuf, localBuf) {
|
|
89
|
+
if (!ctx.stageRunTs) ctx.stageRunTs = stageTimestamp();
|
|
90
|
+
const stageDir = join(ctx.target, '.claude/state/upgrade', ctx.stageRunTs);
|
|
91
|
+
await mkdir(stageDir, { recursive: true });
|
|
92
|
+
await writeStageArtifact(stageDir, `${rel}.baseline-base`, baseBuf);
|
|
93
|
+
await writeStageArtifact(stageDir, `${rel}.baseline-incoming`, incomingBuf);
|
|
94
|
+
await appendToStageManifest(stageDir, ctx, rel, baseBuf, incomingBuf, localBuf);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// --- foundation helpers ---
|
|
98
|
+
|
|
99
|
+
function sha256(buf) {
|
|
100
|
+
return createHash('sha256').update(buf).digest('hex');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function readExpectedSha(oldManifest, rel) {
|
|
104
|
+
const entry = oldManifest?.files?.[rel];
|
|
105
|
+
if (typeof entry === 'string') return entry;
|
|
106
|
+
if (entry && typeof entry === 'object' && typeof entry.sha256 === 'string') return entry.sha256;
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function readCacheIfPresent(target, rel) {
|
|
111
|
+
const cachePath = join(target, '.claude/.baseline-prior', rel);
|
|
112
|
+
if (!existsSync(cachePath)) return null;
|
|
113
|
+
return await readFile(cachePath);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function writeCacheThrough(target, rel, bytes) {
|
|
117
|
+
const cachePath = join(target, '.claude/.baseline-prior', rel);
|
|
118
|
+
await mkdir(dirname(cachePath), { recursive: true });
|
|
119
|
+
await writeFile(cachePath, bytes);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function fetchFromNpm(rel, baseline_version, packOverride) {
|
|
123
|
+
const packFn = packOverride ?? defaultPack;
|
|
124
|
+
const spec = `@friedbotstudio/create-baseline@${baseline_version}`;
|
|
125
|
+
let result;
|
|
126
|
+
try {
|
|
127
|
+
result = await packFn(spec);
|
|
128
|
+
} catch (err) {
|
|
129
|
+
throw new NoBaseError(`npm fetch failed for ${rel}: ${err.message}`, {
|
|
130
|
+
kind: 'npm_fetch_failed', rel, cause: err,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
const bytes = await extractFromPackResult(result, rel);
|
|
134
|
+
if (!bytes) {
|
|
135
|
+
throw new NoBaseError(`npm tarball missing ${rel}`, { kind: 'npm_missing_file', rel });
|
|
136
|
+
}
|
|
137
|
+
return bytes;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function defaultPack(spec) {
|
|
141
|
+
const mod = await import('libnpmpack');
|
|
142
|
+
const fn = mod.default ?? mod.pack ?? mod;
|
|
143
|
+
return fn(spec);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function extractFromPackResult(result, rel) {
|
|
147
|
+
if (result instanceof Map) return result.get(rel) ?? null;
|
|
148
|
+
if (Buffer.isBuffer(result) || result instanceof Uint8Array) {
|
|
149
|
+
return extractFromTarball(Buffer.from(result), rel);
|
|
150
|
+
}
|
|
151
|
+
throw new Error(`unsupported pack result type: ${typeof result}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function extractFromTarball(tarballBytes, rel) {
|
|
155
|
+
const tmp = await mkdtemp(join(tmpdir(), 'baseline-prior-extract-'));
|
|
156
|
+
const tmpRoot = resolve(tmp) + sep;
|
|
157
|
+
const result = spawnSync('tar', ['-xz', '-C', tmp, '-f', '-'], { input: tarballBytes });
|
|
158
|
+
if (result.status !== 0) {
|
|
159
|
+
throw new Error(`tar extract failed: ${(result.stderr || '').toString()}`);
|
|
160
|
+
}
|
|
161
|
+
const candidate = join(tmp, 'package', rel);
|
|
162
|
+
// Defense in depth: although bsdtar (macOS default) and GNU tar both refuse
|
|
163
|
+
// absolute paths and `..` components by default when extracting, validate
|
|
164
|
+
// the candidate resolves under tmp before reading. Refuses to follow a
|
|
165
|
+
// malicious tarball that somehow planted bytes outside the extraction root.
|
|
166
|
+
const resolved = resolve(candidate);
|
|
167
|
+
if (!resolved.startsWith(tmpRoot)) {
|
|
168
|
+
throw new NoBaseError(`tarball entry escapes extraction root: ${rel}`, { kind: 'tarball_path_traversal', rel });
|
|
169
|
+
}
|
|
170
|
+
if (!existsSync(resolved)) return null;
|
|
171
|
+
return await readFile(resolved);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function listSubdirs(root) {
|
|
175
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
176
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function readPendingFiles(manifestPath) {
|
|
180
|
+
try {
|
|
181
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
182
|
+
return (manifest.files || []).filter((f) => f.status === 'PENDING').map((f) => f.rel);
|
|
183
|
+
} catch {
|
|
184
|
+
return [];
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// --- domain helpers ---
|
|
189
|
+
|
|
190
|
+
async function runMechanicalMerge(rel, ctx) {
|
|
191
|
+
const base = await resolveBase(rel, ctx.baseline_version, ctx.target, {
|
|
192
|
+
oldManifest: ctx.oldManifest, pack: ctx.pack,
|
|
193
|
+
});
|
|
194
|
+
const remote = await readFile(join(ctx.templateDir, rel));
|
|
195
|
+
const localPath = join(ctx.target, rel);
|
|
196
|
+
const tmpBase = join(tmpdir(), `merge-base-${randomUUID()}`);
|
|
197
|
+
const tmpRemote = join(tmpdir(), `merge-remote-${randomUUID()}`);
|
|
198
|
+
await writeFile(tmpBase, base);
|
|
199
|
+
await writeFile(tmpRemote, remote);
|
|
200
|
+
const result = spawnSync('git', ['merge-file', '--diff3', localPath, tmpBase, tmpRemote], { encoding: 'utf8' });
|
|
201
|
+
await unlink(tmpBase).catch(() => {});
|
|
202
|
+
await unlink(tmpRemote).catch(() => {});
|
|
203
|
+
if (result.status === 0) {
|
|
204
|
+
return { kind: 'MECHANICAL_MERGE_CLEAN', path: rel, reason: 'git merge-file clean' };
|
|
205
|
+
}
|
|
206
|
+
if (typeof result.status === 'number' && result.status > 0 && result.status < 128) {
|
|
207
|
+
return { kind: 'MECHANICAL_MERGE_CONFLICTED', path: rel, hunks: result.status, reason: `${result.status} conflict hunk(s)` };
|
|
208
|
+
}
|
|
209
|
+
throw new Error(`git merge-file failed for ${rel}: status=${result.status} stderr=${(result.stderr || '').toString()}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function runSemanticStage(rel, ctx) {
|
|
213
|
+
const base = await resolveBase(rel, ctx.baseline_version, ctx.target, {
|
|
214
|
+
oldManifest: ctx.oldManifest, pack: ctx.pack,
|
|
215
|
+
});
|
|
216
|
+
const remote = await readFile(join(ctx.templateDir, rel));
|
|
217
|
+
const local = await readFile(join(ctx.target, rel));
|
|
218
|
+
await writeStage(ctx, rel, base, remote, local);
|
|
219
|
+
return { kind: 'SEMANTIC_MERGE_STAGED', path: rel, reason: 'staged for /upgrade-project' };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function writeStageArtifact(stageDir, rel, bytes) {
|
|
223
|
+
const dst = join(stageDir, rel);
|
|
224
|
+
await mkdir(dirname(dst), { recursive: true });
|
|
225
|
+
await writeFile(dst, bytes);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function appendToStageManifest(stageDir, ctx, rel, baseBuf, incomingBuf, localBuf) {
|
|
229
|
+
const manifestPath = join(stageDir, 'manifest.json');
|
|
230
|
+
const manifest = existsSync(manifestPath)
|
|
231
|
+
? JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
232
|
+
: newStageManifest(ctx);
|
|
233
|
+
manifest.files.push({
|
|
234
|
+
rel,
|
|
235
|
+
base_sha256: sha256(baseBuf),
|
|
236
|
+
incoming_sha256: sha256(incomingBuf),
|
|
237
|
+
local_sha256: sha256(localBuf),
|
|
238
|
+
status: 'PENDING',
|
|
239
|
+
});
|
|
240
|
+
await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function newStageManifest(ctx) {
|
|
244
|
+
return {
|
|
245
|
+
stage_version: 1,
|
|
246
|
+
slug: ctx.slug ?? 'upgrade',
|
|
247
|
+
created_at: new Date().toISOString(),
|
|
248
|
+
baseline_version_from: ctx.oldManifest?.baseline_version ?? 'unknown',
|
|
249
|
+
baseline_version_to: ctx.baseline_version ?? 'unknown',
|
|
250
|
+
files: [],
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function stageTimestamp() {
|
|
255
|
+
return new Date().toISOString().replace(/[:.]/g, '-');
|
|
256
|
+
}
|
|
@@ -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
|
+
}
|