getadvantage 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/brief.mjs +634 -634
- package/checks-runner.mjs +136 -136
- package/checks.mjs +327 -327
- package/deploy.mjs +203 -203
- package/gauge.mjs +95 -0
- package/handoff.mjs +5 -0
- package/index.mjs +206 -181
- package/init.mjs +79 -0
- package/ledger.mjs +110 -0
- package/overviews.mjs +536 -536
- package/package.json +1 -1
- package/util.mjs +142 -142
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 };
|