@esneiderbravo/speclaw 2.0.17 → 2.0.19

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.
@@ -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/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseFlags, REPEATABLE_FLAGS } from "./lib/args.js";
3
- import { ui, header } from "./lib/ui.js";
3
+ import { ui, header, detectTheme } from "./lib/ui.js";
4
4
  import { maybeNotifyUpdate } from "./lib/update-check.js";
5
5
  import { GLOBAL_HELP as HELP, helpFor, knownCommands, wantsHelp } from "./lib/help.js";
6
6
  // Commands that open with the one-line branded header. These are the
@@ -38,13 +38,15 @@ const HEADER_COMMANDS = new Set([
38
38
  * child process. `budget --json`, `doctor --json`, and `coverage` when emitting
39
39
  * TAP/JSON (or when stdout is not a TTY) are machine-consumed and suppress the
40
40
  * header. `session-start` and `reindex-file` are not header-eligible: they must print
41
- * nothing.
41
+ * nothing. The palette is matched to the terminal background first, so every
42
+ * styled line after it — not only `init`'s — stays legible on a light theme.
42
43
  */
43
- function maybeHeader(cmd, flags) {
44
+ async function maybeHeader(cmd, flags) {
44
45
  if (!process.stdout.isTTY && process.env.FORCE_COLOR !== "1")
45
46
  return;
46
47
  if (!HEADER_COMMANDS.has(cmd))
47
48
  return;
49
+ await detectTheme();
48
50
  // A self-update child re-runs `update`; the parent already printed the header.
49
51
  if (cmd === "update" && process.env.SPECLAW_SELF_UPDATED)
50
52
  return;
@@ -172,7 +174,7 @@ async function main() {
172
174
  return;
173
175
  }
174
176
  const flags = parseFlags(rest, REPEATABLE_FLAGS);
175
- maybeHeader(cmd, flags);
177
+ await maybeHeader(cmd, flags);
176
178
  await dispatch(cmd, flags);
177
179
  // The SessionStart and edit hooks must stay silent: no update notice either.
178
180
  if (cmd === "session-start" || cmd === "reindex-file" || cmd === "ship-on-stop")
@@ -16,6 +16,113 @@ 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
+ // Child speclaw processes (a self-update re-run) inherit the answer instead
122
+ // of querying the terminal again.
123
+ process.env.SPECLAW_THEME ??= theme;
124
+ return theme;
125
+ }
19
126
  const colorOn = (Boolean(process.stdout.isTTY) || process.env.FORCE_COLOR === "1") && !process.env.NO_COLOR;
20
127
  // Whether the terminal reliably renders the unicode box/block glyphs the brand
21
128
  // output uses. Non-Windows terminals are assumed capable; a Windows console is
@@ -81,13 +188,13 @@ export function link(label, url) {
81
188
  }
82
189
  /** Brand color helpers for composing styled strings. */
83
190
  export const c = {
84
- cyan: (s) => paint(PALETTE.cyan, s),
85
- cyanDim: (s) => paint(PALETTE.cyanDim, s),
86
- cream: (s) => paint(PALETTE.cream, s),
87
- muted: (s) => paint(PALETTE.muted, s),
88
- green: (s) => paint(PALETTE.green, s),
89
- amber: (s) => paint(PALETTE.amber, s),
90
- red: (s) => paint(PALETTE.red, s),
191
+ cyan: (s) => paint(active.cyan, s),
192
+ cyanDim: (s) => paint(active.cyanDim, s),
193
+ cream: (s) => paint(active.cream, s),
194
+ muted: (s) => paint(active.muted, s),
195
+ green: (s) => paint(active.green, s),
196
+ amber: (s) => paint(active.amber, s),
197
+ red: (s) => paint(active.red, s),
91
198
  bold,
92
199
  };
93
200
  /** Styled output primitives used across the CLI. */
@@ -79,10 +79,28 @@ function branchFiles(projectPath) {
79
79
  return [];
80
80
  const base = mergeBase(projectPath, "main") ?? mergeBase(projectPath, "master");
81
81
  const committed = base ? changedFiles(projectPath, base) : [];
82
- return [...new Set([...committed, ...worktreeChangedFiles(projectPath)])]
82
+ return [
83
+ ...new Set([
84
+ ...committed,
85
+ ...worktreeChangedFiles(projectPath),
86
+ ...untrackedFiles(projectPath),
87
+ ]),
88
+ ]
83
89
  .filter((f) => !f.startsWith("lawbook/changes/"))
84
90
  .sort();
85
91
  }
92
+ /** New files not yet added, outside ship's own output (`lawbook/changes/`, `.speclaw/`). */
93
+ function untrackedFiles(projectPath) {
94
+ return git(projectPath, [
95
+ "-c",
96
+ "core.quotePath=false",
97
+ "ls-files",
98
+ "--others",
99
+ "--exclude-standard",
100
+ ])
101
+ .split("\n")
102
+ .filter((f) => f && !f.startsWith("lawbook/changes/") && !f.startsWith(".speclaw/"));
103
+ }
86
104
  function secs(ms) {
87
105
  return `${(ms / 1000).toFixed(1)} s`;
88
106
  }
@@ -311,30 +329,41 @@ function readMarker(projectPath) {
311
329
  return { branch: "", change: "", fingerprint: raw };
312
330
  }
313
331
  /**
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.
332
+ * Fingerprint of the branch's work outside `lawbook/changes/` and `.speclaw/`: every path that
333
+ * differs from the merge base with `main`/`master` or is untracked, with its
334
+ * working-tree contents. A file hashes the same whether it is untracked, staged
335
+ * or committed, so committing work — new files included — is not new work; ship
336
+ * writes under `lawbook/changes/`, so its own output never changes it either.
337
+ * Without a merge base it falls back to the paths changed against HEAD plus HEAD itself.
319
338
  */
320
339
  function workFingerprint(projectPath) {
321
340
  const base = mergeBase(projectPath, "main") ?? mergeBase(projectPath, "master");
322
- const diff = git(projectPath, ["diff", base ?? "HEAD", "--", ".", ":(exclude)lawbook/changes"]);
341
+ const tracked = git(projectPath, [
342
+ "-c",
343
+ "core.quotePath=false",
344
+ "diff",
345
+ "--name-only",
346
+ base ?? "HEAD",
347
+ "--",
348
+ ".",
349
+ ":(exclude)lawbook/changes",
350
+ ":(exclude).speclaw",
351
+ ]).split("\n");
323
352
  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/"))
353
+ for (const f of [...new Set([...tracked, ...untrackedFiles(projectPath)])].sort()) {
354
+ if (!f)
326
355
  continue;
327
- // Contents, not just names: editing a new, not-yet-added file is new work.
328
356
  hash.update(`${f}\n`);
329
357
  try {
330
358
  hash.update(fs.readFileSync(path.join(projectPath, f)));
331
359
  }
332
360
  catch {
333
- // Vanished or unreadable since listing: its name stands in.
361
+ // Deleted, vanished or unreadable: its name stands in.
362
+ hash.update("\0");
334
363
  }
335
364
  }
336
365
  const anchor = base ?? git(projectPath, ["rev-parse", "HEAD"]).trim();
337
- return hash.update(`\n${anchor}\n${diff}`).digest("hex");
366
+ return hash.update(`\n${anchor}\n`).digest("hex");
338
367
  }
339
368
  /**
340
369
  * The `Stop` hook body: when the agent ends a turn on a feature branch with
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esneiderbravo/speclaw",
3
- "version": "2.0.17",
3
+ "version": "2.0.19",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },