@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 +25 -1
- package/package.json +1 -1
- package/src/doctor.mjs +186 -0
- package/src/exec.mjs +48 -11
- package/src/mvp-flow.mjs +8 -0
- package/src/scaffold.mjs +8 -0
- package/src/templates.mjs +1 -0
- package/src/tracer.mjs +116 -0
- package/src/white-label.mjs +54 -0
- package/templates/.claude/commands/handoff-package.md +123 -0
- package/templates/AGENTS.md +3 -1
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
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
|
-
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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.
|
package/templates/AGENTS.md
CHANGED
|
@@ -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
|
|