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/deploy.mjs CHANGED
@@ -1,203 +1,203 @@
1
- // Ship-Safe — the safe-deploy ritual (`ship-safe deploy`).
2
- //
3
- // This productizes getAdvantage's hard-won deploy rules:
4
- // 1. `vercel --prod` ships the WORKING TREE, so we deploy from a CLEAN
5
- // DETACHED worktree of the exact target commit — never the live, possibly
6
- // dirty, shared checkout (a concurrent session's uncommitted work has
7
- // shipped to prod this way before).
8
- // 2. We CONFIRM the resulting deployment URL starts with the expected project
9
- // prefix (default "getadvantage-"); a different prefix (e.g. "plusxplus-")
10
- // means we deployed the WRONG project — STOP, non-zero exit.
11
- // 3. The Vercel token is read from an ENV VAR by NAME — never hardcoded,
12
- // never echoed/logged.
13
- //
14
- // IMPORTANT: this module is implemented but is only invoked when the founder
15
- // explicitly runs `ship-safe deploy`. It runs `vercel --prod` for real.
16
-
17
- import { execFileSync, spawnSync } from "node:child_process";
18
- import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
19
- import { tmpdir } from "node:os";
20
- import path from "node:path";
21
- import { c, git, gitSafe, section } from "./util.mjs";
22
- import { runChecks } from "./checks-runner.mjs";
23
-
24
- /** Read the linked Vercel project (.vercel/project.json), or null. Used to derive
25
- * a sensible wrong-project guard prefix when --expect-prefix isn't given. */
26
- function readVercelProject(cwd) {
27
- try {
28
- return JSON.parse(readFileSync(path.join(cwd, ".vercel", "project.json"), "utf8"));
29
- } catch {
30
- return null;
31
- }
32
- }
33
-
34
- /**
35
- * @param {object} o
36
- * @param {string} o.cwd repo root
37
- * @param {string} o.commit target commit-ish (default HEAD)
38
- * @param {string} o.expectPrefix required deployment-URL prefix (default: derived from .vercel projectName; guard skipped if none)
39
- * @param {string} o.scope Vercel team scope (passed through to vercel)
40
- * @param {string} o.tokenEnv NAME of the env var holding the Vercel token
41
- * @param {boolean} o.force deploy even if `check` returns NO-GO
42
- * @param {boolean} o.runBuild whether `check` should run a full build too
43
- */
44
- export async function deploy(o) {
45
- const cwd = o.cwd;
46
- const commit = o.commit || "HEAD";
47
- const tokenEnv = o.tokenEnv || "VERCEL_TOKEN";
48
- // The wrong-project guard. Prefer an explicit --expect-prefix; otherwise derive
49
- // it from the linked Vercel project so the guard works in ANY repo (not just
50
- // ours). If we can't derive one, the guard is disabled and we say so — we never
51
- // silently assume a project.
52
- let expectPrefix = o.expectPrefix;
53
- if (!expectPrefix) {
54
- const proj = readVercelProject(cwd);
55
- if (proj && typeof proj.projectName === "string" && proj.projectName) {
56
- expectPrefix = `${proj.projectName}-`;
57
- }
58
- }
59
-
60
- console.log(c.bold("\nShip-Safe — safe deploy"));
61
- console.log(
62
- c.dim(` target commit: ${commit} expect prefix: ${expectPrefix || "(none — wrong-project guard disabled)"}\n`),
63
- );
64
-
65
- // ---- 0. Resolve the token by NAME, never echo it. -----------------------
66
- const token = process.env[tokenEnv];
67
- if (!token) {
68
- console.error(c.red(`✗ Env var ${tokenEnv} is not set — can't authenticate to Vercel.`));
69
- console.error(c.gray(` Set it (e.g. from your User-scoped VERCEL_TOKEN) and retry; the value is never printed.`));
70
- return 1;
71
- }
72
-
73
- // ---- 1. Gate on `check` first (unless --force). -------------------------
74
- section("Pre-deploy checks");
75
- const { exitCode } = await runChecks({ cwd, runBuild: o.runBuild });
76
- if (exitCode !== 0) {
77
- if (!o.force) {
78
- console.error(c.red("\n✗ NO-GO — checks failed. Aborting deploy. (Re-run with --force to override at your own risk.)"));
79
- return 1;
80
- }
81
- console.error(c.yellow("\n⚠ Checks failed but --force was passed — proceeding anyway."));
82
- }
83
-
84
- // ---- 2. Resolve the exact commit SHA we're shipping. --------------------
85
- const sha = git(["rev-parse", commit], { cwd });
86
-
87
- // ---- 3. Create a CLEAN DETACHED worktree of that commit. ----------------
88
- // A detached worktree is a pristine checkout of the SHA — none of the live
89
- // checkout's uncommitted work can ride along. We place it in the OS temp dir
90
- // so we never disturb sibling repos / the shared folder.
91
- const wtDir = mkdtempSync(path.join(tmpdir(), "ship-safe-deploy-"));
92
- let deployed = false;
93
- let deployUrl = "";
94
- try {
95
- section("Clean worktree");
96
- console.log(c.gray(` Creating detached worktree at ${wtDir} @ ${sha.slice(0, 10)}`));
97
- // --detach: no branch, just the commit. The worktree is a clean tree.
98
- git(["worktree", "add", "--detach", wtDir, sha], { cwd });
99
-
100
- // ---- 4. Copy `.vercel` (project link) into the worktree. --------------
101
- // The worktree is a fresh checkout and won't contain the gitignored .vercel
102
- // dir, so vercel wouldn't know which project to target. Copy it over.
103
- const srcVercel = path.join(cwd, ".vercel");
104
- if (existsSync(srcVercel)) {
105
- cpSync(srcVercel, path.join(wtDir, ".vercel"), { recursive: true });
106
- console.log(c.gray(" Copied .vercel project link into the worktree."));
107
- } else {
108
- console.error(c.red("✗ No .vercel/ found in the repo — can't confirm the target project. Run `vercel link` first."));
109
- return 1;
110
- }
111
-
112
- // ---- 5. Run `vercel --prod` FROM the worktree. ------------------------
113
- // We pass the token via the --token flag value (read from env above) and the
114
- // scope through --scope. The token value is never logged by us; we spawn
115
- // with stdio inherited so vercel's own output streams to the founder.
116
- section("Deploying");
117
- const vercelArgs = ["--yes", "vercel", "--prod", "--yes"];
118
- if (o.scope) vercelArgs.push("--scope", o.scope);
119
- vercelArgs.push("--token", token); // value from env; not printed by us
120
-
121
- // Print a redacted version of the command (token masked) for transparency.
122
- const shown = vercelArgs.map((a) => (a === token ? "<token:hidden>" : a));
123
- console.log(c.gray(` npx ${shown.join(" ")} (cwd=${wtDir})`));
124
-
125
- // Capture stdout so we can read the deployment URL, but also echo it live.
126
- const proc = spawnSync("npx", vercelArgs, {
127
- cwd: wtDir,
128
- encoding: "utf8",
129
- shell: process.platform === "win32",
130
- maxBuffer: 32 * 1024 * 1024,
131
- });
132
- deployed = true;
133
- const stdout = proc.stdout || "";
134
- const stderr = proc.stderr || "";
135
- // Echo vercel's output (it normally prints the URL on stdout).
136
- if (stdout) process.stdout.write(stdout);
137
- if (stderr) process.stderr.write(stderr);
138
-
139
- if (proc.status !== 0) {
140
- console.error(c.red(`\n✗ vercel exited with code ${proc.status}. Deploy not confirmed.`));
141
- return proc.status || 1;
142
- }
143
-
144
- // ---- 6. Extract + CONFIRM the deployment URL prefix. ------------------
145
- // Vercel prints a https://<project>-<hash>-<scope>.vercel.app URL. Grab the
146
- // last vercel.app URL in the output and check its host's prefix.
147
- const urls = `${stdout}\n${stderr}`.match(/https:\/\/[a-z0-9-]+\.vercel\.app/gi) || [];
148
- deployUrl = urls.length ? urls[urls.length - 1] : "";
149
- if (!deployUrl) {
150
- console.error(c.red("\n✗ Could not find a deployment URL in vercel's output — STOP and verify manually."));
151
- return 1;
152
- }
153
-
154
- const host = deployUrl.replace(/^https:\/\//, "");
155
- section("Confirm target");
156
- if (expectPrefix) {
157
- if (!host.startsWith(expectPrefix)) {
158
- // WRONG PROJECT. This is the load-bearing guard — e.g. a "plusxplus-" host
159
- // means we shipped to the wrong product. Hard stop.
160
- console.error(
161
- c.red(
162
- `\n✗ STOP — deployment host "${host}" does NOT start with the expected "${expectPrefix}".`,
163
- ),
164
- );
165
- console.error(
166
- c.red(
167
- " You may have deployed the WRONG project. Verify in the Vercel dashboard and roll back if needed.",
168
- ),
169
- );
170
- return 2;
171
- }
172
- console.log(c.green(`✓ Deployed to ${deployUrl} — host prefix "${expectPrefix}" confirmed.`));
173
- } else {
174
- console.log(
175
- c.yellow(`⚠ Deployed to ${deployUrl} — no expected prefix set, so the wrong-project guard was skipped.`),
176
- );
177
- console.log(c.gray(" Pass --expect-prefix <project>- (or link a .vercel project) to enable the guard."));
178
- }
179
- return 0;
180
- } catch (e) {
181
- console.error(c.red(`\n✗ Deploy failed: ${e.message || e}`));
182
- return 1;
183
- } finally {
184
- // ---- 7. Always clean up the temp worktree. ---------------------------
185
- // Deregister it from git AND remove the dir, best-effort. We never touch
186
- // the live checkout here.
187
- try {
188
- gitSafe(["worktree", "remove", "--force", wtDir], { cwd });
189
- } catch {
190
- /* ignore */
191
- }
192
- try {
193
- if (existsSync(wtDir)) rmSync(wtDir, { recursive: true, force: true });
194
- } catch {
195
- /* ignore */
196
- }
197
- // Prune any dangling worktree admin entry.
198
- gitSafe(["worktree", "prune"], { cwd });
199
- if (deployed && deployUrl) {
200
- console.log(c.dim(" (temp worktree cleaned up.)"));
201
- }
202
- }
203
- }
1
+ // Ship-Safe — the safe-deploy ritual (`ship-safe deploy`).
2
+ //
3
+ // This productizes getAdvantage's hard-won deploy rules:
4
+ // 1. `vercel --prod` ships the WORKING TREE, so we deploy from a CLEAN
5
+ // DETACHED worktree of the exact target commit — never the live, possibly
6
+ // dirty, shared checkout (a concurrent session's uncommitted work has
7
+ // shipped to prod this way before).
8
+ // 2. We CONFIRM the resulting deployment URL starts with the expected project
9
+ // prefix (default "getadvantage-"); a different prefix (e.g. "plusxplus-")
10
+ // means we deployed the WRONG project — STOP, non-zero exit.
11
+ // 3. The Vercel token is read from an ENV VAR by NAME — never hardcoded,
12
+ // never echoed/logged.
13
+ //
14
+ // IMPORTANT: this module is implemented but is only invoked when the founder
15
+ // explicitly runs `ship-safe deploy`. It runs `vercel --prod` for real.
16
+
17
+ import { execFileSync, spawnSync } from "node:child_process";
18
+ import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import path from "node:path";
21
+ import { c, git, gitSafe, section } from "./util.mjs";
22
+ import { runChecks } from "./checks-runner.mjs";
23
+
24
+ /** Read the linked Vercel project (.vercel/project.json), or null. Used to derive
25
+ * a sensible wrong-project guard prefix when --expect-prefix isn't given. */
26
+ function readVercelProject(cwd) {
27
+ try {
28
+ return JSON.parse(readFileSync(path.join(cwd, ".vercel", "project.json"), "utf8"));
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * @param {object} o
36
+ * @param {string} o.cwd repo root
37
+ * @param {string} o.commit target commit-ish (default HEAD)
38
+ * @param {string} o.expectPrefix required deployment-URL prefix (default: derived from .vercel projectName; guard skipped if none)
39
+ * @param {string} o.scope Vercel team scope (passed through to vercel)
40
+ * @param {string} o.tokenEnv NAME of the env var holding the Vercel token
41
+ * @param {boolean} o.force deploy even if `check` returns NO-GO
42
+ * @param {boolean} o.runBuild whether `check` should run a full build too
43
+ */
44
+ export async function deploy(o) {
45
+ const cwd = o.cwd;
46
+ const commit = o.commit || "HEAD";
47
+ const tokenEnv = o.tokenEnv || "VERCEL_TOKEN";
48
+ // The wrong-project guard. Prefer an explicit --expect-prefix; otherwise derive
49
+ // it from the linked Vercel project so the guard works in ANY repo (not just
50
+ // ours). If we can't derive one, the guard is disabled and we say so — we never
51
+ // silently assume a project.
52
+ let expectPrefix = o.expectPrefix;
53
+ if (!expectPrefix) {
54
+ const proj = readVercelProject(cwd);
55
+ if (proj && typeof proj.projectName === "string" && proj.projectName) {
56
+ expectPrefix = `${proj.projectName}-`;
57
+ }
58
+ }
59
+
60
+ console.log(c.bold("\nShip-Safe — safe deploy"));
61
+ console.log(
62
+ c.dim(` target commit: ${commit} expect prefix: ${expectPrefix || "(none — wrong-project guard disabled)"}\n`),
63
+ );
64
+
65
+ // ---- 0. Resolve the token by NAME, never echo it. -----------------------
66
+ const token = process.env[tokenEnv];
67
+ if (!token) {
68
+ console.error(c.red(`✗ Env var ${tokenEnv} is not set — can't authenticate to Vercel.`));
69
+ console.error(c.gray(` Set it (e.g. from your User-scoped VERCEL_TOKEN) and retry; the value is never printed.`));
70
+ return 1;
71
+ }
72
+
73
+ // ---- 1. Gate on `check` first (unless --force). -------------------------
74
+ section("Pre-deploy checks");
75
+ const { exitCode } = await runChecks({ cwd, runBuild: o.runBuild });
76
+ if (exitCode !== 0) {
77
+ if (!o.force) {
78
+ console.error(c.red("\n✗ NO-GO — checks failed. Aborting deploy. (Re-run with --force to override at your own risk.)"));
79
+ return 1;
80
+ }
81
+ console.error(c.yellow("\n⚠ Checks failed but --force was passed — proceeding anyway."));
82
+ }
83
+
84
+ // ---- 2. Resolve the exact commit SHA we're shipping. --------------------
85
+ const sha = git(["rev-parse", commit], { cwd });
86
+
87
+ // ---- 3. Create a CLEAN DETACHED worktree of that commit. ----------------
88
+ // A detached worktree is a pristine checkout of the SHA — none of the live
89
+ // checkout's uncommitted work can ride along. We place it in the OS temp dir
90
+ // so we never disturb sibling repos / the shared folder.
91
+ const wtDir = mkdtempSync(path.join(tmpdir(), "ship-safe-deploy-"));
92
+ let deployed = false;
93
+ let deployUrl = "";
94
+ try {
95
+ section("Clean worktree");
96
+ console.log(c.gray(` Creating detached worktree at ${wtDir} @ ${sha.slice(0, 10)}`));
97
+ // --detach: no branch, just the commit. The worktree is a clean tree.
98
+ git(["worktree", "add", "--detach", wtDir, sha], { cwd });
99
+
100
+ // ---- 4. Copy `.vercel` (project link) into the worktree. --------------
101
+ // The worktree is a fresh checkout and won't contain the gitignored .vercel
102
+ // dir, so vercel wouldn't know which project to target. Copy it over.
103
+ const srcVercel = path.join(cwd, ".vercel");
104
+ if (existsSync(srcVercel)) {
105
+ cpSync(srcVercel, path.join(wtDir, ".vercel"), { recursive: true });
106
+ console.log(c.gray(" Copied .vercel project link into the worktree."));
107
+ } else {
108
+ console.error(c.red("✗ No .vercel/ found in the repo — can't confirm the target project. Run `vercel link` first."));
109
+ return 1;
110
+ }
111
+
112
+ // ---- 5. Run `vercel --prod` FROM the worktree. ------------------------
113
+ // We pass the token via the --token flag value (read from env above) and the
114
+ // scope through --scope. The token value is never logged by us; we spawn
115
+ // with stdio inherited so vercel's own output streams to the founder.
116
+ section("Deploying");
117
+ const vercelArgs = ["--yes", "vercel", "--prod", "--yes"];
118
+ if (o.scope) vercelArgs.push("--scope", o.scope);
119
+ vercelArgs.push("--token", token); // value from env; not printed by us
120
+
121
+ // Print a redacted version of the command (token masked) for transparency.
122
+ const shown = vercelArgs.map((a) => (a === token ? "<token:hidden>" : a));
123
+ console.log(c.gray(` npx ${shown.join(" ")} (cwd=${wtDir})`));
124
+
125
+ // Capture stdout so we can read the deployment URL, but also echo it live.
126
+ const proc = spawnSync("npx", vercelArgs, {
127
+ cwd: wtDir,
128
+ encoding: "utf8",
129
+ shell: process.platform === "win32",
130
+ maxBuffer: 32 * 1024 * 1024,
131
+ });
132
+ deployed = true;
133
+ const stdout = proc.stdout || "";
134
+ const stderr = proc.stderr || "";
135
+ // Echo vercel's output (it normally prints the URL on stdout).
136
+ if (stdout) process.stdout.write(stdout);
137
+ if (stderr) process.stderr.write(stderr);
138
+
139
+ if (proc.status !== 0) {
140
+ console.error(c.red(`\n✗ vercel exited with code ${proc.status}. Deploy not confirmed.`));
141
+ return proc.status || 1;
142
+ }
143
+
144
+ // ---- 6. Extract + CONFIRM the deployment URL prefix. ------------------
145
+ // Vercel prints a https://<project>-<hash>-<scope>.vercel.app URL. Grab the
146
+ // last vercel.app URL in the output and check its host's prefix.
147
+ const urls = `${stdout}\n${stderr}`.match(/https:\/\/[a-z0-9-]+\.vercel\.app/gi) || [];
148
+ deployUrl = urls.length ? urls[urls.length - 1] : "";
149
+ if (!deployUrl) {
150
+ console.error(c.red("\n✗ Could not find a deployment URL in vercel's output — STOP and verify manually."));
151
+ return 1;
152
+ }
153
+
154
+ const host = deployUrl.replace(/^https:\/\//, "");
155
+ section("Confirm target");
156
+ if (expectPrefix) {
157
+ if (!host.startsWith(expectPrefix)) {
158
+ // WRONG PROJECT. This is the load-bearing guard — e.g. a "plusxplus-" host
159
+ // means we shipped to the wrong product. Hard stop.
160
+ console.error(
161
+ c.red(
162
+ `\n✗ STOP — deployment host "${host}" does NOT start with the expected "${expectPrefix}".`,
163
+ ),
164
+ );
165
+ console.error(
166
+ c.red(
167
+ " You may have deployed the WRONG project. Verify in the Vercel dashboard and roll back if needed.",
168
+ ),
169
+ );
170
+ return 2;
171
+ }
172
+ console.log(c.green(`✓ Deployed to ${deployUrl} — host prefix "${expectPrefix}" confirmed.`));
173
+ } else {
174
+ console.log(
175
+ c.yellow(`⚠ Deployed to ${deployUrl} — no expected prefix set, so the wrong-project guard was skipped.`),
176
+ );
177
+ console.log(c.gray(" Pass --expect-prefix <project>- (or link a .vercel project) to enable the guard."));
178
+ }
179
+ return 0;
180
+ } catch (e) {
181
+ console.error(c.red(`\n✗ Deploy failed: ${e.message || e}`));
182
+ return 1;
183
+ } finally {
184
+ // ---- 7. Always clean up the temp worktree. ---------------------------
185
+ // Deregister it from git AND remove the dir, best-effort. We never touch
186
+ // the live checkout here.
187
+ try {
188
+ gitSafe(["worktree", "remove", "--force", wtDir], { cwd });
189
+ } catch {
190
+ /* ignore */
191
+ }
192
+ try {
193
+ if (existsSync(wtDir)) rmSync(wtDir, { recursive: true, force: true });
194
+ } catch {
195
+ /* ignore */
196
+ }
197
+ // Prune any dangling worktree admin entry.
198
+ gitSafe(["worktree", "prune"], { cwd });
199
+ if (deployed && deployUrl) {
200
+ console.log(c.dim(" (temp worktree cleaned up.)"));
201
+ }
202
+ }
203
+ }
package/gauge.mjs ADDED
@@ -0,0 +1,95 @@
1
+ // Ship-Safe — CONTEXT FUEL GAUGE (`ship-safe gauge`).
2
+ //
3
+ // "How heavy is this session — should I reset?" A long AI session gets slow as
4
+ // its context bloats; the cure is to save your place (`handoff`) and start fresh.
5
+ // This gauge nudges you to do that BEFORE it gets painful.
6
+ //
7
+ // HONESTY (hard): a CLI canNOT see your AI's context window or token count. This
8
+ // is a HEURISTIC built from REPO ACTIVITY since your last handoff — time elapsed,
9
+ // commits, and lines changed — a proxy for "how long you've been going." It never
10
+ // claims to measure your tokens; it's a reminder, not a measurement.
11
+ //
12
+ // Node built-ins only. ESM. Read-only (writes nothing).
13
+
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import path from "node:path";
16
+ import { c, gitSafe } from "./util.mjs";
17
+
18
+ function readJson(abs) {
19
+ try {
20
+ return JSON.parse(readFileSync(abs, "utf8"));
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ /** Parse "N files changed, A insertions(+), B deletions(-)" → A+B (lines touched). */
27
+ function parseChurn(shortstat) {
28
+ if (!shortstat) return 0;
29
+ let total = 0;
30
+ const ins = shortstat.match(/(\d+) insertion/);
31
+ const del = shortstat.match(/(\d+) deletion/);
32
+ if (ins) total += parseInt(ins[1], 10) || 0;
33
+ if (del) total += parseInt(del[1], 10) || 0;
34
+ return total;
35
+ }
36
+
37
+ function fmtAge(hours) {
38
+ if (hours == null) return "unknown";
39
+ if (hours < 1) return `${Math.max(1, Math.round(hours * 60))} min ago`;
40
+ if (hours < 48) return `${Math.round(hours)}h ago`;
41
+ return `${Math.round(hours / 24)}d ago`;
42
+ }
43
+
44
+ export function runGauge(o) {
45
+ const cwd = o.cwd;
46
+ const marker = readJson(path.join(cwd, ".ship-safe", "handoff.json"));
47
+ const head = gitSafe(["rev-parse", "HEAD"], { cwd });
48
+
49
+ // No save-point yet → can't gauge drift; nudge to set a baseline.
50
+ if (!marker || !marker.head_sha) {
51
+ console.log(` ${c.yellow("◔")} ${c.bold("No save-point yet.")}`);
52
+ console.log(` Run ${c.cyan("ship-safe handoff")} to set your baseline — then this gauge can tell you when a session is getting heavy.`);
53
+ return 0;
54
+ }
55
+
56
+ const lastHead = marker.head_sha;
57
+ let commits = 0;
58
+ const cnt = gitSafe(["rev-list", "--count", `${lastHead}..HEAD`], { cwd });
59
+ if (cnt) commits = parseInt(cnt, 10) || 0;
60
+
61
+ const committedChurn = parseChurn(gitSafe(["diff", "--shortstat", lastHead, "HEAD"], { cwd }));
62
+ const workingChurn = parseChurn(gitSafe(["diff", "--shortstat"], { cwd }));
63
+ const churn = committedChurn + workingChurn;
64
+
65
+ let hours = null;
66
+ if (marker.generated_at) {
67
+ const ms = Date.now() - Date.parse(marker.generated_at);
68
+ if (!Number.isNaN(ms)) hours = Math.max(0, ms / 3_600_000);
69
+ }
70
+
71
+ // Heuristic "session weight" — commits + lines/60 + hours/2. Deliberately rough.
72
+ const weight = commits + churn / 60 + (hours ?? 0) / 2;
73
+ let band, glyph, color, nudge;
74
+ if (weight < 4) {
75
+ band = "Fresh"; glyph = "●"; color = c.green;
76
+ nudge = "Plenty of runway — keep going.";
77
+ } else if (weight < 12) {
78
+ band = "Getting heavy"; glyph = "◐"; color = c.yellow;
79
+ nudge = "A good moment to `ship-safe handoff` soon, so your next session starts light.";
80
+ } else {
81
+ band = "Time to reset"; glyph = "◑"; color = c.red;
82
+ nudge = "Run `ship-safe handoff`, then start a fresh session — you'll keep everything and get your speed back.";
83
+ }
84
+
85
+ console.log(` ${color(glyph)} ${c.bold("Session weight: " + band)}`);
86
+ console.log(
87
+ c.gray(
88
+ ` since your last save-point (${fmtAge(hours)}): ` +
89
+ `${commits} commit(s), ~${churn} line(s) changed.`,
90
+ ),
91
+ );
92
+ console.log(` ${nudge}`);
93
+ console.log(c.gray(" (Heuristic from repo activity — not a read of your AI's context.)"));
94
+ return 0;
95
+ }
package/handoff.mjs CHANGED
@@ -24,6 +24,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
24
24
  import path from "node:path";
25
25
  import { c, gitSafe, relPath } from "./util.mjs";
26
26
  import { runBrief, briefStaleness } from "./brief.mjs";
27
+ import { appendLedger } from "./ledger.mjs";
27
28
 
28
29
  const DEFAULT_HANDOFF = "HANDOFF.md";
29
30
  const MARKER_DIR = ".ship-safe";
@@ -256,16 +257,22 @@ export function runHandoff(o) {
256
257
  };
257
258
  writeFileSync(markerPath(cwd), JSON.stringify(m, null, 2) + "\n", "utf8");
258
259
 
260
+ // ---- 3b. Append a compact entry to the session ledger (the continuity log).
261
+ const ledgerRel = appendLedger(cwd, { headSha: auto.head, branch: auto.branch, lastHead, notes, now });
262
+
259
263
  // ---- 4. Tell the human what to do next. ----------------------------------
260
264
  console.log(c.green(`✓ Handoff written → ${relPath(handoffAbs, cwd)}`));
265
+ console.log(c.gray(` Logged to the session ledger → ${ledgerRel} (\`ship-safe ledger\` for the history).`));
261
266
  if (firstTime) {
262
267
  console.log(
263
268
  c.yellow(" ▸ First handoff — fill in the short narrative (Where we are / Next steps) so the next session knows the plan."),
264
269
  );
265
270
  }
266
- console.log(c.gray(" Next session, paste this to pick up instantly:"));
267
- console.log(c.cyan(` Read PROJECT-BRIEF.md and ${relPath(handoffAbs, cwd)}, then continue where we left off.`));
268
- console.log(c.gray(" Commit both files your brain lives in the repo, not your tool."));
271
+ if (!o.quiet) {
272
+ console.log(c.gray(" Next session, paste this to pick up instantly:"));
273
+ console.log(c.cyan(` Read PROJECT-BRIEF.md and ${relPath(handoffAbs, cwd)}, then continue where we left off.`));
274
+ console.log(c.gray(" Commit both files — your brain lives in the repo, not your tool."));
275
+ }
269
276
  return 0;
270
277
  }
271
278