getadvantage 0.1.0 → 0.3.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/brief.mjs CHANGED
@@ -1,634 +1,634 @@
1
- // Ship-Safe — PROJECT BRAIN (`ship-safe brief`).
2
- //
3
- // A portable, repo-resident, provider-agnostic project-context file that ANY
4
- // model / session / tool can read on start — so you can switch from Claude to
5
- // Qwen (or just session to session) WITHOUT re-explaining the project. Your
6
- // brain lives in the REPO, not your tool.
7
- //
8
- // `ship-safe brief` generates / updates a markdown file (default
9
- // PROJECT-BRIEF.md at the repo root; `--out <path>` to relocate) from the REAL
10
- // repo — everything below is GENERATED by reading the tree, never invented:
11
- //
12
- // • What the app is — name + description (package.json), detected
13
- // stack/framework/builder, what it does (best-effort
14
- // from README / package description).
15
- // • Architecture map — REUSES the overview scanners (scanApiSurface /
16
- // scanIntegrations / scanSchedules) so the brief and
17
- // the `check` maps never drift: API surface, the
18
- // integrations/agents + their env keys, the schedules.
19
- // • How to work here — CLAUDE.md (if present), package.json scripts
20
- // (dev/build/test), and the detected deploy method.
21
- // • Current state — git branch, last few commits, what's in flight
22
- // (uncommitted + unmerged-branch counts).
23
- //
24
- // MAINTENANCE: every generated brief carries a small staleness marker (the HEAD
25
- // sha + a timestamp) in BOTH the markdown frontmatter and a machine-readable
26
- // .ship-safe/brief.json. `ship-safe brief --check` (and the hook the main
27
- // `check` calls) WARNS — never blocks — if the brief is missing or the repo has
28
- // moved on since it was generated.
29
- //
30
- // Node built-ins only. ESM. Generating the brief WRITES two files (the brief +
31
- // the marker); `--check` reads only.
32
-
33
- import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "node:fs";
34
- import { createHash } from "node:crypto";
35
- import path from "node:path";
36
- import { c, git, gitSafe, relPath } from "./util.mjs";
37
- import {
38
- scanApiSurface,
39
- apiRowTag,
40
- scanIntegrations,
41
- scanSchedules,
42
- scheduleRowTag,
43
- } from "./overviews.mjs";
44
-
45
- const DEFAULT_OUT = "PROJECT-BRIEF.md";
46
- const MARKER_DIR = ".ship-safe";
47
- const MARKER_FILE = "brief.json";
48
- // A stable banner so a model/tool can recognise this file by its first lines.
49
- const BANNER = "<!-- ship-safe:project-brief -->";
50
-
51
- // ===========================================================================
52
- // repo-fact readers (best-effort; never throw on a missing/oddly-shaped file)
53
- // ===========================================================================
54
-
55
- function readJson(abs) {
56
- try {
57
- return JSON.parse(readFileSync(abs, "utf8"));
58
- } catch {
59
- return null;
60
- }
61
- }
62
-
63
- function readTextSafe(abs, max = 200_000) {
64
- try {
65
- const st = statSync(abs);
66
- if (!st.isFile() || st.size > max) return "";
67
- return readFileSync(abs, "utf8");
68
- } catch {
69
- return "";
70
- }
71
- }
72
-
73
- /** package.json → { name, version, description, scripts, deps, devDeps }. */
74
- function readPackage(cwd) {
75
- const pkg = readJson(path.join(cwd, "package.json")) || {};
76
- return {
77
- name: typeof pkg.name === "string" ? pkg.name : "(unnamed)",
78
- version: typeof pkg.version === "string" ? pkg.version : "",
79
- description: typeof pkg.description === "string" ? pkg.description : "",
80
- scripts: pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {},
81
- deps: pkg.dependencies && typeof pkg.dependencies === "object" ? pkg.dependencies : {},
82
- devDeps: pkg.devDependencies && typeof pkg.devDependencies === "object" ? pkg.devDependencies : {},
83
- };
84
- }
85
-
86
- /**
87
- * Detect the stack/framework/builder from the dependency set + config files.
88
- * Returns an array of short human strings (e.g. "Next.js 16.2.0 (App Router)").
89
- * Generated from what's actually present — never assumed.
90
- */
91
- function detectStack(cwd, pkg) {
92
- const all = { ...pkg.deps, ...pkg.devDeps };
93
- const out = [];
94
- const ver = (name) => (all[name] ? ` ${String(all[name]).replace(/^[\^~]/, "")}` : "");
95
-
96
- // Framework / runtime.
97
- if (all.next) {
98
- const appRouter = existsSync(path.join(cwd, "app")) ? " (App Router)" : "";
99
- out.push(`Next.js${ver("next")}${appRouter}`);
100
- } else if (all["react-scripts"]) out.push("Create React App");
101
- else if (all.vite) out.push(`Vite${ver("vite")}`);
102
- else if (all.astro) out.push(`Astro${ver("astro")}`);
103
- else if (all.remix || all["@remix-run/react"]) out.push("Remix");
104
- else if (all.express) out.push(`Express${ver("express")}`);
105
-
106
- if (all.react && !out.some((s) => s.startsWith("Next.js") || s.startsWith("Remix"))) {
107
- out.push(`React${ver("react")}`);
108
- } else if (all.react) {
109
- out.push(`React${ver("react")}`);
110
- }
111
-
112
- // Language.
113
- if (all.typescript || existsSync(path.join(cwd, "tsconfig.json"))) {
114
- out.push(`TypeScript${ver("typescript")}`);
115
- }
116
-
117
- // Data layer (presence-driven, from this repo's real deps + the generic ones).
118
- if (all["@neondatabase/serverless"]) out.push("Neon serverless Postgres (prod)");
119
- if (all["@electric-sql/pglite"]) out.push("PGlite in-process Postgres (local fallback)");
120
- if (all["@prisma/client"] || all.prisma) out.push("Prisma");
121
- if (all["drizzle-orm"]) out.push("Drizzle ORM");
122
-
123
- // Notable libs that shape how the app works.
124
- if (all.stripe) out.push("Stripe (billing)");
125
- if (all.jose) out.push("jose (JWT sessions)");
126
- if (all.bcryptjs || all.bcrypt) out.push("bcrypt (password hashing)");
127
- if (all["mcp-handler"] || all["@modelcontextprotocol/sdk"]) out.push("MCP server (mcp-handler)");
128
-
129
- return out;
130
- }
131
-
132
- /** Detect the package manager from the lockfile present. */
133
- function detectPackageManager(cwd) {
134
- if (existsSync(path.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
135
- if (existsSync(path.join(cwd, "yarn.lock"))) return "yarn";
136
- if (existsSync(path.join(cwd, "package-lock.json"))) return "npm";
137
- if (existsSync(path.join(cwd, "bun.lockb"))) return "bun";
138
- return "npm";
139
- }
140
-
141
- /**
142
- * Detect the deploy method. We DON'T invent — we report what's observable:
143
- * • a co-located Ship-Safe deploy CLI (this repo) → the safe-deploy ritual
144
- * • a .vercel link / vercel.json → Vercel
145
- * • a Dockerfile / netlify.toml / .github workflows → note them
146
- */
147
- function detectDeploy(cwd) {
148
- const notes = [];
149
- if (existsSync(path.join(cwd, "cli", "ship-safe", "deploy.mjs"))) {
150
- notes.push("Ship-Safe safe-deploy ritual (`ship-safe deploy`) — clean detached worktree + host-prefix confirm.");
151
- }
152
- if (existsSync(path.join(cwd, ".vercel")) || existsSync(path.join(cwd, "vercel.json"))) {
153
- notes.push("Vercel (`vercel --prod` — CLI deploys the WORKING TREE, not a commit).");
154
- }
155
- if (existsSync(path.join(cwd, "netlify.toml"))) notes.push("Netlify (netlify.toml present).");
156
- if (existsSync(path.join(cwd, "Dockerfile"))) notes.push("Docker (Dockerfile present).");
157
- if (existsSync(path.join(cwd, ".github", "workflows"))) notes.push("GitHub Actions workflow(s) present (.github/workflows).");
158
- if (notes.length === 0) notes.push("No deploy config detected (no .vercel / vercel.json / Dockerfile / CI workflow).");
159
- return notes;
160
- }
161
-
162
- /**
163
- * Best-effort "what it does" sentence(s). Prefer the package description; fall
164
- * back to the first meaningful prose paragraph of the README (skipping the H1,
165
- * badges, and blank lines). Never invents — returns "" if nothing usable.
166
- */
167
- function whatItDoes(cwd, pkg) {
168
- if (pkg.description) return pkg.description.trim();
169
- // Try common README filenames.
170
- for (const name of ["README.md", "Readme.md", "readme.md"]) {
171
- const txt = readTextSafe(path.join(cwd, name));
172
- if (!txt) continue;
173
- const lines = txt.split("\n");
174
- const para = [];
175
- for (const raw of lines) {
176
- const line = raw.trim();
177
- if (!line) {
178
- if (para.length) break; // end of the first prose paragraph
179
- continue;
180
- }
181
- if (line.startsWith("#")) continue; // headings
182
- if (/^!?\[[^\]]*\]\([^)]*\)$/.test(line)) continue; // a lone image/badge link
183
- if (/^<!--/.test(line)) continue; // html comment
184
- para.push(line);
185
- if (para.join(" ").length > 320) break; // enough for a lede
186
- }
187
- if (para.length) {
188
- // Strip markdown emphasis for a cleaner one-liner.
189
- return para.join(" ").replace(/\*\*?/g, "").replace(/`/g, "").trim();
190
- }
191
- }
192
- return "";
193
- }
194
-
195
- // ===========================================================================
196
- // git state
197
- // ===========================================================================
198
-
199
- function gitState(cwd) {
200
- const branch = gitSafe(["rev-parse", "--abbrev-ref", "HEAD"], { cwd }) || "(unknown)";
201
- const head = gitSafe(["rev-parse", "HEAD"], { cwd });
202
- const commits = gitSafe(["log", "-5", "--format=%h %s"], { cwd })
203
- .split("\n")
204
- .filter(Boolean);
205
- // Uncommitted = tracked changes + untracked, counted separately.
206
- const porcelain = gitSafe(["status", "--porcelain"], { cwd })
207
- .split("\n")
208
- .filter((l) => l.length > 0);
209
- let tracked = 0;
210
- let untracked = 0;
211
- for (const l of porcelain) {
212
- if (l.startsWith("??")) untracked++;
213
- else tracked++;
214
- }
215
- // Unmerged branches (not yet merged into the default branch).
216
- const defaultBranch = gitSafe(["rev-parse", "--verify", "--quiet", "main"], { cwd })
217
- ? "main"
218
- : gitSafe(["rev-parse", "--verify", "--quiet", "master"], { cwd })
219
- ? "master"
220
- : null;
221
- let unmerged = 0;
222
- if (defaultBranch) {
223
- unmerged = gitSafe(["branch", "--no-merged", defaultBranch], { cwd })
224
- .split("\n")
225
- .filter((l) => l.trim().length > 0).length;
226
- }
227
- return { branch, head, commits, tracked, untracked, unmerged, defaultBranch };
228
- }
229
-
230
- // ===========================================================================
231
- // markdown rendering
232
- // ===========================================================================
233
-
234
- function mdEscape(s) {
235
- return String(s).replace(/\|/g, "\\|");
236
- }
237
-
238
- /** Build the full PROJECT-BRIEF.md text. */
239
- function renderBrief(cwd) {
240
- const pkg = readPackage(cwd);
241
- const stack = detectStack(cwd, pkg);
242
- const pm = detectPackageManager(cwd);
243
- const deploy = detectDeploy(cwd);
244
- const does = whatItDoes(cwd, pkg);
245
- const git = gitState(cwd);
246
- const api = scanApiSurface(cwd);
247
- const integ = scanIntegrations(cwd);
248
- const sched = scanSchedules(cwd);
249
- const now = new Date().toISOString();
250
- const repoName = path.basename(cwd);
251
-
252
- const claudePresent = existsSync(path.join(cwd, "CLAUDE.md"));
253
- const handoffPresent = existsSync(path.join(cwd, "HANDOFF.md"));
254
-
255
- const L = []; // lines
256
-
257
- // ---- frontmatter (machine-readable staleness marker) --------------------
258
- L.push("---");
259
- L.push("ship_safe_brief: 1");
260
- L.push(`generated_at: ${now}`);
261
- L.push(`head_sha: ${git.head || "(none)"}`);
262
- L.push(`branch: ${git.branch}`);
263
- L.push("generator: ship-safe brief");
264
- L.push("---");
265
- L.push("");
266
- L.push(BANNER);
267
- L.push("");
268
-
269
- // ---- "for the next agent/model" header ----------------------------------
270
- L.push(`# Project Brain — ${pkg.name}`);
271
- L.push("");
272
- L.push("> **Read this first to get up to speed on this project.** This is a");
273
- L.push("> portable, provider-agnostic brief generated from the repo itself by");
274
- L.push("> `ship-safe brief`. It lets any model, session, or tool start cold");
275
- L.push("> without re-explaining the project — your brain lives in the repo,");
276
- L.push("> not in your tool. Everything here is generated from the real tree;");
277
- L.push("> if it looks stale, run `ship-safe brief` to refresh it.");
278
- L.push(">");
279
- L.push("> This is the **COLD** layer — what the project *is* (slow-moving). For the");
280
- L.push("> **HOT** layer — where work *left off right now* (what you were doing, the");
281
- L.push("> next steps) — read `HANDOFF.md` if present, and run `ship-safe handoff` to");
282
- L.push("> refresh both before you switch sessions or models.");
283
- L.push("");
284
-
285
- // ---- 1. What the app is --------------------------------------------------
286
- L.push("## What this is");
287
- L.push("");
288
- if (does) {
289
- L.push(does);
290
- L.push("");
291
- }
292
- L.push(`- **Name:** ${pkg.name}${pkg.version ? ` (v${pkg.version})` : ""}`);
293
- L.push(`- **Repo dir:** \`${repoName}\``);
294
- if (stack.length) L.push(`- **Stack:** ${stack.join(" · ")}`);
295
- L.push(`- **Package manager:** ${pm}`);
296
- L.push("");
297
-
298
- // ---- 2. Architecture map (REUSED scanners) ------------------------------
299
- L.push("## Architecture map");
300
- L.push("");
301
- L.push("*Generated by the same scanners `ship-safe check` uses — so this map and");
302
- L.push("the check verdicts never drift.*");
303
- L.push("");
304
-
305
- // API surface.
306
- L.push("### API surface");
307
- L.push("");
308
- if (api.rows.length === 0) {
309
- L.push("No `app/api/**/route` files found.");
310
- } else {
311
- L.push(
312
- `${api.rows.length} route(s) · ${api.gatedCount} look gated (session or cron secret) · ` +
313
- `${api.mutatingCount} mutate (write) · **${api.dangerous.length} mutate without any obvious gate**.`,
314
- );
315
- L.push("");
316
- L.push("| Route | Methods | Posture |");
317
- L.push("|---|---|---|");
318
- const CAP = 80;
319
- for (const r of api.rows.slice(0, CAP)) {
320
- const methods = r.methods.length ? r.methods.join(", ") : "—";
321
- L.push(`| \`${mdEscape(r.url)}\` | ${mdEscape(methods)} | ${mdEscape(apiRowTag(r))} |`);
322
- }
323
- if (api.rows.length > CAP) L.push(`| … | | …and ${api.rows.length - CAP} more route(s) |`);
324
- if (api.dangerous.length > 0) {
325
- L.push("");
326
- L.push(
327
- `> ⚠ ${api.dangerous.length} route(s) mutate but show no auth/session/cron-secret check — confirm each is meant to be public.`,
328
- );
329
- }
330
- }
331
- L.push("");
332
-
333
- // Integrations / agents.
334
- L.push("### Integrations & agents");
335
- L.push("");
336
- const labels = [...integ.integrations.keys()].sort();
337
- if (labels.length === 0 && integ.clientSecretHits.length === 0) {
338
- L.push("No external LLM / 3rd-party integrations detected in `app/`.");
339
- } else {
340
- L.push(`${labels.length} integration(s) detected${integ.mcpRouteFound ? " (incl. an MCP server at `/api/mcp`)" : ""}.`);
341
- L.push("");
342
- L.push("| Integration | Backing env key(s) | Files |");
343
- L.push("|---|---|---|");
344
- for (const label of labels) {
345
- const e = integ.integrations.get(label);
346
- const keys = [...e.keys];
347
- const keyStr = keys.length ? keys.join(", ") : "_(no key — public endpoint)_";
348
- L.push(`| ${mdEscape(label)} | ${mdEscape(keyStr)} | ${e.files.size} |`);
349
- }
350
- if (integ.clientSecretHits.length > 0) {
351
- L.push("");
352
- L.push(
353
- `> ⚠ ${integ.clientSecretHits.length} secret env read(s) found in \`"use client"\` component(s) — those would ship in the browser bundle.`,
354
- );
355
- }
356
- }
357
- L.push("");
358
-
359
- // Schedules / jobs.
360
- L.push("### Schedules & jobs");
361
- L.push("");
362
- if (sched.rows.length === 0) {
363
- L.push("No cron routes or `vercel.json` crons found.");
364
- } else {
365
- L.push(
366
- `${sched.rows.length} job(s) · ${sched.cronCount} scheduled in \`vercel.json\` · ` +
367
- `${sched.ungated.length} ungated · ${sched.orphanRoutes.length} cron route(s) not wired to a schedule.`,
368
- );
369
- L.push("");
370
- L.push("| Job | Schedule | Gating |");
371
- L.push("|---|---|---|");
372
- for (const r of sched.rows) {
373
- const s = r.schedule ? r.schedule : "not in vercel.json";
374
- L.push(`| \`${mdEscape(r.url)}\` | ${mdEscape(s)} | ${mdEscape(scheduleRowTag(r))} |`);
375
- }
376
- }
377
- L.push("");
378
-
379
- // ---- 3. How to work here ------------------------------------------------
380
- L.push("## How to work here");
381
- L.push("");
382
- // Scripts.
383
- const scriptNames = Object.keys(pkg.scripts);
384
- if (scriptNames.length) {
385
- L.push("**Scripts** (from `package.json`):");
386
- L.push("");
387
- const order = ["dev", "build", "start", "test", "lint", "ship-safe"];
388
- const seen = new Set();
389
- const ordered = [
390
- ...order.filter((s) => pkg.scripts[s]),
391
- ...scriptNames.filter((s) => !order.includes(s)),
392
- ];
393
- for (const s of ordered) {
394
- if (seen.has(s)) continue;
395
- seen.add(s);
396
- L.push(`- \`${pm} run ${s}\` — \`${mdEscape(pkg.scripts[s])}\``);
397
- }
398
- L.push("");
399
- }
400
- // Conventions doc.
401
- if (claudePresent) {
402
- L.push("**Conventions:** `CLAUDE.md` is present at the repo root — read it for the");
403
- L.push("project's working rules, hard constraints, and architecture notes.");
404
- L.push("");
405
- }
406
- if (handoffPresent) {
407
- L.push("**Current status / in-flight work:** see `HANDOFF.md`.");
408
- L.push("");
409
- }
410
- // Deploy method.
411
- L.push("**Deploy:**");
412
- L.push("");
413
- for (const d of deploy) L.push(`- ${d}`);
414
- L.push("");
415
-
416
- // ---- 4. Current state ----------------------------------------------------
417
- L.push("## Current state");
418
- L.push("");
419
- L.push(`- **Branch:** \`${git.branch}\`${git.head ? ` @ \`${git.head.slice(0, 10)}\`` : ""}`);
420
- L.push(
421
- `- **Working tree:** ${git.tracked} tracked change(s), ${git.untracked} untracked file(s)` +
422
- `${git.tracked === 0 && git.untracked === 0 ? " — clean" : " — uncommitted work present"}`,
423
- );
424
- if (git.defaultBranch) {
425
- L.push(`- **Branches not merged into \`${git.defaultBranch}\`:** ${git.unmerged}`);
426
- }
427
- if (git.commits.length) {
428
- L.push("- **Last commits:**");
429
- for (const ln of git.commits) L.push(` - ${mdEscape(ln)}`);
430
- }
431
- L.push("");
432
-
433
- // ---- footer -------------------------------------------------------------
434
- L.push("---");
435
- L.push("");
436
- L.push(`_Generated by \`ship-safe brief\` at ${now} from \`${git.head ? git.head.slice(0, 10) : "(no HEAD)"}\`._`);
437
- L.push("_Regenerate after meaningful changes: `ship-safe brief`. The brief is repo-resident on purpose — commit it so any model/session starts here._");
438
- L.push("");
439
-
440
- return { text: L.join("\n"), head: git.head, generatedAt: now, branch: git.branch };
441
- }
442
-
443
- // ===========================================================================
444
- // staleness marker (.ship-safe/brief.json) + detection
445
- // ===========================================================================
446
-
447
- function markerPath(cwd) {
448
- return path.join(cwd, MARKER_DIR, MARKER_FILE);
449
- }
450
-
451
- /**
452
- * The brief's OWN artifacts — the files a `ship-safe brief` run writes. Staleness
453
- * must IGNORE changes to these (otherwise committing the freshly-generated brief
454
- * would immediately mark it stale, because committing it advances HEAD past the
455
- * sha the brief stamped). Paths are repo-relative, forward-slashed.
456
- */
457
- function briefArtifactPaths(cwd, out) {
458
- return new Set([
459
- relPath(path.resolve(cwd, out), cwd),
460
- `${MARKER_DIR}/${MARKER_FILE}`,
461
- ]);
462
- }
463
-
464
- /**
465
- * A content hash of the tracked CODE INPUTS the brief is derived from — i.e.
466
- * every tracked file EXCEPT the brief's own artifacts (PROJECT-BRIEF.md + the
467
- * .ship-safe/brief.json marker).
468
- *
469
- * We hash each tracked file's ACTUAL WORKING-TREE CONTENT (path + current bytes),
470
- * deliberately NOT its git blob object-id. The object-id would change the instant
471
- * a working-tree edit gets COMMITTED even though the bytes are identical — which
472
- * would make `generate brief → commit the brief → --check` report stale (the very
473
- * bug this guards against, since committing the brief advances HEAD/the index).
474
- * Content-hashing is commit-state-independent: the same bytes hash the same
475
- * whether they sit modified in the working tree or already committed. So:
476
- * • generating + committing the brief (only the EXCLUDED artifacts change) keeps
477
- * this hash constant → the brief never marks itself stale; while
478
- * • any real change to a tracked code file (committed OR uncommitted) flips it.
479
- *
480
- * Deletions are folded in via the file list itself (a removed path drops out, so
481
- * the hash changes). Returns null if git isn't usable (callers fall back to the
482
- * HEAD-sha comparison).
483
- */
484
- function codeInputsHash(cwd, out) {
485
- // -z → NUL-separated paths (robust to spaces / odd names, no quoting). This is
486
- // the set of tracked files; their CONTENT is what we hash.
487
- const listing = gitSafe(["ls-files", "-z"], { cwd });
488
- if (!listing) return null;
489
- const artifacts = briefArtifactPaths(cwd, out);
490
- const h = createHash("sha256");
491
-
492
- const files = listing.split("\0").filter(Boolean).sort();
493
- for (const rel of files) {
494
- if (artifacts.has(rel)) continue; // ignore the brief's own artifacts
495
- const abs = path.resolve(cwd, rel);
496
- let body = "";
497
- let present = false;
498
- try {
499
- const st = statSync(abs);
500
- if (st.isFile() && st.size <= 4_000_000) {
501
- body = readFileSync(abs, "utf8");
502
- present = true;
503
- } else if (st.isFile()) {
504
- // Oversized (e.g. a big asset): hash its size as a cheap proxy rather
505
- // than reading megabytes — a content change still moves the size often
506
- // enough, and we never want to OOM on a giant tracked binary.
507
- body = `__oversize:${st.size}__`;
508
- present = true;
509
- }
510
- } catch {
511
- // Tracked but missing from the working tree (deleted/unstaged-delete) —
512
- // fold the path in WITHOUT content so its removal still changes the hash.
513
- }
514
- h.update(rel + "\0" + (present ? "1" : "0") + "\0" + body + "\n");
515
- }
516
-
517
- return h.digest("hex");
518
- }
519
-
520
- function writeMarker(cwd, out, head, generatedAt, branch) {
521
- const dir = path.join(cwd, MARKER_DIR);
522
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
523
- const marker = {
524
- schema: 2,
525
- out: relPath(path.resolve(cwd, out), cwd),
526
- head_sha: head || null,
527
- // Content hash of the tracked code inputs EXCLUDING the brief's own
528
- // artifacts — this is what staleness keys on, so committing the brief
529
- // doesn't mark it stale. head_sha is kept for human display only.
530
- code_hash: codeInputsHash(cwd, out),
531
- branch: branch || null,
532
- generated_at: generatedAt,
533
- };
534
- writeFileSync(markerPath(cwd), JSON.stringify(marker, null, 2) + "\n", "utf8");
535
- }
536
-
537
- function readMarker(cwd) {
538
- return readJson(markerPath(cwd));
539
- }
540
-
541
- /**
542
- * Staleness verdict. Returns { status, reason } where status ∈
543
- * "ok" — brief present and matches the current HEAD
544
- * "missing" — no brief file (or no marker) at all
545
- * "stale" — brief exists but the repo has moved on since it was generated
546
- * Pure read — never writes.
547
- */
548
- export function briefStaleness(cwd, out = DEFAULT_OUT) {
549
- const briefAbs = path.resolve(cwd, out);
550
- const marker = readMarker(cwd);
551
-
552
- if (!existsSync(briefAbs)) {
553
- return { status: "missing", reason: `No project brief at ${relPath(briefAbs, cwd)}.` };
554
- }
555
- if (!marker) {
556
- return {
557
- status: "stale",
558
- reason: `Project brief exists but its ${MARKER_DIR}/${MARKER_FILE} marker is missing — can't confirm it's current.`,
559
- };
560
- }
561
- const head = gitSafe(["rev-parse", "HEAD"], { cwd });
562
-
563
- // Preferred signal: a content hash of the CODE INPUTS the brief is derived
564
- // from, EXCLUDING the brief's own artifacts. This is stable across the
565
- // generate→commit-the-brief step (which only touches those artifacts), so the
566
- // brief never marks itself stale — but flips the moment real code changes.
567
- if (marker.code_hash) {
568
- const now = codeInputsHash(cwd, out);
569
- if (now && now !== marker.code_hash) {
570
- return {
571
- status: "stale",
572
- reason: `Tracked code changed since the brief was generated — regenerate to reflect it.`,
573
- };
574
- }
575
- if (now) {
576
- return { status: "ok", reason: `Project brief is current (code inputs unchanged${head ? `, HEAD ${head.slice(0, 10)}` : ""}).` };
577
- }
578
- // git unusable now — fall through to the HEAD comparison below.
579
- }
580
-
581
- // Fallback (old markers / no git ls-files): compare the raw HEAD sha.
582
- if (head && marker.head_sha && head !== marker.head_sha) {
583
- return {
584
- status: "stale",
585
- reason: `Repo HEAD moved to ${head.slice(0, 10)} since the brief was generated at ${String(marker.head_sha).slice(0, 10)}.`,
586
- };
587
- }
588
- return { status: "ok", reason: `Project brief is current (HEAD ${head ? head.slice(0, 10) : "?"}).` };
589
- }
590
-
591
- // ===========================================================================
592
- // command entrypoints
593
- // ===========================================================================
594
-
595
- /**
596
- * `ship-safe brief` — generate / refresh the brief, or (`--check`) just report
597
- * staleness without writing.
598
- * @param {object} o
599
- * @param {string} o.cwd repo root
600
- * @param {string} [o.out] output path (default PROJECT-BRIEF.md)
601
- * @param {boolean} [o.check] check-only mode (no write)
602
- * @returns {number} exit code (0 always for generate; 0 even when stale in
603
- * --check mode — staleness WARNS, never blocks)
604
- */
605
- export function runBrief(o) {
606
- const cwd = o.cwd;
607
- const out = o.out || DEFAULT_OUT;
608
-
609
- if (o.check) {
610
- const s = briefStaleness(cwd, out);
611
- if (s.status === "ok") {
612
- console.log(` ${c.green("✓")} ${c.bold("Project brief")} — ${s.reason}`);
613
- } else {
614
- console.log(
615
- ` ${c.yellow("⚠")} ${c.bold("Project brief")} — ${s.reason}`,
616
- );
617
- console.log(` ${c.gray("project brief is stale, run `ship-safe brief` to refresh.")}`);
618
- }
619
- return 0; // a warning, never a NO-GO
620
- }
621
-
622
- // Generate.
623
- const { text, head, generatedAt, branch } = renderBrief(cwd);
624
- const briefAbs = path.resolve(cwd, out);
625
- writeFileSync(briefAbs, text, "utf8");
626
- writeMarker(cwd, out, head, generatedAt, branch);
627
-
628
- console.log(c.green(`✓ Project brief written → ${relPath(briefAbs, cwd)}`));
629
- console.log(c.gray(` Staleness marker → ${MARKER_DIR}/${MARKER_FILE} (HEAD ${head ? head.slice(0, 10) : "?"})`));
630
- console.log(c.gray(" Commit it so any model/session/tool starts here — your brain lives in the repo, not your tool."));
631
- return 0;
632
- }
633
-
634
- export { DEFAULT_OUT };
1
+ // Ship-Safe — PROJECT BRAIN (`ship-safe brief`).
2
+ //
3
+ // A portable, repo-resident, provider-agnostic project-context file that ANY
4
+ // model / session / tool can read on start — so you can switch from Claude to
5
+ // Qwen (or just session to session) WITHOUT re-explaining the project. Your
6
+ // brain lives in the REPO, not your tool.
7
+ //
8
+ // `ship-safe brief` generates / updates a markdown file (default
9
+ // PROJECT-BRIEF.md at the repo root; `--out <path>` to relocate) from the REAL
10
+ // repo — everything below is GENERATED by reading the tree, never invented:
11
+ //
12
+ // • What the app is — name + description (package.json), detected
13
+ // stack/framework/builder, what it does (best-effort
14
+ // from README / package description).
15
+ // • Architecture map — REUSES the overview scanners (scanApiSurface /
16
+ // scanIntegrations / scanSchedules) so the brief and
17
+ // the `check` maps never drift: API surface, the
18
+ // integrations/agents + their env keys, the schedules.
19
+ // • How to work here — CLAUDE.md (if present), package.json scripts
20
+ // (dev/build/test), and the detected deploy method.
21
+ // • Current state — git branch, last few commits, what's in flight
22
+ // (uncommitted + unmerged-branch counts).
23
+ //
24
+ // MAINTENANCE: every generated brief carries a small staleness marker (the HEAD
25
+ // sha + a timestamp) in BOTH the markdown frontmatter and a machine-readable
26
+ // .ship-safe/brief.json. `ship-safe brief --check` (and the hook the main
27
+ // `check` calls) WARNS — never blocks — if the brief is missing or the repo has
28
+ // moved on since it was generated.
29
+ //
30
+ // Node built-ins only. ESM. Generating the brief WRITES two files (the brief +
31
+ // the marker); `--check` reads only.
32
+
33
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "node:fs";
34
+ import { createHash } from "node:crypto";
35
+ import path from "node:path";
36
+ import { c, git, gitSafe, relPath } from "./util.mjs";
37
+ import {
38
+ scanApiSurface,
39
+ apiRowTag,
40
+ scanIntegrations,
41
+ scanSchedules,
42
+ scheduleRowTag,
43
+ } from "./overviews.mjs";
44
+
45
+ const DEFAULT_OUT = "PROJECT-BRIEF.md";
46
+ const MARKER_DIR = ".ship-safe";
47
+ const MARKER_FILE = "brief.json";
48
+ // A stable banner so a model/tool can recognise this file by its first lines.
49
+ const BANNER = "<!-- ship-safe:project-brief -->";
50
+
51
+ // ===========================================================================
52
+ // repo-fact readers (best-effort; never throw on a missing/oddly-shaped file)
53
+ // ===========================================================================
54
+
55
+ function readJson(abs) {
56
+ try {
57
+ return JSON.parse(readFileSync(abs, "utf8"));
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ function readTextSafe(abs, max = 200_000) {
64
+ try {
65
+ const st = statSync(abs);
66
+ if (!st.isFile() || st.size > max) return "";
67
+ return readFileSync(abs, "utf8");
68
+ } catch {
69
+ return "";
70
+ }
71
+ }
72
+
73
+ /** package.json → { name, version, description, scripts, deps, devDeps }. */
74
+ function readPackage(cwd) {
75
+ const pkg = readJson(path.join(cwd, "package.json")) || {};
76
+ return {
77
+ name: typeof pkg.name === "string" ? pkg.name : "(unnamed)",
78
+ version: typeof pkg.version === "string" ? pkg.version : "",
79
+ description: typeof pkg.description === "string" ? pkg.description : "",
80
+ scripts: pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {},
81
+ deps: pkg.dependencies && typeof pkg.dependencies === "object" ? pkg.dependencies : {},
82
+ devDeps: pkg.devDependencies && typeof pkg.devDependencies === "object" ? pkg.devDependencies : {},
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Detect the stack/framework/builder from the dependency set + config files.
88
+ * Returns an array of short human strings (e.g. "Next.js 16.2.0 (App Router)").
89
+ * Generated from what's actually present — never assumed.
90
+ */
91
+ function detectStack(cwd, pkg) {
92
+ const all = { ...pkg.deps, ...pkg.devDeps };
93
+ const out = [];
94
+ const ver = (name) => (all[name] ? ` ${String(all[name]).replace(/^[\^~]/, "")}` : "");
95
+
96
+ // Framework / runtime.
97
+ if (all.next) {
98
+ const appRouter = existsSync(path.join(cwd, "app")) ? " (App Router)" : "";
99
+ out.push(`Next.js${ver("next")}${appRouter}`);
100
+ } else if (all["react-scripts"]) out.push("Create React App");
101
+ else if (all.vite) out.push(`Vite${ver("vite")}`);
102
+ else if (all.astro) out.push(`Astro${ver("astro")}`);
103
+ else if (all.remix || all["@remix-run/react"]) out.push("Remix");
104
+ else if (all.express) out.push(`Express${ver("express")}`);
105
+
106
+ if (all.react && !out.some((s) => s.startsWith("Next.js") || s.startsWith("Remix"))) {
107
+ out.push(`React${ver("react")}`);
108
+ } else if (all.react) {
109
+ out.push(`React${ver("react")}`);
110
+ }
111
+
112
+ // Language.
113
+ if (all.typescript || existsSync(path.join(cwd, "tsconfig.json"))) {
114
+ out.push(`TypeScript${ver("typescript")}`);
115
+ }
116
+
117
+ // Data layer (presence-driven, from this repo's real deps + the generic ones).
118
+ if (all["@neondatabase/serverless"]) out.push("Neon serverless Postgres (prod)");
119
+ if (all["@electric-sql/pglite"]) out.push("PGlite in-process Postgres (local fallback)");
120
+ if (all["@prisma/client"] || all.prisma) out.push("Prisma");
121
+ if (all["drizzle-orm"]) out.push("Drizzle ORM");
122
+
123
+ // Notable libs that shape how the app works.
124
+ if (all.stripe) out.push("Stripe (billing)");
125
+ if (all.jose) out.push("jose (JWT sessions)");
126
+ if (all.bcryptjs || all.bcrypt) out.push("bcrypt (password hashing)");
127
+ if (all["mcp-handler"] || all["@modelcontextprotocol/sdk"]) out.push("MCP server (mcp-handler)");
128
+
129
+ return out;
130
+ }
131
+
132
+ /** Detect the package manager from the lockfile present. */
133
+ function detectPackageManager(cwd) {
134
+ if (existsSync(path.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
135
+ if (existsSync(path.join(cwd, "yarn.lock"))) return "yarn";
136
+ if (existsSync(path.join(cwd, "package-lock.json"))) return "npm";
137
+ if (existsSync(path.join(cwd, "bun.lockb"))) return "bun";
138
+ return "npm";
139
+ }
140
+
141
+ /**
142
+ * Detect the deploy method. We DON'T invent — we report what's observable:
143
+ * • a co-located Ship-Safe deploy CLI (this repo) → the safe-deploy ritual
144
+ * • a .vercel link / vercel.json → Vercel
145
+ * • a Dockerfile / netlify.toml / .github workflows → note them
146
+ */
147
+ function detectDeploy(cwd) {
148
+ const notes = [];
149
+ if (existsSync(path.join(cwd, "cli", "ship-safe", "deploy.mjs"))) {
150
+ notes.push("Ship-Safe safe-deploy ritual (`ship-safe deploy`) — clean detached worktree + host-prefix confirm.");
151
+ }
152
+ if (existsSync(path.join(cwd, ".vercel")) || existsSync(path.join(cwd, "vercel.json"))) {
153
+ notes.push("Vercel (`vercel --prod` — CLI deploys the WORKING TREE, not a commit).");
154
+ }
155
+ if (existsSync(path.join(cwd, "netlify.toml"))) notes.push("Netlify (netlify.toml present).");
156
+ if (existsSync(path.join(cwd, "Dockerfile"))) notes.push("Docker (Dockerfile present).");
157
+ if (existsSync(path.join(cwd, ".github", "workflows"))) notes.push("GitHub Actions workflow(s) present (.github/workflows).");
158
+ if (notes.length === 0) notes.push("No deploy config detected (no .vercel / vercel.json / Dockerfile / CI workflow).");
159
+ return notes;
160
+ }
161
+
162
+ /**
163
+ * Best-effort "what it does" sentence(s). Prefer the package description; fall
164
+ * back to the first meaningful prose paragraph of the README (skipping the H1,
165
+ * badges, and blank lines). Never invents — returns "" if nothing usable.
166
+ */
167
+ function whatItDoes(cwd, pkg) {
168
+ if (pkg.description) return pkg.description.trim();
169
+ // Try common README filenames.
170
+ for (const name of ["README.md", "Readme.md", "readme.md"]) {
171
+ const txt = readTextSafe(path.join(cwd, name));
172
+ if (!txt) continue;
173
+ const lines = txt.split("\n");
174
+ const para = [];
175
+ for (const raw of lines) {
176
+ const line = raw.trim();
177
+ if (!line) {
178
+ if (para.length) break; // end of the first prose paragraph
179
+ continue;
180
+ }
181
+ if (line.startsWith("#")) continue; // headings
182
+ if (/^!?\[[^\]]*\]\([^)]*\)$/.test(line)) continue; // a lone image/badge link
183
+ if (/^<!--/.test(line)) continue; // html comment
184
+ para.push(line);
185
+ if (para.join(" ").length > 320) break; // enough for a lede
186
+ }
187
+ if (para.length) {
188
+ // Strip markdown emphasis for a cleaner one-liner.
189
+ return para.join(" ").replace(/\*\*?/g, "").replace(/`/g, "").trim();
190
+ }
191
+ }
192
+ return "";
193
+ }
194
+
195
+ // ===========================================================================
196
+ // git state
197
+ // ===========================================================================
198
+
199
+ function gitState(cwd) {
200
+ const branch = gitSafe(["rev-parse", "--abbrev-ref", "HEAD"], { cwd }) || "(unknown)";
201
+ const head = gitSafe(["rev-parse", "HEAD"], { cwd });
202
+ const commits = gitSafe(["log", "-5", "--format=%h %s"], { cwd })
203
+ .split("\n")
204
+ .filter(Boolean);
205
+ // Uncommitted = tracked changes + untracked, counted separately.
206
+ const porcelain = gitSafe(["status", "--porcelain"], { cwd })
207
+ .split("\n")
208
+ .filter((l) => l.length > 0);
209
+ let tracked = 0;
210
+ let untracked = 0;
211
+ for (const l of porcelain) {
212
+ if (l.startsWith("??")) untracked++;
213
+ else tracked++;
214
+ }
215
+ // Unmerged branches (not yet merged into the default branch).
216
+ const defaultBranch = gitSafe(["rev-parse", "--verify", "--quiet", "main"], { cwd })
217
+ ? "main"
218
+ : gitSafe(["rev-parse", "--verify", "--quiet", "master"], { cwd })
219
+ ? "master"
220
+ : null;
221
+ let unmerged = 0;
222
+ if (defaultBranch) {
223
+ unmerged = gitSafe(["branch", "--no-merged", defaultBranch], { cwd })
224
+ .split("\n")
225
+ .filter((l) => l.trim().length > 0).length;
226
+ }
227
+ return { branch, head, commits, tracked, untracked, unmerged, defaultBranch };
228
+ }
229
+
230
+ // ===========================================================================
231
+ // markdown rendering
232
+ // ===========================================================================
233
+
234
+ function mdEscape(s) {
235
+ return String(s).replace(/\|/g, "\\|");
236
+ }
237
+
238
+ /** Build the full PROJECT-BRIEF.md text. */
239
+ function renderBrief(cwd) {
240
+ const pkg = readPackage(cwd);
241
+ const stack = detectStack(cwd, pkg);
242
+ const pm = detectPackageManager(cwd);
243
+ const deploy = detectDeploy(cwd);
244
+ const does = whatItDoes(cwd, pkg);
245
+ const git = gitState(cwd);
246
+ const api = scanApiSurface(cwd);
247
+ const integ = scanIntegrations(cwd);
248
+ const sched = scanSchedules(cwd);
249
+ const now = new Date().toISOString();
250
+ const repoName = path.basename(cwd);
251
+
252
+ const claudePresent = existsSync(path.join(cwd, "CLAUDE.md"));
253
+ const handoffPresent = existsSync(path.join(cwd, "HANDOFF.md"));
254
+
255
+ const L = []; // lines
256
+
257
+ // ---- frontmatter (machine-readable staleness marker) --------------------
258
+ L.push("---");
259
+ L.push("ship_safe_brief: 1");
260
+ L.push(`generated_at: ${now}`);
261
+ L.push(`head_sha: ${git.head || "(none)"}`);
262
+ L.push(`branch: ${git.branch}`);
263
+ L.push("generator: ship-safe brief");
264
+ L.push("---");
265
+ L.push("");
266
+ L.push(BANNER);
267
+ L.push("");
268
+
269
+ // ---- "for the next agent/model" header ----------------------------------
270
+ L.push(`# Project Brain — ${pkg.name}`);
271
+ L.push("");
272
+ L.push("> **Read this first to get up to speed on this project.** This is a");
273
+ L.push("> portable, provider-agnostic brief generated from the repo itself by");
274
+ L.push("> `ship-safe brief`. It lets any model, session, or tool start cold");
275
+ L.push("> without re-explaining the project — your brain lives in the repo,");
276
+ L.push("> not in your tool. Everything here is generated from the real tree;");
277
+ L.push("> if it looks stale, run `ship-safe brief` to refresh it.");
278
+ L.push(">");
279
+ L.push("> This is the **COLD** layer — what the project *is* (slow-moving). For the");
280
+ L.push("> **HOT** layer — where work *left off right now* (what you were doing, the");
281
+ L.push("> next steps) — read `HANDOFF.md` if present, and run `ship-safe handoff` to");
282
+ L.push("> refresh both before you switch sessions or models.");
283
+ L.push("");
284
+
285
+ // ---- 1. What the app is --------------------------------------------------
286
+ L.push("## What this is");
287
+ L.push("");
288
+ if (does) {
289
+ L.push(does);
290
+ L.push("");
291
+ }
292
+ L.push(`- **Name:** ${pkg.name}${pkg.version ? ` (v${pkg.version})` : ""}`);
293
+ L.push(`- **Repo dir:** \`${repoName}\``);
294
+ if (stack.length) L.push(`- **Stack:** ${stack.join(" · ")}`);
295
+ L.push(`- **Package manager:** ${pm}`);
296
+ L.push("");
297
+
298
+ // ---- 2. Architecture map (REUSED scanners) ------------------------------
299
+ L.push("## Architecture map");
300
+ L.push("");
301
+ L.push("*Generated by the same scanners `ship-safe check` uses — so this map and");
302
+ L.push("the check verdicts never drift.*");
303
+ L.push("");
304
+
305
+ // API surface.
306
+ L.push("### API surface");
307
+ L.push("");
308
+ if (api.rows.length === 0) {
309
+ L.push("No `app/api/**/route` files found.");
310
+ } else {
311
+ L.push(
312
+ `${api.rows.length} route(s) · ${api.gatedCount} look gated (session or cron secret) · ` +
313
+ `${api.mutatingCount} mutate (write) · **${api.dangerous.length} mutate without any obvious gate**.`,
314
+ );
315
+ L.push("");
316
+ L.push("| Route | Methods | Posture |");
317
+ L.push("|---|---|---|");
318
+ const CAP = 80;
319
+ for (const r of api.rows.slice(0, CAP)) {
320
+ const methods = r.methods.length ? r.methods.join(", ") : "—";
321
+ L.push(`| \`${mdEscape(r.url)}\` | ${mdEscape(methods)} | ${mdEscape(apiRowTag(r))} |`);
322
+ }
323
+ if (api.rows.length > CAP) L.push(`| … | | …and ${api.rows.length - CAP} more route(s) |`);
324
+ if (api.dangerous.length > 0) {
325
+ L.push("");
326
+ L.push(
327
+ `> ⚠ ${api.dangerous.length} route(s) mutate but show no auth/session/cron-secret check — confirm each is meant to be public.`,
328
+ );
329
+ }
330
+ }
331
+ L.push("");
332
+
333
+ // Integrations / agents.
334
+ L.push("### Integrations & agents");
335
+ L.push("");
336
+ const labels = [...integ.integrations.keys()].sort();
337
+ if (labels.length === 0 && integ.clientSecretHits.length === 0) {
338
+ L.push("No external LLM / 3rd-party integrations detected in `app/`.");
339
+ } else {
340
+ L.push(`${labels.length} integration(s) detected${integ.mcpRouteFound ? " (incl. an MCP server at `/api/mcp`)" : ""}.`);
341
+ L.push("");
342
+ L.push("| Integration | Backing env key(s) | Files |");
343
+ L.push("|---|---|---|");
344
+ for (const label of labels) {
345
+ const e = integ.integrations.get(label);
346
+ const keys = [...e.keys];
347
+ const keyStr = keys.length ? keys.join(", ") : "_(no key — public endpoint)_";
348
+ L.push(`| ${mdEscape(label)} | ${mdEscape(keyStr)} | ${e.files.size} |`);
349
+ }
350
+ if (integ.clientSecretHits.length > 0) {
351
+ L.push("");
352
+ L.push(
353
+ `> ⚠ ${integ.clientSecretHits.length} secret env read(s) found in \`"use client"\` component(s) — those would ship in the browser bundle.`,
354
+ );
355
+ }
356
+ }
357
+ L.push("");
358
+
359
+ // Schedules / jobs.
360
+ L.push("### Schedules & jobs");
361
+ L.push("");
362
+ if (sched.rows.length === 0) {
363
+ L.push("No cron routes or `vercel.json` crons found.");
364
+ } else {
365
+ L.push(
366
+ `${sched.rows.length} job(s) · ${sched.cronCount} scheduled in \`vercel.json\` · ` +
367
+ `${sched.ungated.length} ungated · ${sched.orphanRoutes.length} cron route(s) not wired to a schedule.`,
368
+ );
369
+ L.push("");
370
+ L.push("| Job | Schedule | Gating |");
371
+ L.push("|---|---|---|");
372
+ for (const r of sched.rows) {
373
+ const s = r.schedule ? r.schedule : "not in vercel.json";
374
+ L.push(`| \`${mdEscape(r.url)}\` | ${mdEscape(s)} | ${mdEscape(scheduleRowTag(r))} |`);
375
+ }
376
+ }
377
+ L.push("");
378
+
379
+ // ---- 3. How to work here ------------------------------------------------
380
+ L.push("## How to work here");
381
+ L.push("");
382
+ // Scripts.
383
+ const scriptNames = Object.keys(pkg.scripts);
384
+ if (scriptNames.length) {
385
+ L.push("**Scripts** (from `package.json`):");
386
+ L.push("");
387
+ const order = ["dev", "build", "start", "test", "lint", "ship-safe"];
388
+ const seen = new Set();
389
+ const ordered = [
390
+ ...order.filter((s) => pkg.scripts[s]),
391
+ ...scriptNames.filter((s) => !order.includes(s)),
392
+ ];
393
+ for (const s of ordered) {
394
+ if (seen.has(s)) continue;
395
+ seen.add(s);
396
+ L.push(`- \`${pm} run ${s}\` — \`${mdEscape(pkg.scripts[s])}\``);
397
+ }
398
+ L.push("");
399
+ }
400
+ // Conventions doc.
401
+ if (claudePresent) {
402
+ L.push("**Conventions:** `CLAUDE.md` is present at the repo root — read it for the");
403
+ L.push("project's working rules, hard constraints, and architecture notes.");
404
+ L.push("");
405
+ }
406
+ if (handoffPresent) {
407
+ L.push("**Current status / in-flight work:** see `HANDOFF.md`.");
408
+ L.push("");
409
+ }
410
+ // Deploy method.
411
+ L.push("**Deploy:**");
412
+ L.push("");
413
+ for (const d of deploy) L.push(`- ${d}`);
414
+ L.push("");
415
+
416
+ // ---- 4. Current state ----------------------------------------------------
417
+ L.push("## Current state");
418
+ L.push("");
419
+ L.push(`- **Branch:** \`${git.branch}\`${git.head ? ` @ \`${git.head.slice(0, 10)}\`` : ""}`);
420
+ L.push(
421
+ `- **Working tree:** ${git.tracked} tracked change(s), ${git.untracked} untracked file(s)` +
422
+ `${git.tracked === 0 && git.untracked === 0 ? " — clean" : " — uncommitted work present"}`,
423
+ );
424
+ if (git.defaultBranch) {
425
+ L.push(`- **Branches not merged into \`${git.defaultBranch}\`:** ${git.unmerged}`);
426
+ }
427
+ if (git.commits.length) {
428
+ L.push("- **Last commits:**");
429
+ for (const ln of git.commits) L.push(` - ${mdEscape(ln)}`);
430
+ }
431
+ L.push("");
432
+
433
+ // ---- footer -------------------------------------------------------------
434
+ L.push("---");
435
+ L.push("");
436
+ L.push(`_Generated by \`ship-safe brief\` at ${now} from \`${git.head ? git.head.slice(0, 10) : "(no HEAD)"}\`._`);
437
+ L.push("_Regenerate after meaningful changes: `ship-safe brief`. The brief is repo-resident on purpose — commit it so any model/session starts here._");
438
+ L.push("");
439
+
440
+ return { text: L.join("\n"), head: git.head, generatedAt: now, branch: git.branch };
441
+ }
442
+
443
+ // ===========================================================================
444
+ // staleness marker (.ship-safe/brief.json) + detection
445
+ // ===========================================================================
446
+
447
+ function markerPath(cwd) {
448
+ return path.join(cwd, MARKER_DIR, MARKER_FILE);
449
+ }
450
+
451
+ /**
452
+ * The brief's OWN artifacts — the files a `ship-safe brief` run writes. Staleness
453
+ * must IGNORE changes to these (otherwise committing the freshly-generated brief
454
+ * would immediately mark it stale, because committing it advances HEAD past the
455
+ * sha the brief stamped). Paths are repo-relative, forward-slashed.
456
+ */
457
+ function briefArtifactPaths(cwd, out) {
458
+ return new Set([
459
+ relPath(path.resolve(cwd, out), cwd),
460
+ `${MARKER_DIR}/${MARKER_FILE}`,
461
+ ]);
462
+ }
463
+
464
+ /**
465
+ * A content hash of the tracked CODE INPUTS the brief is derived from — i.e.
466
+ * every tracked file EXCEPT the brief's own artifacts (PROJECT-BRIEF.md + the
467
+ * .ship-safe/brief.json marker).
468
+ *
469
+ * We hash each tracked file's ACTUAL WORKING-TREE CONTENT (path + current bytes),
470
+ * deliberately NOT its git blob object-id. The object-id would change the instant
471
+ * a working-tree edit gets COMMITTED even though the bytes are identical — which
472
+ * would make `generate brief → commit the brief → --check` report stale (the very
473
+ * bug this guards against, since committing the brief advances HEAD/the index).
474
+ * Content-hashing is commit-state-independent: the same bytes hash the same
475
+ * whether they sit modified in the working tree or already committed. So:
476
+ * • generating + committing the brief (only the EXCLUDED artifacts change) keeps
477
+ * this hash constant → the brief never marks itself stale; while
478
+ * • any real change to a tracked code file (committed OR uncommitted) flips it.
479
+ *
480
+ * Deletions are folded in via the file list itself (a removed path drops out, so
481
+ * the hash changes). Returns null if git isn't usable (callers fall back to the
482
+ * HEAD-sha comparison).
483
+ */
484
+ function codeInputsHash(cwd, out) {
485
+ // -z → NUL-separated paths (robust to spaces / odd names, no quoting). This is
486
+ // the set of tracked files; their CONTENT is what we hash.
487
+ const listing = gitSafe(["ls-files", "-z"], { cwd });
488
+ if (!listing) return null;
489
+ const artifacts = briefArtifactPaths(cwd, out);
490
+ const h = createHash("sha256");
491
+
492
+ const files = listing.split("\0").filter(Boolean).sort();
493
+ for (const rel of files) {
494
+ if (artifacts.has(rel)) continue; // ignore the brief's own artifacts
495
+ const abs = path.resolve(cwd, rel);
496
+ let body = "";
497
+ let present = false;
498
+ try {
499
+ const st = statSync(abs);
500
+ if (st.isFile() && st.size <= 4_000_000) {
501
+ body = readFileSync(abs, "utf8");
502
+ present = true;
503
+ } else if (st.isFile()) {
504
+ // Oversized (e.g. a big asset): hash its size as a cheap proxy rather
505
+ // than reading megabytes — a content change still moves the size often
506
+ // enough, and we never want to OOM on a giant tracked binary.
507
+ body = `__oversize:${st.size}__`;
508
+ present = true;
509
+ }
510
+ } catch {
511
+ // Tracked but missing from the working tree (deleted/unstaged-delete) —
512
+ // fold the path in WITHOUT content so its removal still changes the hash.
513
+ }
514
+ h.update(rel + "\0" + (present ? "1" : "0") + "\0" + body + "\n");
515
+ }
516
+
517
+ return h.digest("hex");
518
+ }
519
+
520
+ function writeMarker(cwd, out, head, generatedAt, branch) {
521
+ const dir = path.join(cwd, MARKER_DIR);
522
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
523
+ const marker = {
524
+ schema: 2,
525
+ out: relPath(path.resolve(cwd, out), cwd),
526
+ head_sha: head || null,
527
+ // Content hash of the tracked code inputs EXCLUDING the brief's own
528
+ // artifacts — this is what staleness keys on, so committing the brief
529
+ // doesn't mark it stale. head_sha is kept for human display only.
530
+ code_hash: codeInputsHash(cwd, out),
531
+ branch: branch || null,
532
+ generated_at: generatedAt,
533
+ };
534
+ writeFileSync(markerPath(cwd), JSON.stringify(marker, null, 2) + "\n", "utf8");
535
+ }
536
+
537
+ function readMarker(cwd) {
538
+ return readJson(markerPath(cwd));
539
+ }
540
+
541
+ /**
542
+ * Staleness verdict. Returns { status, reason } where status ∈
543
+ * "ok" — brief present and matches the current HEAD
544
+ * "missing" — no brief file (or no marker) at all
545
+ * "stale" — brief exists but the repo has moved on since it was generated
546
+ * Pure read — never writes.
547
+ */
548
+ export function briefStaleness(cwd, out = DEFAULT_OUT) {
549
+ const briefAbs = path.resolve(cwd, out);
550
+ const marker = readMarker(cwd);
551
+
552
+ if (!existsSync(briefAbs)) {
553
+ return { status: "missing", reason: `No project brief at ${relPath(briefAbs, cwd)}.` };
554
+ }
555
+ if (!marker) {
556
+ return {
557
+ status: "stale",
558
+ reason: `Project brief exists but its ${MARKER_DIR}/${MARKER_FILE} marker is missing — can't confirm it's current.`,
559
+ };
560
+ }
561
+ const head = gitSafe(["rev-parse", "HEAD"], { cwd });
562
+
563
+ // Preferred signal: a content hash of the CODE INPUTS the brief is derived
564
+ // from, EXCLUDING the brief's own artifacts. This is stable across the
565
+ // generate→commit-the-brief step (which only touches those artifacts), so the
566
+ // brief never marks itself stale — but flips the moment real code changes.
567
+ if (marker.code_hash) {
568
+ const now = codeInputsHash(cwd, out);
569
+ if (now && now !== marker.code_hash) {
570
+ return {
571
+ status: "stale",
572
+ reason: `Tracked code changed since the brief was generated — regenerate to reflect it.`,
573
+ };
574
+ }
575
+ if (now) {
576
+ return { status: "ok", reason: `Project brief is current (code inputs unchanged${head ? `, HEAD ${head.slice(0, 10)}` : ""}).` };
577
+ }
578
+ // git unusable now — fall through to the HEAD comparison below.
579
+ }
580
+
581
+ // Fallback (old markers / no git ls-files): compare the raw HEAD sha.
582
+ if (head && marker.head_sha && head !== marker.head_sha) {
583
+ return {
584
+ status: "stale",
585
+ reason: `Repo HEAD moved to ${head.slice(0, 10)} since the brief was generated at ${String(marker.head_sha).slice(0, 10)}.`,
586
+ };
587
+ }
588
+ return { status: "ok", reason: `Project brief is current (HEAD ${head ? head.slice(0, 10) : "?"}).` };
589
+ }
590
+
591
+ // ===========================================================================
592
+ // command entrypoints
593
+ // ===========================================================================
594
+
595
+ /**
596
+ * `ship-safe brief` — generate / refresh the brief, or (`--check`) just report
597
+ * staleness without writing.
598
+ * @param {object} o
599
+ * @param {string} o.cwd repo root
600
+ * @param {string} [o.out] output path (default PROJECT-BRIEF.md)
601
+ * @param {boolean} [o.check] check-only mode (no write)
602
+ * @returns {number} exit code (0 always for generate; 0 even when stale in
603
+ * --check mode — staleness WARNS, never blocks)
604
+ */
605
+ export function runBrief(o) {
606
+ const cwd = o.cwd;
607
+ const out = o.out || DEFAULT_OUT;
608
+
609
+ if (o.check) {
610
+ const s = briefStaleness(cwd, out);
611
+ if (s.status === "ok") {
612
+ console.log(` ${c.green("✓")} ${c.bold("Project brief")} — ${s.reason}`);
613
+ } else {
614
+ console.log(
615
+ ` ${c.yellow("⚠")} ${c.bold("Project brief")} — ${s.reason}`,
616
+ );
617
+ console.log(` ${c.gray("project brief is stale, run `ship-safe brief` to refresh.")}`);
618
+ }
619
+ return 0; // a warning, never a NO-GO
620
+ }
621
+
622
+ // Generate.
623
+ const { text, head, generatedAt, branch } = renderBrief(cwd);
624
+ const briefAbs = path.resolve(cwd, out);
625
+ writeFileSync(briefAbs, text, "utf8");
626
+ writeMarker(cwd, out, head, generatedAt, branch);
627
+
628
+ console.log(c.green(`✓ Project brief written → ${relPath(briefAbs, cwd)}`));
629
+ console.log(c.gray(` Staleness marker → ${MARKER_DIR}/${MARKER_FILE} (HEAD ${head ? head.slice(0, 10) : "?"})`));
630
+ console.log(c.gray(" Commit it so any model/session/tool starts here — your brain lives in the repo, not your tool."));
631
+ return 0;
632
+ }
633
+
634
+ export { DEFAULT_OUT };