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/index.mjs
CHANGED
|
@@ -1,181 +1,206 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Ship-Safe — "is this safe to ship?"
|
|
3
|
-
//
|
|
4
|
-
// A local, dependency-free pre-deploy auditor for AI-built apps. Run it in your
|
|
5
|
-
// repo BEFORE you deploy and it gives a plain-language GO / NO-GO:
|
|
6
|
-
//
|
|
7
|
-
// • dirty-tree guard — `vercel --prod` ships the working tree, so a dirty
|
|
8
|
-
// tree (or another session's work) would ship live
|
|
9
|
-
// • secret scan — leaked keys in committed/staged files
|
|
10
|
-
// • build + typecheck — `tsc --noEmit` (and `--build` for a full build)
|
|
11
|
-
// • schema-bump check — DDL changed without a SCHEMA_VERSION bump
|
|
12
|
-
//
|
|
13
|
-
// Plus v1.1 read-only OVERVIEW MAPS (default-on; `--no-overview` to skip) —
|
|
14
|
-
// "understand what you built":
|
|
15
|
-
// • API surface map — every route, its methods, and whether it's auth-gated
|
|
16
|
-
// (⚠ mutating routes with no auth check)
|
|
17
|
-
// • integrations map — external/LLM/3rd-party calls + the env keys behind them
|
|
18
|
-
// (⚠ a secret reachable from the client bundle)
|
|
19
|
-
// • schedules & jobs map — vercel.json crons + cron routes + their gating
|
|
20
|
-
// (⚠ an ungated, publicly-triggerable cron)
|
|
21
|
-
//
|
|
22
|
-
// Node built-ins only. ESM. Nothing here mutates your repo (`check`); only the
|
|
23
|
-
// explicit `deploy` subcommand performs an action, and it deploys from a clean
|
|
24
|
-
// detached worktree of the target commit.
|
|
25
|
-
//
|
|
26
|
-
// Usage:
|
|
27
|
-
// node cli/ship-safe/index.mjs [check] [--build]
|
|
28
|
-
// node cli/ship-safe/index.mjs deploy [--expect-prefix getadvantage-] [--scope <s>]
|
|
29
|
-
// [--commit <ref>] [--token-env VERCEL_TOKEN]
|
|
30
|
-
// [--build] [--force]
|
|
31
|
-
|
|
32
|
-
import { c } from "./util.mjs";
|
|
33
|
-
import { repoRoot } from "./util.mjs";
|
|
34
|
-
import { runChecks } from "./checks-runner.mjs";
|
|
35
|
-
import { deploy } from "./deploy.mjs";
|
|
36
|
-
import { runBrief } from "./brief.mjs";
|
|
37
|
-
import { runHandoff } from "./handoff.mjs";
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
${c.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
${c.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
${c.
|
|
94
|
-
--
|
|
95
|
-
--
|
|
96
|
-
--
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
});
|
|
146
|
-
process.exit(
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
if (cmd === "
|
|
150
|
-
header();
|
|
151
|
-
const code =
|
|
152
|
-
cwd,
|
|
153
|
-
out: flags.out,
|
|
154
|
-
|
|
155
|
-
});
|
|
156
|
-
process.exit(code);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
if (cmd === "
|
|
160
|
-
header();
|
|
161
|
-
const code =
|
|
162
|
-
cwd,
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
});
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Ship-Safe — "is this safe to ship?"
|
|
3
|
+
//
|
|
4
|
+
// A local, dependency-free pre-deploy auditor for AI-built apps. Run it in your
|
|
5
|
+
// repo BEFORE you deploy and it gives a plain-language GO / NO-GO:
|
|
6
|
+
//
|
|
7
|
+
// • dirty-tree guard — `vercel --prod` ships the working tree, so a dirty
|
|
8
|
+
// tree (or another session's work) would ship live
|
|
9
|
+
// • secret scan — leaked keys in committed/staged files
|
|
10
|
+
// • build + typecheck — `tsc --noEmit` (and `--build` for a full build)
|
|
11
|
+
// • schema-bump check — DDL changed without a SCHEMA_VERSION bump
|
|
12
|
+
//
|
|
13
|
+
// Plus v1.1 read-only OVERVIEW MAPS (default-on; `--no-overview` to skip) —
|
|
14
|
+
// "understand what you built":
|
|
15
|
+
// • API surface map — every route, its methods, and whether it's auth-gated
|
|
16
|
+
// (⚠ mutating routes with no auth check)
|
|
17
|
+
// • integrations map — external/LLM/3rd-party calls + the env keys behind them
|
|
18
|
+
// (⚠ a secret reachable from the client bundle)
|
|
19
|
+
// • schedules & jobs map — vercel.json crons + cron routes + their gating
|
|
20
|
+
// (⚠ an ungated, publicly-triggerable cron)
|
|
21
|
+
//
|
|
22
|
+
// Node built-ins only. ESM. Nothing here mutates your repo (`check`); only the
|
|
23
|
+
// explicit `deploy` subcommand performs an action, and it deploys from a clean
|
|
24
|
+
// detached worktree of the target commit.
|
|
25
|
+
//
|
|
26
|
+
// Usage:
|
|
27
|
+
// node cli/ship-safe/index.mjs [check] [--build]
|
|
28
|
+
// node cli/ship-safe/index.mjs deploy [--expect-prefix getadvantage-] [--scope <s>]
|
|
29
|
+
// [--commit <ref>] [--token-env VERCEL_TOKEN]
|
|
30
|
+
// [--build] [--force]
|
|
31
|
+
|
|
32
|
+
import { c } from "./util.mjs";
|
|
33
|
+
import { repoRoot } from "./util.mjs";
|
|
34
|
+
import { runChecks } from "./checks-runner.mjs";
|
|
35
|
+
import { deploy } from "./deploy.mjs";
|
|
36
|
+
import { runBrief } from "./brief.mjs";
|
|
37
|
+
import { runHandoff } from "./handoff.mjs";
|
|
38
|
+
import { runLedger } from "./ledger.mjs";
|
|
39
|
+
import { runGauge } from "./gauge.mjs";
|
|
40
|
+
import { runInit } from "./init.mjs";
|
|
41
|
+
|
|
42
|
+
function parseArgs(argv) {
|
|
43
|
+
// First non-flag token is the subcommand; default to "check".
|
|
44
|
+
const flags = {};
|
|
45
|
+
const positional = [];
|
|
46
|
+
for (let i = 0; i < argv.length; i++) {
|
|
47
|
+
const a = argv[i];
|
|
48
|
+
if (a.startsWith("--")) {
|
|
49
|
+
const key = a.slice(2);
|
|
50
|
+
// value-taking flags vs boolean flags
|
|
51
|
+
const valueFlags = new Set(["expect-prefix", "scope", "commit", "token-env", "base-ref", "out"]);
|
|
52
|
+
if (valueFlags.has(key)) {
|
|
53
|
+
flags[key] = argv[++i];
|
|
54
|
+
} else {
|
|
55
|
+
flags[key] = true;
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
positional.push(a);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { cmd: positional[0] || "check", flags };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function header() {
|
|
65
|
+
console.log(c.bold("┌──────────────────────────────────────────┐"));
|
|
66
|
+
console.log(c.bold("│ Ship-Safe — is this safe to ship? │"));
|
|
67
|
+
console.log(c.bold("└──────────────────────────────────────────┘"));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function printHelp() {
|
|
71
|
+
header();
|
|
72
|
+
console.log(`
|
|
73
|
+
${c.bold("Commands")}
|
|
74
|
+
${c.cyan("check")} Run all read-only pre-deploy checks (default). Exits 0 on GO, 1 on NO-GO.
|
|
75
|
+
${c.cyan("brief")} Generate / refresh the PROJECT BRAIN — a portable, repo-resident project
|
|
76
|
+
brief (default ${c.bold("PROJECT-BRIEF.md")}) that ANY model/session/tool reads on start,
|
|
77
|
+
so you can switch tools without re-explaining the project. ${c.bold("--check")} only warns
|
|
78
|
+
if the brief is missing or stale (never blocks).
|
|
79
|
+
${c.cyan("handoff")} Refresh the brief AND write ${c.bold("HANDOFF.md")} — the HOT "where we left off"
|
|
80
|
+
layer (what you were doing · next steps · open threads), so you can drop a long,
|
|
81
|
+
slow session and start a fresh, fast one with no loss. Your notes are preserved
|
|
82
|
+
across refreshes.
|
|
83
|
+
${c.cyan("gauge")} Is this session getting heavy? A quick freshness read (repo activity since
|
|
84
|
+
your last handoff) that nudges you to reset before things get slow.
|
|
85
|
+
${c.cyan("ledger")} Show the session ledger — the running log of save-points (the project's
|
|
86
|
+
history); each ${c.bold("handoff")} adds an entry.
|
|
87
|
+
${c.cyan("init")} Wire the brain into your agent's instructions file (CLAUDE.md / AGENTS.md /
|
|
88
|
+
.cursorrules) so the brief + handoff load automatically at session start.
|
|
89
|
+
${c.cyan("deploy")} Run check, then deploy from a clean detached worktree and confirm the
|
|
90
|
+
deployment URL prefix. Performs a real ${c.bold("vercel --prod")}.
|
|
91
|
+
|
|
92
|
+
${c.bold("Flags")}
|
|
93
|
+
--build Also run a full ${c.bold("npm run build")} (default: tsc --noEmit only).
|
|
94
|
+
--base-ref <ref> Merge-base ref for the schema-bump diff (default: main).
|
|
95
|
+
--no-overview Skip the read-only overview maps (API surface, integrations, schedules).
|
|
96
|
+
--no-brief-check Skip the (non-blocking) brief-staleness warning in ${c.cyan("check")}.
|
|
97
|
+
|
|
98
|
+
${c.dim("brief only:")}
|
|
99
|
+
--out <path> Where to write the brief (default: PROJECT-BRIEF.md at repo root).
|
|
100
|
+
--check Report staleness only (no write); warns if missing/stale.
|
|
101
|
+
|
|
102
|
+
${c.dim("deploy only:")}
|
|
103
|
+
--expect-prefix <p> Required deployment-host prefix (default: derived from your linked .vercel project; guard skipped if none).
|
|
104
|
+
--scope <scope> Vercel team scope, passed through to vercel.
|
|
105
|
+
--commit <ref> Commit-ish to deploy (default: HEAD).
|
|
106
|
+
--token-env <NAME> Env var NAME holding the Vercel token (default: VERCEL_TOKEN).
|
|
107
|
+
--force Deploy even if checks return NO-GO (use with care).
|
|
108
|
+
|
|
109
|
+
${c.bold("Examples")}
|
|
110
|
+
ship-safe run the pre-deploy checks (GO / NO-GO)
|
|
111
|
+
ship-safe brief generate / refresh the project brain
|
|
112
|
+
ship-safe init auto-load the brain at every session start
|
|
113
|
+
ship-safe handoff save your place for the next session
|
|
114
|
+
ship-safe gauge is this session getting heavy?
|
|
115
|
+
ship-safe deploy --expect-prefix myproject-
|
|
116
|
+
`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function main() {
|
|
120
|
+
const { cmd, flags } = parseArgs(process.argv.slice(2));
|
|
121
|
+
|
|
122
|
+
if (cmd === "help" || flags.help) {
|
|
123
|
+
printHelp();
|
|
124
|
+
process.exit(0);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let cwd;
|
|
128
|
+
try {
|
|
129
|
+
cwd = repoRoot();
|
|
130
|
+
} catch {
|
|
131
|
+
console.error(c.red("✗ Not inside a git repository. Ship-Safe must run in your project's repo."));
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (cmd === "check") {
|
|
136
|
+
header();
|
|
137
|
+
const { exitCode } = await runChecks({
|
|
138
|
+
cwd,
|
|
139
|
+
runBuild: !!flags.build,
|
|
140
|
+
baseRef: flags["base-ref"],
|
|
141
|
+
// Overviews are default-on; `--no-overview` turns them off.
|
|
142
|
+
overview: !flags["no-overview"],
|
|
143
|
+
// Brief-staleness warning is default-on; `--no-brief-check` turns it off.
|
|
144
|
+
briefCheck: !flags["no-brief-check"],
|
|
145
|
+
});
|
|
146
|
+
process.exit(exitCode);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (cmd === "brief") {
|
|
150
|
+
header();
|
|
151
|
+
const code = runBrief({
|
|
152
|
+
cwd,
|
|
153
|
+
out: flags.out,
|
|
154
|
+
check: !!flags.check,
|
|
155
|
+
});
|
|
156
|
+
process.exit(code);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (cmd === "handoff") {
|
|
160
|
+
header();
|
|
161
|
+
const code = runHandoff({
|
|
162
|
+
cwd,
|
|
163
|
+
out: flags.out,
|
|
164
|
+
noBrief: !!flags["no-brief"],
|
|
165
|
+
});
|
|
166
|
+
process.exit(code);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (cmd === "gauge") {
|
|
170
|
+
header();
|
|
171
|
+
process.exit(runGauge({ cwd }));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (cmd === "ledger") {
|
|
175
|
+
header();
|
|
176
|
+
process.exit(runLedger({ cwd }));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (cmd === "init") {
|
|
180
|
+
header();
|
|
181
|
+
process.exit(runInit({ cwd }));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (cmd === "deploy") {
|
|
185
|
+
header();
|
|
186
|
+
const code = await deploy({
|
|
187
|
+
cwd,
|
|
188
|
+
commit: flags.commit,
|
|
189
|
+
expectPrefix: flags["expect-prefix"],
|
|
190
|
+
scope: flags.scope,
|
|
191
|
+
tokenEnv: flags["token-env"],
|
|
192
|
+
force: !!flags.force,
|
|
193
|
+
runBuild: !!flags.build,
|
|
194
|
+
});
|
|
195
|
+
process.exit(code);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
console.error(c.red(`✗ Unknown command: ${cmd}`));
|
|
199
|
+
printHelp();
|
|
200
|
+
process.exit(1);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
main().catch((e) => {
|
|
204
|
+
console.error(c.red(`✗ Ship-Safe crashed: ${e?.stack || e}`));
|
|
205
|
+
process.exit(1);
|
|
206
|
+
});
|
package/init.mjs
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Ship-Safe — AUTO-LOAD HOOK (`ship-safe init`).
|
|
2
|
+
//
|
|
3
|
+
// Kills the discipline tax. AI coding tools read a conventional instructions file
|
|
4
|
+
// at the START of every session — `CLAUDE.md` (Claude Code), `AGENTS.md` (Codex
|
|
5
|
+
// and a growing set of tools), `.cursorrules` (Cursor), GitHub Copilot's
|
|
6
|
+
// instructions file. `init` ensures each present file has a small MANAGED block
|
|
7
|
+
// telling the agent to read PROJECT-BRIEF.md + HANDOFF.md first — so the brain
|
|
8
|
+
// loads itself and you never have to remember to point at it.
|
|
9
|
+
//
|
|
10
|
+
// Idempotent: it upserts its own marked block (never duplicates), so re-running
|
|
11
|
+
// after the brief changes is safe. If NO entry file exists, it creates AGENTS.md
|
|
12
|
+
// (the emerging cross-tool standard). Node built-ins only. ESM.
|
|
13
|
+
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { c, relPath } from "./util.mjs";
|
|
17
|
+
|
|
18
|
+
const START = "<!-- ship-safe:auto-load -->";
|
|
19
|
+
const END = "<!-- /ship-safe:auto-load -->";
|
|
20
|
+
|
|
21
|
+
const BLOCK = [
|
|
22
|
+
START,
|
|
23
|
+
"## Project brain — read first",
|
|
24
|
+
"Before anything else, read **`PROJECT-BRIEF.md`** (what this project is) and,",
|
|
25
|
+
"if present, **`HANDOFF.md`** (where work left off). Refresh them with",
|
|
26
|
+
"`ship-safe handoff` (or `npx getadvantage handoff`) before switching sessions",
|
|
27
|
+
"or models — your context lives in the repo, not the tool.",
|
|
28
|
+
END,
|
|
29
|
+
].join("\n");
|
|
30
|
+
|
|
31
|
+
// Files an AI coding tool auto-reads at session start. We touch existing ones;
|
|
32
|
+
// if none exist we create AGENTS.md (the cross-tool convention).
|
|
33
|
+
const TARGETS = [
|
|
34
|
+
"CLAUDE.md",
|
|
35
|
+
"AGENTS.md",
|
|
36
|
+
".cursorrules",
|
|
37
|
+
".github/copilot-instructions.md",
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
function upsertBlock(abs) {
|
|
41
|
+
let body = existsSync(abs) ? readFileSync(abs, "utf8") : "";
|
|
42
|
+
const i = body.indexOf(START);
|
|
43
|
+
const j = body.indexOf(END);
|
|
44
|
+
let action;
|
|
45
|
+
if (i !== -1 && j !== -1 && j > i) {
|
|
46
|
+
body = body.slice(0, i) + BLOCK + body.slice(j + END.length);
|
|
47
|
+
action = "updated";
|
|
48
|
+
} else {
|
|
49
|
+
body = (body ? body.replace(/\n*$/, "") + "\n\n" : "") + BLOCK + "\n";
|
|
50
|
+
action = body && existsSync(abs) ? "appended to" : "created";
|
|
51
|
+
}
|
|
52
|
+
const dir = path.dirname(abs);
|
|
53
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
54
|
+
writeFileSync(abs, body, "utf8");
|
|
55
|
+
return action;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function runInit(o) {
|
|
59
|
+
const cwd = o.cwd;
|
|
60
|
+
const present = TARGETS.filter((t) => existsSync(path.join(cwd, t)));
|
|
61
|
+
const done = [];
|
|
62
|
+
|
|
63
|
+
if (present.length === 0) {
|
|
64
|
+
const abs = path.join(cwd, "AGENTS.md");
|
|
65
|
+
writeFileSync(abs, `# Agent instructions\n\n${BLOCK}\n`, "utf8");
|
|
66
|
+
done.push(`AGENTS.md (created)`);
|
|
67
|
+
} else {
|
|
68
|
+
for (const t of present) {
|
|
69
|
+
const action = upsertBlock(path.join(cwd, t));
|
|
70
|
+
done.push(`${t} (${action})`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
console.log(c.green(`✓ Wired the project brain in: ${done.join(", ")}`));
|
|
75
|
+
console.log(c.gray(" Your AI reads these at session start — so PROJECT-BRIEF.md + HANDOFF.md load automatically."));
|
|
76
|
+
console.log(c.gray(" Re-run anytime; it updates its own marked block and never duplicates."));
|
|
77
|
+
console.log(c.gray(" (Don't forget to run `ship-safe brief` once so the brain file exists.)"));
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
package/ledger.mjs
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Ship-Safe — SESSION LEDGER (`ship-safe ledger`).
|
|
2
|
+
//
|
|
3
|
+
// The append-only continuity changelog. Every `ship-safe handoff` adds (or, if
|
|
4
|
+
// you re-run it in the same session, updates) one compact entry to
|
|
5
|
+
// `.ship-safe/ledger.md`: date · branch @ sha · commits since last · next step.
|
|
6
|
+
// Over time it becomes the project's session history — the thread that lets any
|
|
7
|
+
// model/session see not just WHERE the project is, but HOW it got there.
|
|
8
|
+
//
|
|
9
|
+
// Node built-ins only. ESM. Writes one repo-resident file.
|
|
10
|
+
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { c, gitSafe, relPath } from "./util.mjs";
|
|
14
|
+
|
|
15
|
+
const HEAD_MARK = "<!-- ship-safe:ledger -->";
|
|
16
|
+
|
|
17
|
+
function ledgerPath(cwd) {
|
|
18
|
+
return path.join(cwd, ".ship-safe", "ledger.md");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Pull the first real "next step" line out of a handoff notes block. Returns
|
|
22
|
+
* "—" when it's still the placeholder or empty. */
|
|
23
|
+
function extractNext(notes) {
|
|
24
|
+
if (!notes) return "—";
|
|
25
|
+
const lines = notes.split("\n");
|
|
26
|
+
let inNext = false;
|
|
27
|
+
for (const raw of lines) {
|
|
28
|
+
const line = raw.trim();
|
|
29
|
+
if (/^##\s+next steps/i.test(line)) { inNext = true; continue; }
|
|
30
|
+
if (inNext) {
|
|
31
|
+
if (/^##\s/.test(line)) break; // next heading
|
|
32
|
+
if (!line) continue;
|
|
33
|
+
const text = line.replace(/^(\d+\.|[-*])\s*/, "").trim();
|
|
34
|
+
if (!text || text.startsWith("_(")) return "—"; // untouched placeholder
|
|
35
|
+
return text.replace(/\s+/g, " ").slice(0, 140);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return "—";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Append (or update the latest same-sha) ledger entry. Called by `handoff`.
|
|
43
|
+
* @returns {string} repo-relative ledger path (for the caller to mention).
|
|
44
|
+
*/
|
|
45
|
+
export function appendLedger(cwd, { headSha, branch, lastHead, notes, now }) {
|
|
46
|
+
const abs = ledgerPath(cwd);
|
|
47
|
+
const dir = path.dirname(abs);
|
|
48
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
49
|
+
|
|
50
|
+
let body = existsSync(abs) ? readFileSync(abs, "utf8") : "";
|
|
51
|
+
if (!body.includes(HEAD_MARK)) {
|
|
52
|
+
body =
|
|
53
|
+
`${HEAD_MARK}\n# Session ledger\n\n` +
|
|
54
|
+
`_A running log of save-points (\`ship-safe handoff\`), newest at the bottom — ` +
|
|
55
|
+
`the project's session history._\n`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// commits since the last handoff (best-effort; 0 if range invalid/first run)
|
|
59
|
+
let commitCount = 0;
|
|
60
|
+
if (lastHead) {
|
|
61
|
+
const cnt = gitSafe(["rev-list", "--count", `${lastHead}..HEAD`], { cwd });
|
|
62
|
+
if (cnt) commitCount = parseInt(cnt, 10) || 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const shortSha = headSha ? headSha.slice(0, 10) : "(none)";
|
|
66
|
+
const date = now.slice(0, 16).replace("T", " "); // YYYY-MM-DD HH:MM (UTC)
|
|
67
|
+
const next = extractNext(notes);
|
|
68
|
+
const entry =
|
|
69
|
+
`- **${date}** · \`${branch}\` @ \`${shortSha}\` · ${commitCount} commit(s) since last · next: ${next}`;
|
|
70
|
+
|
|
71
|
+
// If the most recent entry is for THIS head sha, update it in place (re-running
|
|
72
|
+
// handoff in one session shouldn't duplicate the save-point).
|
|
73
|
+
const lines = body.split("\n");
|
|
74
|
+
let lastIdx = -1;
|
|
75
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
76
|
+
if (lines[i].startsWith("- **")) { lastIdx = i; break; }
|
|
77
|
+
}
|
|
78
|
+
if (lastIdx >= 0 && lines[lastIdx].includes(`@ \`${shortSha}\``)) {
|
|
79
|
+
lines[lastIdx] = entry;
|
|
80
|
+
body = lines.join("\n");
|
|
81
|
+
} else {
|
|
82
|
+
body = body.replace(/\n*$/, "") + "\n" + entry + "\n";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
writeFileSync(abs, body, "utf8");
|
|
86
|
+
return relPath(abs, cwd);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** `ship-safe ledger` — print the recent session history. */
|
|
90
|
+
export function runLedger(o) {
|
|
91
|
+
const cwd = o.cwd;
|
|
92
|
+
const abs = ledgerPath(cwd);
|
|
93
|
+
if (!existsSync(abs)) {
|
|
94
|
+
console.log(` ${c.yellow("⚠")} No session ledger yet — run ${c.cyan("ship-safe handoff")} to start one.`);
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
const body = readFileSync(abs, "utf8");
|
|
98
|
+
const entries = body.split("\n").filter((l) => l.startsWith("- **"));
|
|
99
|
+
console.log(c.bold(`\n Session ledger — ${entries.length} save-point(s) ${c.gray(`(${relPath(abs, cwd)})`)}\n`));
|
|
100
|
+
const tail = entries.slice(-15);
|
|
101
|
+
for (const e of tail) {
|
|
102
|
+
console.log(" " + e.replace(/^- /, "").replace(/\*\*/g, "").replace(/`/g, ""));
|
|
103
|
+
}
|
|
104
|
+
if (entries.length > tail.length) {
|
|
105
|
+
console.log(c.gray(` …and ${entries.length - tail.length} older — see ${relPath(abs, cwd)}`));
|
|
106
|
+
}
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export { ledgerPath };
|