@esneiderbravo/speclaw 2.0.16 → 2.0.18
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/dist/cli/commands/init.js +4 -1
- package/dist/cli/lib/ui.js +111 -7
- package/dist/modules/lawbook/ship.js +61 -20
- package/package.json +1 -1
|
@@ -7,7 +7,7 @@ import { buildIndex } from "../../modules/compass/indexer.js";
|
|
|
7
7
|
import { AGENTS, agentById } from "../../shared/agents.js";
|
|
8
8
|
import { loadPacks } from "../../modules/tools/packs.js";
|
|
9
9
|
import { list } from "../lib/args.js";
|
|
10
|
-
import { ui, c, banner, renderProgress, clearProgress } from "../lib/ui.js";
|
|
10
|
+
import { ui, c, banner, detectTheme, renderProgress, clearProgress } from "../lib/ui.js";
|
|
11
11
|
import { checkForUpdates } from "../lib/update-check.js";
|
|
12
12
|
import { reportTrackedLocalContent } from "../lib/untrack.js";
|
|
13
13
|
const PACK_LABELS = {};
|
|
@@ -37,6 +37,9 @@ export async function runInit(flags) {
|
|
|
37
37
|
let packs;
|
|
38
38
|
const availablePacks = loadPacks();
|
|
39
39
|
const packIds = Object.keys(availablePacks);
|
|
40
|
+
// Init is read once, top to bottom, on whatever background the user has —
|
|
41
|
+
// match the palette to it before the first styled line.
|
|
42
|
+
await detectTheme();
|
|
40
43
|
banner();
|
|
41
44
|
// Scaffolding with a stale version writes yesterday's foundation, so recommend
|
|
42
45
|
// upgrading first — prominently, before any prompts, so there's time to cancel.
|
package/dist/cli/lib/ui.js
CHANGED
|
@@ -16,6 +16,110 @@ export const PALETTE = Object.freeze({
|
|
|
16
16
|
amber: [245, 183, 61], // #F5B73D — warning (re-tuned)
|
|
17
17
|
red: [255, 92, 71], // #FF5C47 — deny: errors
|
|
18
18
|
});
|
|
19
|
+
// The same slots tuned for a light terminal: the ink palette is near-invisible
|
|
20
|
+
// on white (cream is ~1.1:1, signal cyan ~1.6:1), so each slot here holds at
|
|
21
|
+
// least 4.5:1 against white paper #FFFFFF instead.
|
|
22
|
+
// Covers: req~brand-terminal-palette~1
|
|
23
|
+
export const LIGHT_PALETTE = Object.freeze({
|
|
24
|
+
cyan: [0, 122, 140], // #007A8C — deep signal
|
|
25
|
+
cyanDim: [0, 122, 140], // #007A8C
|
|
26
|
+
cream: [31, 35, 40], // #1F2328 — primary text on light paper
|
|
27
|
+
muted: [95, 102, 112], // #5F6670 — secondary text
|
|
28
|
+
green: [26, 127, 55], // #1A7F37 — success
|
|
29
|
+
amber: [154, 103, 0], // #9A6700 — warning
|
|
30
|
+
red: [207, 34, 46], // #CF222E — errors
|
|
31
|
+
});
|
|
32
|
+
/**
|
|
33
|
+
* The theme an explicit signal asks for: `SPECLAW_THEME=light|dark` wins, then
|
|
34
|
+
* `COLORFGBG` (`fg;bg`, set by rxvt, Konsole, and others), whose background
|
|
35
|
+
* index 7 or 15 means a light terminal. Returns `undefined` when neither says.
|
|
36
|
+
*/
|
|
37
|
+
export function themeFromEnv(env = process.env) {
|
|
38
|
+
const forced = env.SPECLAW_THEME?.toLowerCase();
|
|
39
|
+
if (forced === "light" || forced === "dark")
|
|
40
|
+
return forced;
|
|
41
|
+
const bg = env.COLORFGBG?.split(";").pop();
|
|
42
|
+
if (bg === undefined || bg === "")
|
|
43
|
+
return undefined;
|
|
44
|
+
return bg === "7" || bg === "15" ? "light" : "dark";
|
|
45
|
+
}
|
|
46
|
+
let active = themeFromEnv() === "light" ? LIGHT_PALETTE : PALETTE;
|
|
47
|
+
/** Switch every brand color to the given theme's palette. */
|
|
48
|
+
export function setTheme(theme) {
|
|
49
|
+
active = theme === "light" ? LIGHT_PALETTE : PALETTE;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The theme an OSC 11 background-color reply implies
|
|
53
|
+
* (`ESC ] 11 ; rgb:RRRR/GGGG/BBBB` with 1–4 hex digits per channel): light when
|
|
54
|
+
* the background's relative luminance is above one half.
|
|
55
|
+
*/
|
|
56
|
+
export function themeFromOsc11(reply) {
|
|
57
|
+
const m = /rgb:([0-9a-f]{1,4})\/([0-9a-f]{1,4})\/([0-9a-f]{1,4})/i.exec(reply);
|
|
58
|
+
if (!m)
|
|
59
|
+
return undefined;
|
|
60
|
+
const [r, g, b] = [m[1], m[2], m[3]].map((h) => parseInt(h, 16) / (16 ** h.length - 1));
|
|
61
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.5 ? "light" : "dark";
|
|
62
|
+
}
|
|
63
|
+
// Ask the terminal for its background color. Terminals that don't support the
|
|
64
|
+
// query never answer, so the wait is capped; a reply arriving after the cap
|
|
65
|
+
// would land in the next prompt's input, hence a generous-but-short window.
|
|
66
|
+
function queryBackground(timeoutMs = 200) {
|
|
67
|
+
const stdin = process.stdin;
|
|
68
|
+
if (!stdin.isTTY || !process.stdout.isTTY || typeof stdin.setRawMode !== "function") {
|
|
69
|
+
return Promise.resolve("");
|
|
70
|
+
}
|
|
71
|
+
return new Promise((resolve) => {
|
|
72
|
+
let buf = "";
|
|
73
|
+
const wasRaw = stdin.isRaw;
|
|
74
|
+
const done = () => {
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
stdin.off("data", onData);
|
|
77
|
+
stdin.setRawMode(wasRaw);
|
|
78
|
+
stdin.pause();
|
|
79
|
+
resolve(buf);
|
|
80
|
+
};
|
|
81
|
+
const onData = (d) => {
|
|
82
|
+
buf += d.toString("latin1");
|
|
83
|
+
if (buf.includes("\x07") || buf.includes("\x1b\\"))
|
|
84
|
+
done();
|
|
85
|
+
};
|
|
86
|
+
const timer = setTimeout(done, timeoutMs);
|
|
87
|
+
stdin.setRawMode(true);
|
|
88
|
+
stdin.on("data", onData);
|
|
89
|
+
stdin.resume();
|
|
90
|
+
process.stdout.write("\x1b]11;?\x07");
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
// macOS appearance as a last resort: most terminals there follow the system
|
|
94
|
+
// theme by default. `AppleInterfaceStyle` exists only in dark mode.
|
|
95
|
+
async function macAppearance() {
|
|
96
|
+
if (process.platform !== "darwin")
|
|
97
|
+
return undefined;
|
|
98
|
+
const { execFile } = await import("node:child_process");
|
|
99
|
+
return new Promise((resolve) => {
|
|
100
|
+
execFile("defaults", ["read", "-g", "AppleInterfaceStyle"], { timeout: 500 }, (err, out) => {
|
|
101
|
+
if (err && typeof err.code !== "number")
|
|
102
|
+
return resolve(undefined);
|
|
103
|
+
resolve(/dark/i.test(String(out)) ? "dark" : "light");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Detect the terminal's background and switch to the matching palette. An
|
|
109
|
+
* explicit env signal ({@link themeFromEnv}) wins; otherwise the terminal is
|
|
110
|
+
* asked directly (OSC 11), then the macOS appearance is consulted. Meant for
|
|
111
|
+
* interactive commands before their first styled line; does nothing when
|
|
112
|
+
* color is off.
|
|
113
|
+
*/
|
|
114
|
+
export async function detectTheme() {
|
|
115
|
+
const fromEnv = themeFromEnv();
|
|
116
|
+
let theme = fromEnv ?? "dark";
|
|
117
|
+
if (colorOn && !fromEnv) {
|
|
118
|
+
theme = themeFromOsc11(await queryBackground()) ?? (await macAppearance()) ?? "dark";
|
|
119
|
+
}
|
|
120
|
+
setTheme(theme);
|
|
121
|
+
return theme;
|
|
122
|
+
}
|
|
19
123
|
const colorOn = (Boolean(process.stdout.isTTY) || process.env.FORCE_COLOR === "1") && !process.env.NO_COLOR;
|
|
20
124
|
// Whether the terminal reliably renders the unicode box/block glyphs the brand
|
|
21
125
|
// output uses. Non-Windows terminals are assumed capable; a Windows console is
|
|
@@ -81,13 +185,13 @@ export function link(label, url) {
|
|
|
81
185
|
}
|
|
82
186
|
/** Brand color helpers for composing styled strings. */
|
|
83
187
|
export const c = {
|
|
84
|
-
cyan: (s) => paint(
|
|
85
|
-
cyanDim: (s) => paint(
|
|
86
|
-
cream: (s) => paint(
|
|
87
|
-
muted: (s) => paint(
|
|
88
|
-
green: (s) => paint(
|
|
89
|
-
amber: (s) => paint(
|
|
90
|
-
red: (s) => paint(
|
|
188
|
+
cyan: (s) => paint(active.cyan, s),
|
|
189
|
+
cyanDim: (s) => paint(active.cyanDim, s),
|
|
190
|
+
cream: (s) => paint(active.cream, s),
|
|
191
|
+
muted: (s) => paint(active.muted, s),
|
|
192
|
+
green: (s) => paint(active.green, s),
|
|
193
|
+
amber: (s) => paint(active.amber, s),
|
|
194
|
+
red: (s) => paint(active.red, s),
|
|
91
195
|
bold,
|
|
92
196
|
};
|
|
93
197
|
/** Styled output primitives used across the CLI. */
|
|
@@ -238,7 +238,7 @@ export function shipChange(projectPath, name, opts = {}) {
|
|
|
238
238
|
archivedTo = specArchive(projectPath, name, date).archivedTo;
|
|
239
239
|
}
|
|
240
240
|
if (gatesPassed && isGitRepo(projectPath))
|
|
241
|
-
markShipped(projectPath);
|
|
241
|
+
markShipped(projectPath, name);
|
|
242
242
|
const t4 = Date.now();
|
|
243
243
|
const gatesMs = t2 - t1;
|
|
244
244
|
return {
|
|
@@ -278,32 +278,69 @@ function git(projectPath, args) {
|
|
|
278
278
|
const r = spawnSync("git", args, { cwd: projectPath, encoding: "utf8" });
|
|
279
279
|
return r.status === 0 ? r.stdout : "";
|
|
280
280
|
}
|
|
281
|
-
/**
|
|
281
|
+
/** The last shipped work; a manual ship and the hook share it. */
|
|
282
282
|
const SHIP_MARKER = path.join(".speclaw", "ship-last");
|
|
283
|
-
function
|
|
283
|
+
function currentBranch(projectPath) {
|
|
284
|
+
return git(projectPath, ["rev-parse", "--abbrev-ref", "HEAD"]).trim();
|
|
285
|
+
}
|
|
286
|
+
function markShipped(projectPath, change) {
|
|
284
287
|
const marker = path.join(projectPath, SHIP_MARKER);
|
|
285
288
|
fs.mkdirSync(path.dirname(marker), { recursive: true });
|
|
286
|
-
|
|
289
|
+
const body = {
|
|
290
|
+
branch: currentBranch(projectPath),
|
|
291
|
+
change,
|
|
292
|
+
fingerprint: workFingerprint(projectPath),
|
|
293
|
+
};
|
|
294
|
+
fs.writeFileSync(marker, JSON.stringify(body) + "\n");
|
|
295
|
+
}
|
|
296
|
+
/** The last ship marker, or null; a pre-2.0.17 marker (a bare fingerprint) has no change name. */
|
|
297
|
+
function readMarker(projectPath) {
|
|
298
|
+
const marker = path.join(projectPath, SHIP_MARKER);
|
|
299
|
+
if (!fs.existsSync(marker))
|
|
300
|
+
return null;
|
|
301
|
+
const raw = fs.readFileSync(marker, "utf8").trim();
|
|
302
|
+
try {
|
|
303
|
+
const m = JSON.parse(raw);
|
|
304
|
+
if (typeof m.fingerprint === "string") {
|
|
305
|
+
return { branch: m.branch ?? "", change: m.change ?? "", fingerprint: m.fingerprint };
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
// Legacy marker: the fingerprint alone.
|
|
310
|
+
}
|
|
311
|
+
return { branch: "", change: "", fingerprint: raw };
|
|
287
312
|
}
|
|
288
313
|
/**
|
|
289
|
-
* Fingerprint of the work outside `lawbook/changes/`: the
|
|
290
|
-
*
|
|
291
|
-
*
|
|
314
|
+
* Fingerprint of the branch's work outside `lawbook/changes/`: the working tree
|
|
315
|
+
* diffed against the merge base with `main`/`master`, plus untracked files
|
|
316
|
+
* (names and contents). Committing that work leaves it unchanged — a commit is not new work —
|
|
317
|
+
* and ship writes under `lawbook/changes/`, so its own output never changes it.
|
|
318
|
+
* Without a merge base it falls back to the diff against HEAD plus HEAD itself.
|
|
292
319
|
*/
|
|
293
320
|
function workFingerprint(projectPath) {
|
|
294
|
-
const
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
.
|
|
299
|
-
|
|
300
|
-
|
|
321
|
+
const base = mergeBase(projectPath, "main") ?? mergeBase(projectPath, "master");
|
|
322
|
+
const diff = git(projectPath, ["diff", base ?? "HEAD", "--", ".", ":(exclude)lawbook/changes"]);
|
|
323
|
+
const hash = createHash("sha256");
|
|
324
|
+
for (const f of git(projectPath, ["ls-files", "--others", "--exclude-standard"]).split("\n")) {
|
|
325
|
+
if (!f || f.startsWith("lawbook/changes/") || f.startsWith(".speclaw/"))
|
|
326
|
+
continue;
|
|
327
|
+
// Contents, not just names: editing a new, not-yet-added file is new work.
|
|
328
|
+
hash.update(`${f}\n`);
|
|
329
|
+
try {
|
|
330
|
+
hash.update(fs.readFileSync(path.join(projectPath, f)));
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
// Vanished or unreadable since listing: its name stands in.
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const anchor = base ?? git(projectPath, ["rev-parse", "HEAD"]).trim();
|
|
337
|
+
return hash.update(`\n${anchor}\n${diff}`).digest("hex");
|
|
301
338
|
}
|
|
302
339
|
/**
|
|
303
340
|
* The `Stop` hook body: when the agent ends a turn on a feature branch with
|
|
304
341
|
* work that changed since the last ship, run {@link shipChange} for the change
|
|
305
|
-
*
|
|
306
|
-
* answered a question costs nothing.
|
|
342
|
+
* last shipped on this branch, else the one named after the branch. Skips in
|
|
343
|
+
* milliseconds otherwise, so a turn that only answered a question costs nothing.
|
|
307
344
|
*
|
|
308
345
|
* @param projectPath - Project root.
|
|
309
346
|
* @returns Why it skipped, or the ship result.
|
|
@@ -311,15 +348,19 @@ function workFingerprint(projectPath) {
|
|
|
311
348
|
export function shipOnStop(projectPath) {
|
|
312
349
|
if (!isGitRepo(projectPath))
|
|
313
350
|
return { skipped: "not-git" };
|
|
314
|
-
const branch =
|
|
351
|
+
const branch = currentBranch(projectPath);
|
|
315
352
|
if (!branch || ["main", "master", "HEAD"].includes(branch))
|
|
316
353
|
return { skipped: "base-branch" };
|
|
317
354
|
if (branchFiles(projectPath).length === 0)
|
|
318
355
|
return { skipped: "no-changes" };
|
|
319
|
-
const marker =
|
|
320
|
-
if (
|
|
356
|
+
const marker = readMarker(projectPath);
|
|
357
|
+
if (marker?.fingerprint === workFingerprint(projectPath)) {
|
|
321
358
|
return { skipped: "unchanged-since-last-ship" };
|
|
322
359
|
}
|
|
323
|
-
|
|
360
|
+
// A change shipped by name on this branch keeps receiving the branch's later
|
|
361
|
+
// work; the branch name would point at another (possibly archived) change.
|
|
362
|
+
const change = marker && marker.branch === branch && marker.change
|
|
363
|
+
? marker.change
|
|
364
|
+
: changeNameForBranch(branch);
|
|
324
365
|
return { skipped: null, change, result: shipChange(projectPath, change) };
|
|
325
366
|
}
|