@oaklandzoo/ostup 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.
package/bin/cli.mjs CHANGED
@@ -5,12 +5,13 @@ import { dirname, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { loadDotEnv } from '../src/env-loader.mjs';
7
7
  import { setDryRun } from '../src/exec.mjs';
8
+ import { startRun, endRun, disableTracer, traceError } from '../src/tracer.mjs';
8
9
 
9
10
  loadDotEnv();
10
11
 
11
12
  const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
12
13
 
13
- const SUBCOMMANDS = new Set(['init', 'update', 'brief', 'export-pro']);
14
+ const SUBCOMMANDS = new Set(['init', 'update', 'brief', 'export-pro', 'doctor']);
14
15
 
15
16
  async function readPkg() {
16
17
  const raw = await readFile(resolve(PKG_ROOT, 'package.json'), 'utf8');
@@ -44,6 +45,7 @@ function parseArgs(argv) {
44
45
  else if (a === '--output') flags.output = argv[++i];
45
46
  else if (a.startsWith('--output=')) flags.output = a.slice('--output='.length);
46
47
  else if (a === '--white-label') flags.whiteLabel = true;
48
+ else if (a === '--no-log') flags.noLog = true;
47
49
  else if (a.startsWith('-')) {
48
50
  process.stderr.write(`unknown flag: ${a}\n`);
49
51
  process.exit(1);
@@ -66,6 +68,7 @@ function printHelp() {
66
68
  ' init Scaffold a new project (interactive or with --yes).',
67
69
  ' brief Run the 10-question operator intake; write docs/brief.md + brief.json.',
68
70
  ' export-pro Bundle brief + brand + content + initial PRD into a ZIP for client handoff.',
71
+ ' doctor Self-diagnosis: preflight + auth + permissions + disk + Chrome. Read-only.',
69
72
  ' update Refresh bundled templates from the pinned source.',
70
73
  '',
71
74
  'Flags for `ostup init`:',
@@ -93,6 +96,7 @@ function printHelp() {
93
96
  'Global flags:',
94
97
  ' --version, -v Print version and exit.',
95
98
  ' --help, -h Print this help and exit.',
99
+ ' --no-log Disable auto-logging to ~/.ostup/logs/. (default: write a log per run)',
96
100
  '',
97
101
  ].join('\n')
98
102
  );
@@ -171,6 +175,18 @@ if (subcommand === 'export-pro') {
171
175
  }
172
176
  }
173
177
 
178
+ if (subcommand === 'doctor') {
179
+ const { runDoctor, printDoctorReport } = await import('../src/doctor.mjs');
180
+ try {
181
+ const result = await runDoctor();
182
+ process.stdout.write(printDoctorReport(result));
183
+ process.exit(result.failCount > 0 ? 1 : 0);
184
+ } catch (err) {
185
+ process.stderr.write(`doctor failed: ${err.message}\n`);
186
+ process.exit(2);
187
+ }
188
+ }
189
+
174
190
  // subcommand === 'init'
175
191
  if (flags.kitOnly) {
176
192
  const { scaffold } = await import('../src/scaffold.mjs');
@@ -185,12 +201,20 @@ if (flags.kitOnly) {
185
201
  }
186
202
  }
187
203
 
204
+ if (flags.noLog) disableTracer();
205
+ const logPath = await startRun({ label: 'init' });
206
+ if (logPath) process.stdout.write(`Logging this run to ${logPath}\n`);
207
+
188
208
  const { runMvp } = await import('../src/mvp-flow.mjs');
189
209
  try {
190
210
  await runMvp({ flags });
211
+ await endRun({ ok: true });
191
212
  process.exit(0);
192
213
  } catch (err) {
214
+ await traceError('init', err);
215
+ await endRun({ ok: false });
193
216
  process.stderr.write(`${err.message}\n`);
217
+ if (logPath) process.stderr.write(`Full run log: ${logPath}\n`);
194
218
  const userErrors = new Set([
195
219
  'NO_TTY',
196
220
  'USER_ABORT',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oaklandzoo/ostup",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Scaffolds a new repo with the Ostup Agent Kit pre-installed: slash commands, doc templates, and a clean working state.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/doctor.mjs ADDED
@@ -0,0 +1,186 @@
1
+ // doctor.mjs: self-diagnosis subcommand. Runs preflight + auth checks + permissions + reports.
2
+
3
+ import { existsSync } from 'node:fs';
4
+ import { execSync } from 'node:child_process';
5
+ import { homedir } from 'node:os';
6
+ import { join, resolve, dirname } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { preflight } from './preflight.mjs';
9
+ import { checkGithubAuth, checkVercelAuth } from './credential-prompts.mjs';
10
+
11
+ function runOk(cmd) {
12
+ try {
13
+ execSync(cmd, { stdio: ['ignore', 'pipe', 'ignore'] });
14
+ return true;
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ function runCapture(cmd) {
21
+ try {
22
+ return execSync(cmd, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ export async function runDoctor() {
29
+ const findings = [];
30
+ let okCount = 0;
31
+ let failCount = 0;
32
+ let warnCount = 0;
33
+
34
+ function report(level, label, detail) {
35
+ findings.push({ level, label, detail });
36
+ if (level === 'ok') okCount++;
37
+ else if (level === 'fail') failCount++;
38
+ else warnCount++;
39
+ }
40
+
41
+ // === Preflight ===
42
+ const pf = preflight();
43
+ if (pf.ok) {
44
+ report('ok', 'preflight', 'all required binaries present (node 20+, git, gh, vercel)');
45
+ } else {
46
+ report('fail', `preflight: ${pf.failed?.name || 'unknown'}`, pf.reason || pf.failed?.fix || '(no detail)');
47
+ }
48
+
49
+ // === Node version detail ===
50
+ const nodeVer = runCapture('node --version');
51
+ if (nodeVer) {
52
+ report('ok', 'node', nodeVer);
53
+ }
54
+
55
+ // === Git config ===
56
+ const gitName = runCapture('git config --global user.name');
57
+ const gitEmail = runCapture('git config --global user.email');
58
+ if (gitName && gitEmail) {
59
+ report('ok', 'git config', `${gitName} <${gitEmail}>`);
60
+ } else {
61
+ report('fail', 'git config', 'user.name or user.email not set; commits will fail. Run: git config --global user.name "Your Name" && git config --global user.email you@example.com');
62
+ }
63
+
64
+ // === GitHub auth ===
65
+ const gh = checkGithubAuth();
66
+ if (gh.ok) {
67
+ const ghUser = runCapture('gh api user --jq .login') || '(unknown)';
68
+ report('ok', 'gh auth', `authenticated as ${ghUser} (source: ${gh.source})`);
69
+ // Check scopes
70
+ const scopes = runCapture('gh auth status 2>&1 | grep -oE "Token scopes:.*"');
71
+ if (scopes) {
72
+ report('ok', 'gh scopes', scopes.replace('Token scopes:', '').trim());
73
+ if (!scopes.includes('delete_repo')) {
74
+ report('warn', 'gh delete_repo scope', 'missing; synthetic test cleanup will require manual deletion. Run: gh auth refresh -h github.com -s delete_repo');
75
+ }
76
+ }
77
+ } else {
78
+ report('fail', 'gh auth', 'not authenticated; run: gh auth login');
79
+ }
80
+
81
+ // === Vercel auth ===
82
+ const v = checkVercelAuth();
83
+ if (v.ok) {
84
+ const vUser = runCapture('vercel whoami');
85
+ report('ok', 'vercel auth', `authenticated as ${vUser || '(unknown)'} (source: ${v.source})`);
86
+
87
+ // Detect personal-account-as-scope risk
88
+ const teams = runCapture('vercel teams ls 2>&1 | grep -E "^✔|^\\*" | head -5');
89
+ if (teams) {
90
+ report('ok', 'vercel teams', teams.replace(/\n/g, '; '));
91
+ }
92
+ } else {
93
+ report('fail', 'vercel auth', 'not authenticated; run: vercel login');
94
+ }
95
+
96
+ // === Vercel AI Gateway key (optional but needed for /generate-image) ===
97
+ if (process.env.VERCEL_AI_GATEWAY_KEY) {
98
+ report('ok', 'VERCEL_AI_GATEWAY_KEY', 'present (image generation enabled)');
99
+ } else {
100
+ report('warn', 'VERCEL_AI_GATEWAY_KEY', 'not set; /generate-image will fail until set. Get key at https://vercel.com/dashboard/ai-gateway');
101
+ }
102
+
103
+ // === Chrome for screenshot verification ===
104
+ const chromeMac = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
105
+ if (existsSync(chromeMac)) {
106
+ report('ok', 'chrome', 'present (visual verification supported per CLAUDE.md Part 19)');
107
+ } else if (runOk('which chromium 2>/dev/null') || runOk('which google-chrome 2>/dev/null')) {
108
+ report('ok', 'chrome', 'chromium or google-chrome on PATH');
109
+ } else {
110
+ report('warn', 'chrome', 'no Chrome detected; visual verification per CLAUDE.md Part 19 will not work. Install from https://www.google.com/chrome/');
111
+ }
112
+
113
+ // === Disk space in home dir ===
114
+ const dfHome = runCapture(`df -k ${homedir()} | tail -1 | awk '{print $4}'`);
115
+ if (dfHome) {
116
+ const kbFree = parseInt(dfHome, 10);
117
+ if (Number.isFinite(kbFree) && kbFree < 1024 * 1024) {
118
+ // less than 1 GB free
119
+ report('warn', 'disk space', `${(kbFree / 1024).toFixed(0)} MB free in ${homedir()}; scaffolds may fail (npm install needs ~500 MB)`);
120
+ } else {
121
+ report('ok', 'disk space', `${(kbFree / 1024 / 1024).toFixed(1)} GB free in ${homedir()}`);
122
+ }
123
+ }
124
+
125
+ // === Memory dir creatable ===
126
+ const memTest = join(homedir(), '.claude', 'projects', '__ostup_doctor_test__', 'memory');
127
+ try {
128
+ const { mkdir, rm } = await import('node:fs/promises');
129
+ await mkdir(memTest, { recursive: true });
130
+ await rm(memTest, { recursive: true, force: true });
131
+ report('ok', 'memory dir', 'can create ~/.claude/projects/<project>/memory at scaffold time');
132
+ } catch (err) {
133
+ report('fail', 'memory dir', `cannot create ~/.claude/projects/__ostup_doctor_test__/memory: ${err.message}`);
134
+ }
135
+
136
+ // === Log dir creatable ===
137
+ try {
138
+ const { mkdir } = await import('node:fs/promises');
139
+ await mkdir(join(homedir(), '.ostup', 'logs'), { recursive: true });
140
+ report('ok', 'log dir', `~/.ostup/logs/ writable (run logs land here)`);
141
+ } catch (err) {
142
+ report('fail', 'log dir', `cannot create ~/.ostup/logs/: ${err.message}`);
143
+ }
144
+
145
+ // === npm version (we don't call `npm bin` anymore, but warn on very old npm) ===
146
+ const npmVer = runCapture('npm --version');
147
+ if (npmVer) {
148
+ const major = parseInt(npmVer.split('.')[0], 10);
149
+ if (major < 9) {
150
+ report('warn', 'npm version', `${npmVer} is old; recommend npm 9+. Run: npm install -g npm@latest`);
151
+ } else {
152
+ report('ok', 'npm', npmVer);
153
+ }
154
+ }
155
+
156
+ // === ostup tarball install location (sanity) ===
157
+ const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
158
+ report('ok', 'ostup install', pkgRoot);
159
+
160
+ return { findings, okCount, warnCount, failCount };
161
+ }
162
+
163
+ export function printDoctorReport({ findings, okCount, warnCount, failCount }) {
164
+ const lines = [];
165
+ lines.push('');
166
+ lines.push('=====================================');
167
+ lines.push(' ostup doctor: self-diagnosis');
168
+ lines.push('=====================================');
169
+ lines.push('');
170
+ for (const f of findings) {
171
+ const sym = f.level === 'ok' ? ' OK ' : f.level === 'warn' ? ' WARN ' : ' FAIL ';
172
+ lines.push(`[${sym}] ${f.label.padEnd(28)} ${f.detail || ''}`);
173
+ }
174
+ lines.push('');
175
+ lines.push(`Summary: ${okCount} OK, ${warnCount} WARN, ${failCount} FAIL`);
176
+ lines.push('');
177
+ if (failCount > 0) {
178
+ lines.push('Fix the FAILs before running `ostup init`. WARNs are optional.');
179
+ } else if (warnCount > 0) {
180
+ lines.push('No blockers. WARNs are optional improvements.');
181
+ } else {
182
+ lines.push('Everything clean. Ready to scaffold.');
183
+ }
184
+ lines.push('');
185
+ return lines.join('\n');
186
+ }
package/src/exec.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  // exec.mjs: uniform shell call wrapper with logging, dry-run capture, and step-aware error formatting.
2
2
  import { execa } from 'execa';
3
+ import { traceStep, getLogPath } from './tracer.mjs';
3
4
 
4
5
  const HINTS = {
5
6
  'gh repo create': 'Confirm the repo name is not already taken under this owner.',
@@ -56,19 +57,55 @@ export async function run(step, cmd, args = [], opts = {}) {
56
57
  return { stdout: '', stderr: '', exitCode: 0, dryRun: true };
57
58
  }
58
59
  if (!_quiet) process.stdout.write(`[${step}] start: ${display}\n`);
60
+ const start = Date.now();
59
61
  try {
60
- return await _runner(cmd, args, { ...opts });
62
+ const result = await _runner(cmd, args, { ...opts });
63
+ const duration = Date.now() - start;
64
+ await traceStep({
65
+ step, cmd, args,
66
+ stdout: result?.stdout || '',
67
+ stderr: result?.stderr || '',
68
+ exitCode: result?.exitCode || 0,
69
+ durationMs: duration,
70
+ });
71
+ return result;
61
72
  } catch (err) {
62
- const stderr = (err && (err.stderr || err.shortMessage || err.message) || '').toString().trim();
63
- process.stderr.write(
64
- [
65
- `[${step}] FAIL`,
66
- ` Command: ${display}`,
67
- ` Stderr: ${stderr || '(empty)'}`,
68
- ` Fix: ${hintFor(cmd, args)}`,
69
- 'Exit 1.',
70
- ].join('\n') + '\n'
71
- );
73
+ const duration = Date.now() - start;
74
+ const stdout = (err && err.stdout || '').toString();
75
+ const stderr = (err && err.stderr || '').toString();
76
+ const message = (err && (err.shortMessage || err.message) || '').toString().trim();
77
+
78
+ // Capture both stdout AND stderr (was stderr-only). Some tools (vercel, gh) output errors to stdout.
79
+ const stdoutTail = stdout.split('\n').slice(-15).join('\n').trim();
80
+ const stderrTail = stderr.split('\n').slice(-15).join('\n').trim();
81
+ const logPath = getLogPath();
82
+
83
+ const lines = [`[${step}] FAIL`, ` Command: ${display}`];
84
+ if (stderrTail) {
85
+ lines.push(` Stderr (last 15 lines):`);
86
+ for (const l of stderrTail.split('\n')) lines.push(` ${l}`);
87
+ }
88
+ if (stdoutTail) {
89
+ lines.push(` Stdout (last 15 lines):`);
90
+ for (const l of stdoutTail.split('\n')) lines.push(` ${l}`);
91
+ }
92
+ if (!stderrTail && !stdoutTail && message) {
93
+ lines.push(` Error: ${message}`);
94
+ }
95
+ lines.push(` Fix: ${hintFor(cmd, args)}`);
96
+ if (logPath) {
97
+ lines.push(` Log: ${logPath}`);
98
+ }
99
+ lines.push('Exit 1.');
100
+ process.stderr.write(lines.join('\n') + '\n');
101
+
102
+ await traceStep({
103
+ step, cmd, args,
104
+ stdout, stderr,
105
+ exitCode: err?.exitCode || 1,
106
+ durationMs: duration,
107
+ });
108
+
72
109
  const e = new Error(`step ${step} failed`);
73
110
  e.code = 'STEP_FAILED';
74
111
  e.step = step;
package/src/mvp-flow.mjs CHANGED
@@ -19,6 +19,7 @@ import { REGISTRY, OPTIONAL_REGISTRY } from './templates.mjs';
19
19
  import { run as exec, isDryRun } from './exec.mjs';
20
20
  import { loadBrief, writeBriefFiles } from './brief/index.mjs';
21
21
  import { applyProfileOverlay } from './brief/profile-router.mjs';
22
+ import { applyWhiteLabel } from './white-label.mjs';
22
23
 
23
24
  const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
24
25
  const TEMPLATES_ROOT = resolve(PKG_ROOT, 'templates');
@@ -121,6 +122,13 @@ export async function runMvp({ flags = {}, cwd = process.cwd() } = {}) {
121
122
  await writeProjectEnvFiles({ targetDir, collected: creds.collected });
122
123
  await ensureGitignoreEnv({ targetDir });
123
124
 
125
+ if (flags.whiteLabel) {
126
+ const wl = await applyWhiteLabel({ targetDir });
127
+ if (wl.touched.length > 0) {
128
+ process.stdout.write(`[white-label] scrubbed OSTUP/Goodshin attribution in ${wl.touched.length} file(s)\n`);
129
+ }
130
+ }
131
+
124
132
  await exec('git-init', 'git', ['init'], { cwd: targetDir });
125
133
  await exec('git-branch','git', ['branch', '-M', 'main'], { cwd: targetDir });
126
134
  await exec('git-add', 'git', ['add', '.'], { cwd: targetDir });
package/src/scaffold.mjs CHANGED
@@ -51,6 +51,14 @@ export async function scaffold({ targetDir, flags, stdinIsTTY = process.stdin.is
51
51
  await writeOne({ entry, absTarget, tokens });
52
52
  }
53
53
 
54
+ if (flags.whiteLabel) {
55
+ const { applyWhiteLabel } = await import('./white-label.mjs');
56
+ const wl = await applyWhiteLabel({ targetDir: absTarget });
57
+ if (wl.touched.length > 0) {
58
+ process.stdout.write(`[white-label] scrubbed OSTUP/Goodshin attribution in ${wl.touched.length} file(s)\n`);
59
+ }
60
+ }
61
+
54
62
  initGitIfNeeded(absTarget);
55
63
  printNextSteps(absTarget);
56
64
  }
package/src/templates.mjs CHANGED
@@ -33,6 +33,7 @@ export const REGISTRY = [
33
33
  { src: '.claude/commands/break-into-stories.md', dest: '.claude/commands/break-into-stories.md' },
34
34
  { src: '.claude/commands/generate-brand-kit.md', dest: '.claude/commands/generate-brand-kit.md' },
35
35
  { src: '.claude/commands/generate-content-pack.md', dest: '.claude/commands/generate-content-pack.md' },
36
+ { src: '.claude/commands/handoff-package.md', dest: '.claude/commands/handoff-package.md' },
36
37
  { src: 'CLAUDE.md', dest: 'CLAUDE.md' },
37
38
  { src: 'AGENTS.md', dest: 'AGENTS.md' },
38
39
  { src: 'START_HERE.md', dest: 'START_HERE.md' },
package/src/tracer.mjs ADDED
@@ -0,0 +1,116 @@
1
+ // tracer.mjs: structured per-run log file. Writes every step + output to ~/.ostup/logs/<run-id>.log.
2
+ // Auto-enabled on every ostup init / brief / export-pro run unless --no-log is passed.
3
+
4
+ import { existsSync } from 'node:fs';
5
+ import { mkdir, appendFile } from 'node:fs/promises';
6
+ import { homedir } from 'node:os';
7
+ import { join } from 'node:path';
8
+
9
+ let _enabled = true;
10
+ let _logPath = null;
11
+ let _runId = null;
12
+
13
+ export function disableTracer() {
14
+ _enabled = false;
15
+ }
16
+
17
+ export function enableTracer() {
18
+ _enabled = true;
19
+ }
20
+
21
+ export function isTracerEnabled() {
22
+ return _enabled;
23
+ }
24
+
25
+ export async function startRun({ label = 'run' } = {}) {
26
+ if (!_enabled) return null;
27
+ _runId = `${label}-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}`;
28
+ const dir = join(homedir(), '.ostup', 'logs');
29
+ await mkdir(dir, { recursive: true });
30
+ _logPath = join(dir, `${_runId}.log`);
31
+ await appendFile(_logPath, `# ostup run ${_runId}\n# started: ${new Date().toISOString()}\n# cwd: ${process.cwd()}\n# argv: ${process.argv.slice(2).join(' ')}\n\n`);
32
+ return _logPath;
33
+ }
34
+
35
+ export async function trace(category, message) {
36
+ if (!_enabled || !_logPath) return;
37
+ const stamp = new Date().toISOString();
38
+ const line = `[${stamp}] [${category}] ${message}\n`;
39
+ try {
40
+ await appendFile(_logPath, line);
41
+ } catch {
42
+ // Logging must never crash the main flow.
43
+ }
44
+ }
45
+
46
+ export async function traceStep({ step, cmd, args = [], stdout = '', stderr = '', exitCode = 0, durationMs = 0 }) {
47
+ if (!_enabled || !_logPath) return;
48
+ const display = `${cmd} ${(args || []).join(' ')}`.trim();
49
+ const stdoutTail = (stdout || '').toString().split('\n').slice(-30).join('\n');
50
+ const stderrTail = (stderr || '').toString().split('\n').slice(-30).join('\n');
51
+ const lines = [
52
+ `## step ${step} | exit=${exitCode} | ${durationMs}ms`,
53
+ ` cmd: ${display}`,
54
+ ];
55
+ if (stdoutTail.trim()) {
56
+ lines.push(` stdout (last 30 lines):`);
57
+ for (const l of stdoutTail.split('\n')) lines.push(` ${l}`);
58
+ }
59
+ if (stderrTail.trim()) {
60
+ lines.push(` stderr (last 30 lines):`);
61
+ for (const l of stderrTail.split('\n')) lines.push(` ${l}`);
62
+ }
63
+ lines.push('');
64
+ try {
65
+ await appendFile(_logPath, lines.join('\n'));
66
+ } catch {
67
+ // ignore
68
+ }
69
+ }
70
+
71
+ export async function traceError(category, err) {
72
+ if (!_enabled || !_logPath) return;
73
+ const stamp = new Date().toISOString();
74
+ const lines = [
75
+ `## ERROR [${stamp}] [${category}]`,
76
+ ` message: ${err?.message || err}`,
77
+ ` code: ${err?.code || '(none)'}`,
78
+ ];
79
+ if (err?.cause) {
80
+ lines.push(` cause:`);
81
+ const causeStr = (err.cause.message || String(err.cause)).split('\n');
82
+ for (const l of causeStr.slice(0, 20)) lines.push(` ${l}`);
83
+ }
84
+ if (err?.stack) {
85
+ lines.push(` stack (first 10 lines):`);
86
+ for (const l of err.stack.split('\n').slice(0, 10)) lines.push(` ${l}`);
87
+ }
88
+ lines.push('');
89
+ try {
90
+ await appendFile(_logPath, lines.join('\n'));
91
+ } catch {
92
+ // ignore
93
+ }
94
+ }
95
+
96
+ export async function endRun({ ok = true } = {}) {
97
+ if (!_enabled || !_logPath) return null;
98
+ const stamp = new Date().toISOString();
99
+ try {
100
+ await appendFile(_logPath, `\n# ended: ${stamp}\n# status: ${ok ? 'ok' : 'failed'}\n`);
101
+ } catch {
102
+ // ignore
103
+ }
104
+ const path = _logPath;
105
+ _logPath = null;
106
+ _runId = null;
107
+ return path;
108
+ }
109
+
110
+ export function getLogPath() {
111
+ return _logPath;
112
+ }
113
+
114
+ export function getRunId() {
115
+ return _runId;
116
+ }
@@ -0,0 +1,54 @@
1
+ // white-label.mjs: post-process specific generated files to strip OSTUP / Goodshin attribution.
2
+ // Used when `ostup init --white-label` is passed (Studio tier).
3
+
4
+ import { readFile, writeFile } from 'node:fs/promises';
5
+ import { existsSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+
8
+ const TARGETS = [
9
+ 'AGENTS.md',
10
+ 'README.md',
11
+ 'START_HERE.md',
12
+ '.claude/commands/bootstrap.md',
13
+ '.claude/commands/prompt-start.md',
14
+ '.claude/commands/prompt-end.md',
15
+ '.claude/commands/preflight.md',
16
+ ];
17
+
18
+ const REPLACEMENTS = [
19
+ { from: /Ostup Agent Kit/g, to: 'Project Kit' },
20
+ { from: /the Ostup Agent Kit/g, to: 'the Project Kit' },
21
+ { from: /scaffolded with ostup/gi, to: 'scaffolded' },
22
+ { from: /Built by Goodshin\.?/gi, to: '' },
23
+ { from: /Generated by `ostup brief`/g, to: 'Generated' },
24
+ { from: /Generated by `ostup [a-z-]+`/g, to: 'Generated' },
25
+ { from: /\bostup CLI subcommands.*\n/g, to: '' },
26
+ { from: /^\s*-\s*`ostup [a-z-]+`.*$/gm, to: '' },
27
+ { from: /\bnpx @oaklandzoo\/ostup [a-z-]+/g, to: 'project tooling' },
28
+ { from: /\(Vercel \+ GitHub \+ Goodshin\)/g, to: '(Vercel + GitHub)' },
29
+ ];
30
+
31
+ export async function applyWhiteLabel({ targetDir } = {}) {
32
+ const touched = [];
33
+ for (const rel of TARGETS) {
34
+ const path = join(targetDir, rel);
35
+ if (!existsSync(path)) continue;
36
+ let body = await readFile(path, 'utf8');
37
+ let changed = false;
38
+ for (const rule of REPLACEMENTS) {
39
+ if (rule.to === undefined) continue;
40
+ const next = body.replace(rule.from, rule.to);
41
+ if (next !== body) {
42
+ body = next;
43
+ changed = true;
44
+ }
45
+ }
46
+ if (changed) {
47
+ // Collapse stray double blank lines that replacements may have left.
48
+ body = body.replace(/\n{3,}/g, '\n\n');
49
+ await writeFile(path, body, 'utf8');
50
+ touched.push(rel);
51
+ }
52
+ }
53
+ return { touched };
54
+ }
@@ -0,0 +1,123 @@
1
+ ---
2
+ description: Bundle the current project state into a single client-ready handoff document. Useful for agencies handing off a project to a client or a new developer. Writes docs/HANDOFF_PACKAGE.md and optionally calls `ostup export-pro` for the ZIP.
3
+ ---
4
+
5
+ # /handoff-package
6
+
7
+ The Studio-tier (or any operator's) equivalent of a one-page handoff summary. Distills the current state into a single Markdown doc that someone new to the project can read in 10 minutes and understand:
8
+
9
+ - What this project is
10
+ - What is shipped
11
+ - What is in flight
12
+ - How to run it locally
13
+ - How to deploy
14
+ - Who to ask about what
15
+
16
+ ## Step 1: read context
17
+
18
+ ```bash
19
+ [ -f docs/brief.md ] && head -80 docs/brief.md
20
+ [ -f HANDOFF.md ] && cat HANDOFF.md
21
+ [ -f docs/PROJECT_STATE.md ] && cat docs/PROJECT_STATE.md
22
+ [ -f docs/MANUAL_TASKS.md ] && cat docs/MANUAL_TASKS.md
23
+ [ -f docs/ARCHITECTURE.md ] && cat docs/ARCHITECTURE.md
24
+ [ -f README.md ] && head -40 README.md
25
+ ls tasks/ 2>/dev/null
26
+ git log --oneline -10
27
+ ```
28
+
29
+ ## Step 2: write `docs/HANDOFF_PACKAGE.md`
30
+
31
+ ```markdown
32
+ # Handoff: <project name>
33
+
34
+ > One-page client-ready overview. Generated <YYYY-MM-DD>.
35
+ > If something here is wrong, the source of truth is `docs/brief.md` and the git log.
36
+
37
+ ## 1. What this project is
38
+
39
+ <one paragraph from brief.summary + audience>
40
+
41
+ ## 2. Where it lives
42
+
43
+ - **Repo:** <git remote URL>
44
+ - **Live URL:** <from HANDOFF or brief>
45
+ - **Latest commit:** <SHA + subject>
46
+
47
+ ## 3. What is shipped
48
+
49
+ <from PROJECT_STATE "Recently done" + git log of last 10 commits>
50
+
51
+ ## 4. What is in flight
52
+
53
+ <from HANDOFF "Active context" / "What to do next">
54
+
55
+ ## 5. How to run it locally
56
+
57
+ ```bash
58
+ git clone <repo URL>
59
+ cd <project>
60
+ npm install
61
+ cp .env.example .env.local # fill in the values
62
+ npm run dev
63
+ ```
64
+
65
+ ## 6. Environment variables required
66
+
67
+ | Var | Purpose | Where to get it |
68
+ |---|---|---|
69
+ <list each from .env.example with brief purpose>
70
+
71
+ ## 7. How to deploy
72
+
73
+ <from ARCHITECTURE.md or PROJECT_STATE — Vercel default; document any custom CI/CD>
74
+
75
+ ## 8. Profile and add-ons
76
+
77
+ <from brief.scaffold.profile + brief.scaffold.addons; explain each add-on briefly>
78
+
79
+ ## 9. Blockers + things the operator must do manually
80
+
81
+ <from MANUAL_TASKS.md "Active" section>
82
+
83
+ ## 10. Who to ask
84
+
85
+ - **Project owner:** <from brief.project.owner_or_client>
86
+ - **Last agent / dev:** <from HANDOFF if recorded>
87
+
88
+ ## 11. Recommended next steps for whoever picks this up
89
+
90
+ 1. Run `/preflight` to confirm Vercel + GitHub + env state.
91
+ 2. Run `/prompt-start` (or `/resume` if HANDOFF feels stale).
92
+ 3. Read `docs/brief.md` end to end.
93
+ 4. Pick the first item from "What is in flight" and confirm before changing anything.
94
+ ```
95
+
96
+ ## Step 3: optionally bundle into a ZIP
97
+
98
+ Ask the operator: "Bundle into a ZIP via `ostup export-pro`?"
99
+
100
+ If yes:
101
+
102
+ ```bash
103
+ ostup export-pro --output handoff-<project>-$(date +%Y%m%d).zip
104
+ ```
105
+
106
+ The ZIP includes `docs/HANDOFF_PACKAGE.md` plus the standard pro-export contents (brief, brand, content, initial PRD, agent kit).
107
+
108
+ ## Step 4: report
109
+
110
+ ```
111
+ Handoff package: docs/HANDOFF_PACKAGE.md
112
+
113
+ Optional bundle: handoff-<project>-<YYYY-MM-DD>.zip (run ostup export-pro to create)
114
+
115
+ The recipient can read the package in ~10 minutes and pick up the work.
116
+ ```
117
+
118
+ ## Hard rules
119
+
120
+ - Pull every fact from existing files (brief, HANDOFF, PROJECT_STATE, ARCHITECTURE, MANUAL_TASKS, git log). Do not invent.
121
+ - If a section has no source, write `_TBD — operator to fill_` rather than guess.
122
+ - This is a SNAPSHOT. Regenerate when you re-hand-off.
123
+ - Operator can edit the file directly to refine; do not auto-regenerate on every session.
@@ -29,7 +29,7 @@ Operator materials live in `{{INPUTS_PATH}}`. Read `{{INPUTS_PATH}}README.md` fo
29
29
 
30
30
  Session lifecycle: `/bootstrap`, `/prompt-start`, `/prompt-mid`, `/prompt-end`, `/preflight`, `/resume`, `/handoff-doctor`
31
31
 
32
- Building: `/create-prd`, `/break-into-stories`, `/generate-tasks`, `/update-image`, `/update-gui`, `/update-backend`, `/add-storage`, `/generate-image-prompt`, `/generate-image`
32
+ Building: `/create-prd`, `/break-into-stories`, `/generate-tasks`, `/update-image`, `/update-gui`, `/update-backend`, `/add-storage`, `/generate-image-prompt`, `/generate-image`, `/generate-brand-kit`, `/generate-content-pack`, `/handoff-package`
33
33
 
34
34
  See each file under `.claude/commands/` for the full routine.
35
35
 
@@ -38,6 +38,8 @@ See each file under `.claude/commands/` for the full routine.
38
38
  - `ostup brief` — 10-question operator intake; writes `docs/brief.md`, `docs/brief.json`, `tasks/prd-initial-build.md`.
39
39
  - `ostup init --brief <path>` — scaffold using an existing brief.json; applies the matching profile overlay.
40
40
  - `ostup init` — interactive scaffold without brief.
41
+ - `ostup init --white-label` — Studio tier; strips OSTUP/Goodshin attribution from generated docs.
42
+ - `ostup export-pro` — bundle docs/brief + assets/brand + assets/content + initial PRD into a ZIP for client handoff.
41
43
 
42
44
  ## Helpers
43
45