@esneiderbravo/speclaw 2.0.5 → 2.0.7

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 CHANGED
@@ -4,12 +4,12 @@
4
4
 
5
5
  <br/>
6
6
 
7
- <a href="https://www.npmjs.com/package/@esneiderbravo/speclaw"><img src="https://img.shields.io/npm/v/@esneiderbravo/speclaw?color=0E8E8E&labelColor=0B0F10&style=flat-square&label=npm" alt="npm"></a>
8
- &nbsp;<a href="https://github.com/esneiderbravo/speclaw/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/esneiderbravo/speclaw/ci.yml?branch=main&label=CI&labelColor=0B0F10&style=flat-square&color=0E8E8E" alt="CI"></a>
9
- &nbsp;<a href="https://www.npmjs.com/package/@esneiderbravo/speclaw?activeTab=versions"><img src="https://img.shields.io/badge/provenance-SLSA-0E8E8E?labelColor=0B0F10&style=flat-square" alt="npm provenance"></a>
10
- &nbsp;<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-0E8E8E?labelColor=0B0F10&style=flat-square" alt="MIT"></a>
11
- &nbsp;<img src="https://img.shields.io/badge/node-%E2%89%A522.16-0E8E8E?labelColor=0B0F10&style=flat-square" alt="Node >= 22.16">
12
- &nbsp;<img src="https://img.shields.io/badge/v2.0-0E8E8E?labelColor=0B0F10&style=flat-square" alt="v2.0">
7
+ <a href="https://www.npmjs.com/package/@esneiderbravo/speclaw"><img src="https://img.shields.io/npm/v/@esneiderbravo/speclaw?color=00707f&labelColor=131313&style=flat-square&label=npm" alt="npm"></a>
8
+ &nbsp;<a href="https://github.com/esneiderbravo/speclaw/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/esneiderbravo/speclaw/ci.yml?branch=main&label=CI&labelColor=131313&style=flat-square&color=00707f" alt="CI"></a>
9
+ &nbsp;<a href="https://www.npmjs.com/package/@esneiderbravo/speclaw?activeTab=versions"><img src="https://img.shields.io/badge/provenance-SLSA-00707f?labelColor=131313&style=flat-square" alt="npm provenance"></a>
10
+ &nbsp;<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-00707f?labelColor=131313&style=flat-square" alt="MIT"></a>
11
+ &nbsp;<img src="https://img.shields.io/badge/node-%E2%89%A522.16-00707f?labelColor=131313&style=flat-square" alt="Node >= 22.16">
12
+ &nbsp;<img src="https://img.shields.io/badge/v2.0-00707f?labelColor=131313&style=flat-square" alt="v2.0">
13
13
 
14
14
  <br/><br/>
15
15
 
@@ -24,12 +24,12 @@ those laws hold in the editor, in CI, and in the PR.
24
24
  One command. No cloud, no LLM, no API keys — <b>everything runs on your machine.</b>
25
25
  </p>
26
26
 
27
- <img src="https://img.shields.io/badge/100%25_local-0E8E8E?labelColor=0B0F10&style=flat-square" alt="100% local">
28
- &nbsp;<img src="https://img.shields.io/badge/no_LLM_·_no_cloud-0E8E8E?labelColor=0B0F10&style=flat-square" alt="no LLM">
29
- &nbsp;<img src="https://img.shields.io/badge/CLI_+_MCP-0E8E8E?labelColor=0B0F10&style=flat-square" alt="CLI + MCP">
30
- &nbsp;<img src="https://img.shields.io/badge/9_canonical_tools-0E8E8E?labelColor=0B0F10&style=flat-square" alt="9 tools">
31
- &nbsp;<img src="https://img.shields.io/badge/Cortex-One_brain._Many_agents.-0E8E8E?labelColor=0B0F10&style=flat-square" alt="Cortex">
32
- &nbsp;<img src="https://img.shields.io/badge/any_agent-0E8E8E?labelColor=0B0F10&style=flat-square" alt="any agent">
27
+ <img src="https://img.shields.io/badge/100%25_local-00707f?labelColor=131313&style=flat-square" alt="100% local">
28
+ &nbsp;<img src="https://img.shields.io/badge/no_LLM_·_no_cloud-00707f?labelColor=131313&style=flat-square" alt="no LLM">
29
+ &nbsp;<img src="https://img.shields.io/badge/CLI_+_MCP-00707f?labelColor=131313&style=flat-square" alt="CLI + MCP">
30
+ &nbsp;<img src="https://img.shields.io/badge/9_canonical_tools-00707f?labelColor=131313&style=flat-square" alt="9 tools">
31
+ &nbsp;<img src="https://img.shields.io/badge/Cortex-One_brain._Many_agents.-00707f?labelColor=131313&style=flat-square" alt="Cortex">
32
+ &nbsp;<img src="https://img.shields.io/badge/any_agent-00707f?labelColor=131313&style=flat-square" alt="any agent">
33
33
 
34
34
  </div>
35
35
 
@@ -107,7 +107,7 @@ gh attestation verify <tarball> --owner esneiderbravo
107
107
  <img src="https://raw.githubusercontent.com/esneiderbravo/speclaw/main/brand/terminal-init.png" width="800" alt="speclaw init — terminal output">
108
108
  </p>
109
109
 
110
- <p align="center"><i>Teal steps, green checks, a live progress bar — themed with the speclaw palette.</i></p>
110
+ <p align="center"><i>Cyan steps, green checks, a live progress bar — themed with the speclaw palette.</i></p>
111
111
 
112
112
  <br/>
113
113
 
@@ -116,7 +116,7 @@ gh attestation verify <tarball> --owner esneiderbravo
116
116
  | Module | What it does |
117
117
  | :-- | :-- |
118
118
  | **Foundation** | The project's constitution: `LAWS.md` binding granular standards under `docs/standards/`, plus `CLAUDE.md` / `AGENTS.md`. **Enforced** via agent hooks (`speclaw check`), deterministic graph laws (`speclaw verify` / `laws verify`), multidialect compile (`laws compile`), and committed **`speclaw.lock`** digests + injection scan (`laws lock` / `accept` / `scan`). |
119
- | **Compass** | Local code graph (tree-sitter → `node:sqlite`): hybrid find (FTS5 + vectors + RRF + PageRank), impact, affected-tests, hotspots, coupling, visualize. Schema **10**. No LLM — lives in `.speclaw/` (gitignored). |
119
+ | **Compass** | Local code graph (tree-sitter → `node:sqlite`): hybrid find (FTS5 + vectors + RRF + PageRank), impact, affected-tests, hotspots, coupling, visualize. Schema **10**. Refreshed silently at every Claude Code session start once built. No LLM — lives in `.speclaw/` (gitignored). |
120
120
  | **Lawbook** | Spec-driven workflow with **adaptive ceremony** (levels 0–3), EARS linting, requirement **coverage**, sealed **drift** anchors, bugfix + `lawbook_investigate`. Artifacts under `lawbook/`. |
121
121
  | **Cortex** | **One brain. Many agents.** The multi-agent loop: durable `harness.json`, MCP tool `cortex` (`status`/`start`/`advance`/`rework`/`brief`), CLI `speclaw cortex`, skill `/lawbook/cortex`. Dispatches explorer → planner → implementer → reviewer → tester → archiver. |
122
122
  | **Team** | Declare `team.owners` in `lawbook/config.yaml`; `speclaw owners --write` compiles a managed trailing block in `.github/CODEOWNERS` (GitHub: last match wins). Doctor checks the posture. CLI-only — no MCP tool. |
@@ -247,7 +247,10 @@ regenerate `ai-specs/` locally. Optional **`team.owners`** in
247
247
 
248
248
  **Enforcement artifacts.** For agents that support hooks, speclaw merges its law
249
249
  hooks into that agent's settings **by identity** — it never touches hooks you
250
- added yourself. The compiled law manifest lives in `.speclaw/laws-manifest.json`
250
+ added yourself. The same merge adds one `SessionStart` command hook that
251
+ silently refreshes an existing Compass index when a session starts
252
+ (`speclaw session-start`; it skips when no index exists, never downloads or
253
+ contacts the registry, and always exits 0). The compiled law manifest lives in `.speclaw/laws-manifest.json`
251
254
  (gitignored) and is **adapted to the target tree** on `init`/`update` — speclaw's
252
255
  own architecture laws are seeded only when those paths exist, and the cycle law
253
256
  follows `apps/*/src`, `packages/*/src`, or `src/` rather than copying
@@ -2,9 +2,27 @@ import { buildIndex } from "../../modules/compass/indexer.js";
2
2
  import { startWatch } from "../../modules/compass/watcher.js";
3
3
  import { recordCompassCall } from "../../shared/compass-calls.js";
4
4
  import { ui, renderProgress, clearProgress } from "../lib/ui.js";
5
+ /** Usage text for `speclaw index --help`. */
6
+ export const INDEX_HELP = `Usage: speclaw index [options]
7
+
8
+ (Re)build the local Compass code graph for the current directory. Runs are
9
+ incremental: unchanged files are skipped, and an unchanged project skips the
10
+ global post-processing.
11
+
12
+ Options
13
+ --force Re-extract every file
14
+ --prune Also evict embedding-cache rows unused for --retention days
15
+ --retention <days> Retention window for --prune (default 30)
16
+ --max-cache-mb <mb> Embedding-cache size cap (default 256)
17
+ --json Print the index statistics as JSON
18
+ `;
5
19
  /** (Re)build the Compass code graph for the cwd, showing progress and final stats. */
6
20
  export async function runIndex(flags) {
7
21
  const cwd = process.cwd();
22
+ if (flags.help || flags.h) {
23
+ process.stdout.write(INDEX_HELP);
24
+ return;
25
+ }
8
26
  recordCompassCall(cwd, "compass_index");
9
27
  const json = Boolean(flags.json);
10
28
  if (!json)
@@ -0,0 +1,29 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ /**
4
+ * `speclaw session-start`: refresh an existing Compass index incrementally,
5
+ * print nothing, and never fail. It is a top-level command rather than an
6
+ * `index` flag so an older speclaw that resolves first rejects it as unknown
7
+ * before indexing anything. With no `.speclaw/index.db` it returns at once,
8
+ * before `node:sqlite` is even loaded — the first build stays an explicit
9
+ * `speclaw index` / `compass_index`. It is not logged as a Compass call: the
10
+ * hook would otherwise append an entry per session and rotate real evidence out.
11
+ *
12
+ * @param cwd - Project root (defaults to the process working directory).
13
+ */
14
+ // Covers: req~session-start-index~1
15
+ export async function runSessionStart(cwd = process.cwd()) {
16
+ try {
17
+ if (!fs.existsSync(path.join(cwd, ".speclaw", "index.db")))
18
+ return;
19
+ // Node lines that still flag `node:sqlite` as experimental would print an
20
+ // ExperimentalWarning to stderr; this command must print nothing.
21
+ process.removeAllListeners("warning");
22
+ const { buildIndex } = await import("../../modules/compass/indexer.js");
23
+ await buildIndex(cwd, {});
24
+ }
25
+ catch {
26
+ // A concurrent writer (SQLITE_BUSY after busy_timeout) or any other failure
27
+ // must never fail or print into a starting session.
28
+ }
29
+ }
package/dist/cli/index.js CHANGED
@@ -19,6 +19,7 @@ Setup
19
19
 
20
20
  Compass (code intelligence — the same surface agents use via MCP)
21
21
  index (Re)build the local code graph (--force / --prune / --json)
22
+ session-start Silent, fail-safe refresh of an existing index (SessionStart hook)
22
23
  watch Keep the index fresh on file changes
23
24
  explore <node> A node's source + callers/callees
24
25
  search <query> Hybrid find (BM25+vector+name); --focus --max-tokens --explain
@@ -99,7 +100,7 @@ const HEADER_COMMANDS = new Set([
99
100
  * forced-color signal counts as interactive so the header is exercisable in a
100
101
  * child process. `budget --json`, `doctor --json`, and `coverage` when emitting
101
102
  * TAP/JSON (or when stdout is not a TTY) are machine-consumed and suppress the
102
- * header.
103
+ * header. `session-start` is not header-eligible: it must print nothing.
103
104
  */
104
105
  function maybeHeader(cmd, flags) {
105
106
  if (!process.stdout.isTTY && process.env.FORCE_COLOR !== "1")
@@ -156,6 +157,8 @@ async function dispatch(cmd, flags) {
156
157
  return (await import("./commands/index-build.js")).runIndex(flags);
157
158
  case "watch":
158
159
  return (await import("./commands/index-build.js")).runWatch(flags);
160
+ case "session-start":
161
+ return (await import("./commands/session-start.js")).runSessionStart();
159
162
  case "explore":
160
163
  case "search":
161
164
  case "recall":
@@ -204,6 +207,9 @@ async function main() {
204
207
  const flags = parseFlags(rest);
205
208
  maybeHeader(cmd, flags);
206
209
  await dispatch(cmd, flags);
210
+ // The SessionStart hook's refresh must stay silent: no update notice either.
211
+ if (cmd === "session-start")
212
+ return;
207
213
  await maybeNotifyUpdate(cmd);
208
214
  }
209
215
  main().catch((err) => {
@@ -1,17 +1,21 @@
1
- // Brand-themed terminal UI. Colors come from the speclaw palette (cyan #2EE6E6
2
- // = the "law", cream text, muted gray, green/amber for status) rendered as
3
- // 24-bit truecolor ANSI — no dependency needed. Colors auto-disable when the
4
- // output is not a TTY or NO_COLOR is set.
1
+ // Brand-themed terminal UI. Colors come from the speclaw site's ink theme
2
+ // (`app/tokens.css` in speclaw-site): signal cyan #00E3FD = the "law" accent,
3
+ // ink #F4F4F3 primary text, ink-faint #999EA3 secondary text, deny #FF5C47 for
4
+ // errors, and green/amber re-tuned for success/warning. Every color holds at
5
+ // least 4.5:1 (WCAG 2.x) against the ink paper #131313. They render as 24-bit
6
+ // truecolor ANSI — no dependency needed — and auto-disable when the output is
7
+ // not a TTY or NO_COLOR is set.
5
8
  import { pkgVersion } from "../../shared/version.js";
6
- const PALETTE = {
7
- cyan: [46, 230, 230], // #2EE6E6 — the accent / "law"
8
- cyanDim: [23, 193, 193], // #17C1C1
9
- cream: [244, 241, 234], // #F4F1EA — primary text
10
- muted: [110, 123, 128], // #6E7B80 — secondary text
11
- green: [63, 185, 80], // #3FB950 — success
12
- amber: [227, 179, 65], // #E3B341 — warning
13
- red: [235, 90, 90],
14
- };
9
+ // Covers: req~brand-terminal-palette~1
10
+ export const PALETTE = Object.freeze({
11
+ cyan: [0, 227, 253], // #00E3FD — signal: the accent / "law"
12
+ cyanDim: [0, 227, 253], // #00E3FD — signal (the site has no mid cyan for text)
13
+ cream: [244, 244, 243], // #F4F4F3 — ink: primary text
14
+ muted: [153, 158, 163], // #999EA3 — ink-faint: secondary text
15
+ green: [62, 207, 122], // #3ECF7A — success (re-tuned)
16
+ amber: [245, 183, 61], // #F5B73D — warning (re-tuned)
17
+ red: [255, 92, 71], // #FF5C47 — deny: errors
18
+ });
15
19
  const colorOn = (Boolean(process.stdout.isTTY) || process.env.FORCE_COLOR === "1") && !process.env.NO_COLOR;
16
20
  // Whether the terminal reliably renders the unicode box/block glyphs the brand
17
21
  // output uses. Non-Windows terminals are assumed capable; a Windows console is
@@ -19,6 +19,8 @@ CREATE TABLE IF NOT EXISTS files (
19
19
  );
20
20
  CREATE INDEX IF NOT EXISTS idx_files_is_test ON files(is_test);
21
21
  -- nodes: the definitions in the codebase (functions, classes, methods, types).
22
+ -- start_byte/end_byte are UTF-16 code-unit offsets into the decoded source
23
+ -- (tree-sitter's startIndex/endIndex), not UTF-8 byte offsets, despite the names.
22
24
  CREATE TABLE IF NOT EXISTS nodes (
23
25
  id INTEGER PRIMARY KEY,
24
26
  file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
@@ -36,9 +36,9 @@ function digest(parts) {
36
36
  /**
37
37
  * Hash of the exact source bytes for a symbol range (detects cosmetic edits).
38
38
  *
39
- * @param source - Full file source as UTF-8 string.
40
- * @param startByte - Inclusive start offset.
41
- * @param endByte - Exclusive end offset.
39
+ * @param source - Full file source, decoded from UTF-8.
40
+ * @param startByte - Inclusive start offset: a UTF-16 code-unit index into `source`, despite the name.
41
+ * @param endByte - Exclusive end offset: a UTF-16 code-unit index into `source`, despite the name.
42
42
  */
43
43
  export function rawHash(source, startByte, endByte) {
44
44
  return createHash("sha256").update(source.slice(startByte, endByte)).digest("hex").slice(0, 32);
@@ -186,6 +186,13 @@ function* walkFiles(root) {
186
186
  * and an embedding cache keyed by embedder-input hash so renames/moves do not
187
187
  * recompute vectors. The whole run executes in a single transaction.
188
188
  *
189
+ * A run without force, prune, or an explicit `maxCacheMB` that re-extracts and
190
+ * removes nothing and finds the root hash unchanged is a no-op: it skips the
191
+ * dir-hash rewrite, edge/import resolution, PageRank, and the embedding-cache
192
+ * touch and eviction, writes only `meta.indexed_at`, and rewrites
193
+ * `docs/compass.md` only when its map block is empty. It still returns full
194
+ * statistics (`totals`, `nextStep`).
195
+ *
189
196
  * @param projectPath - Absolute path to the project root.
190
197
  * @param onProgressOrOpts - Progress callback (legacy) or {@link BuildIndexOptions}.
191
198
  */
@@ -251,6 +258,8 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
251
258
  signature = excluded.signature,
252
259
  doc = excluded.doc`);
253
260
  const allFiles = [...walkFiles(projectPath)];
261
+ // Set inside the transaction; read after it to decide whether the map is written.
262
+ let noop;
254
263
  db.exec("BEGIN");
255
264
  try {
256
265
  let done = 0;
@@ -369,37 +378,49 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
369
378
  const dirMap = buildDirHashMap(fileHashes);
370
379
  const rootHash = dirMap.get("") ?? "";
371
380
  stats.rootUnchanged = Boolean(prevRoot && prevRoot.hash === rootHash && !force && stats.files === 0);
372
- const now = Date.now();
373
- db.prepare("DELETE FROM dir_hashes").run();
374
- const insDir = db.prepare("INSERT INTO dir_hashes(path, hash, n_files, updated_at) VALUES (?, ?, ?, ?)");
375
- for (const [dir, hash] of dirMap) {
376
- const nFiles = [...fileHashes.keys()].filter((f) => dir === "" ? true : f === dir || f.startsWith(dir + "/")).length;
377
- insDir.run(dir, hash, nFiles, now);
378
- }
379
- db.exec(`
380
- UPDATE edges SET dst_node_id = (
381
- SELECT n.id FROM nodes n
382
- WHERE n.name = edges.dst_name
383
- ORDER BY CASE WHEN n.file_id = edges.src_file_id THEN 0 ELSE 1 END, n.id
384
- LIMIT 1
385
- )
386
- WHERE kind = 'call' AND dst_node_id IS NULL
387
- `);
388
- resolveImportEdges(db, projectPath);
389
- recomputeGlobalPagerank(db);
390
- // Touch last_seen for all live content hashes under active model
391
- db.prepare(`UPDATE embedding_cache SET last_seen_at = ?
392
- WHERE model = ?
393
- AND content_hash IN (SELECT content_hash FROM nodes WHERE content_hash IS NOT NULL)`).run(now, embedder.id);
394
- if (prune) {
395
- const cutoff = now - retentionDays * 24 * 60 * 60 * 1000;
396
- db.prepare(`DELETE FROM embedding_cache
397
- WHERE last_seen_at < ?
398
- AND content_hash NOT IN (SELECT content_hash FROM nodes WHERE content_hash IS NOT NULL)`).run(cutoff);
381
+ // Covers: req~index-noop-fast-path~1
382
+ // No-op fast path: nothing re-extracted or removed and the same root means
383
+ // dir hashes, edges, PageRank, and the cache are already what a full pass
384
+ // would write — skip them so a run on every session start stays cheap.
385
+ // `rootUnchanged` already implies no force (explicit or needs_reindex); an
386
+ // explicit prune or cache cap is a maintenance request and runs in full.
387
+ noop = stats.rootUnchanged && !prune && opts.maxCacheMB === undefined && stats.removed === 0;
388
+ if (!noop) {
389
+ const now = Date.now();
390
+ db.prepare("DELETE FROM dir_hashes").run();
391
+ const insDir = db.prepare("INSERT INTO dir_hashes(path, hash, n_files, updated_at) VALUES (?, ?, ?, ?)");
392
+ for (const [dir, hash] of dirMap) {
393
+ const nFiles = [...fileHashes.keys()].filter((f) => dir === "" ? true : f === dir || f.startsWith(dir + "/")).length;
394
+ insDir.run(dir, hash, nFiles, now);
395
+ }
396
+ db.exec(`
397
+ UPDATE edges SET dst_node_id = (
398
+ SELECT n.id FROM nodes n
399
+ WHERE n.name = edges.dst_name
400
+ ORDER BY CASE WHEN n.file_id = edges.src_file_id THEN 0 ELSE 1 END, n.id
401
+ LIMIT 1
402
+ )
403
+ WHERE kind = 'call' AND dst_node_id IS NULL
404
+ `);
405
+ resolveImportEdges(db, projectPath);
406
+ recomputeGlobalPagerank(db);
407
+ // Touch last_seen for all live content hashes under active model
408
+ db.prepare(`UPDATE embedding_cache SET last_seen_at = ?
409
+ WHERE model = ?
410
+ AND content_hash IN (SELECT content_hash FROM nodes WHERE content_hash IS NOT NULL)`).run(now, embedder.id);
411
+ if (prune) {
412
+ const cutoff = now - retentionDays * 24 * 60 * 60 * 1000;
413
+ db.prepare(`DELETE FROM embedding_cache
414
+ WHERE last_seen_at < ?
415
+ AND content_hash NOT IN (SELECT content_hash FROM nodes WHERE content_hash IS NOT NULL)`).run(cutoff);
416
+ }
417
+ evictCacheBySize(db, maxCacheMB);
399
418
  }
400
- evictCacheBySize(db, maxCacheMB);
419
+ // Written on every run, no-op included, so doctor's freshness check sees a
420
+ // verified-current index as fresh.
401
421
  db.prepare("INSERT INTO meta(key, value) VALUES ('indexed_at', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(new Date().toISOString());
402
- clearNeedsReindex(db);
422
+ if (!noop)
423
+ clearNeedsReindex(db);
403
424
  stats.totals = countTotals(db);
404
425
  stats.nextStep = indexNextStep(stats.totals);
405
426
  db.exec("COMMIT");
@@ -412,8 +433,11 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
412
433
  db.close();
413
434
  }
414
435
  try {
415
- const { writeCompactMap } = await import("./map.js");
416
- writeCompactMap(projectPath);
436
+ const { writeCompactMap, compactMapPending } = await import("./map.js");
437
+ // A no-op run leaves the tracked docs/compass.md alone (no working-tree
438
+ // churn) unless its map block is still empty.
439
+ if (!noop || compactMapPending(projectPath))
440
+ writeCompactMap(projectPath);
417
441
  }
418
442
  catch {
419
443
  // Map generation must never fail an index run.
@@ -82,6 +82,30 @@ export function generateCompactMap(projectPath) {
82
82
  db.close();
83
83
  }
84
84
  }
85
+ /**
86
+ * True when `docs/compass.md` holds both map markers with nothing but
87
+ * whitespace between them — a freshly scaffolded or reset map block that a
88
+ * no-op index run must still fill. False when the file or a marker is missing
89
+ * (there is nothing a map write could fill) or the block already has a map.
90
+ *
91
+ * @param projectPath - Project root.
92
+ * @returns Whether the compact map block is present but empty.
93
+ */
94
+ export function compactMapPending(projectPath) {
95
+ const compassPath = path.join(projectPath, "docs", "compass.md");
96
+ let text;
97
+ try {
98
+ text = fs.readFileSync(compassPath, "utf8");
99
+ }
100
+ catch {
101
+ return false;
102
+ }
103
+ const start = text.indexOf(MAP_START);
104
+ const end = text.indexOf(MAP_END);
105
+ if (start < 0 || end < 0 || end < start)
106
+ return false;
107
+ return text.slice(start + MAP_START.length, end).trim() === "";
108
+ }
85
109
  /**
86
110
  * Replace content between map markers in `docs/compass.md`. Preserves content
87
111
  * outside markers. Does nothing (with reason) when markers are missing or the
@@ -43,10 +43,25 @@ export function search(projectPath, query, limit = 25) {
43
43
  db.close();
44
44
  }
45
45
  }
46
- function readSource(projectPath, file, startByte, endByte) {
46
+ /**
47
+ * Read a symbol's source text from disk.
48
+ *
49
+ * @remarks The offsets are UTF-16 code-unit indices into the decoded file text
50
+ * (tree-sitter parses the `utf8`-decoded string), not byte offsets — so the file
51
+ * is decoded before slicing. A byte slice drifts on any preceding non-ASCII text.
52
+ *
53
+ * @param projectPath - Absolute project root.
54
+ * @param file - Repo-relative file path.
55
+ * @param startIndex - Inclusive start offset (`nodes.start_byte`).
56
+ * @param endIndex - Exclusive end offset (`nodes.end_byte`).
57
+ * @returns The symbol's source, or `""` when the file cannot be read.
58
+ *
59
+ * // Covers: req~explore-exact-source~1
60
+ */
61
+ function readSource(projectPath, file, startIndex, endIndex) {
47
62
  try {
48
- const buf = fs.readFileSync(path.join(projectPath, file));
49
- return buf.subarray(startByte, endByte).toString("utf8");
63
+ const text = fs.readFileSync(path.join(projectPath, file), "utf8");
64
+ return text.slice(startIndex, endIndex);
50
65
  }
51
66
  catch {
52
67
  return "";
@@ -88,11 +88,14 @@ export function visualize(projectPath, opts = {}) {
88
88
  /**
89
89
  * Render the graph as a self-contained, offline HTML page: the data is embedded
90
90
  * as JSON and drawn by an inline canvas force-directed renderer (no CDN, no
91
- * dependencies).
91
+ * dependencies). Colors are the site's ink tokens (`app/tokens.css` in
92
+ * speclaw-site): paper #131313, sheet #1f2022, rule #303236, ink #f4f4f3,
93
+ * ink-faint #999ea3, signal #00e3fd. No retired-palette color may return here.
92
94
  *
93
95
  * @param data - The nodes/links/totals to embed.
94
96
  * @returns A complete HTML document as a string.
95
97
  */
98
+ // Covers: req~brand-viewer-palette~1
96
99
  export function renderHtml(data) {
97
100
  const payload = JSON.stringify(data);
98
101
  return `<!doctype html>
@@ -100,20 +103,20 @@ export function renderHtml(data) {
100
103
  <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
101
104
  <title>speclaw · Compass graph</title>
102
105
  <style>
103
- :root { --cy:#17C1C1; --cr:#F4F1EA; --mu:#6E7B80; --bg:#0B0F10; }
106
+ :root { --cy:#00e3fd; --cr:#f4f4f3; --mu:#999ea3; --bg:#131313; --sh:#1f2022; --ru:#303236; }
104
107
  * { box-sizing:border-box; }
105
108
  html,body { margin:0; height:100%; background:var(--bg); color:var(--cr);
106
- font-family:'SF Mono','JetBrains Mono',Menlo,Consolas,monospace; overflow:hidden; }
109
+ font-family:'Chivo Mono','SFMono-Regular',Menlo,monospace; overflow:hidden; }
107
110
  canvas { display:block; cursor:grab; }
108
111
  canvas:active { cursor:grabbing; }
109
112
  .panel { position:fixed; top:16px; left:16px; padding:12px 14px; border-radius:10px;
110
- background:rgba(12,17,19,.82); border:1px solid #232A2D; font-size:12px; line-height:1.5; }
113
+ background:rgba(31,32,34,.82); border:1px solid var(--ru); font-size:12px; line-height:1.5; }
111
114
  .panel b { color:var(--cr); } .panel .sub { color:var(--mu); }
112
115
  .legend { position:fixed; bottom:16px; left:16px; font-size:11px; color:var(--mu);
113
- background:rgba(12,17,19,.82); border:1px solid #232A2D; border-radius:10px; padding:10px 12px; }
116
+ background:rgba(31,32,34,.82); border:1px solid var(--ru); border-radius:10px; padding:10px 12px; }
114
117
  .legend span { display:inline-flex; align-items:center; margin-right:12px; }
115
118
  .legend i { width:9px; height:9px; border-radius:50%; display:inline-block; margin-right:5px; }
116
- .tip { position:fixed; padding:6px 9px; border-radius:7px; background:#0E1517; border:1px solid #17C1C1;
119
+ .tip { position:fixed; padding:6px 9px; border-radius:7px; background:var(--sh); border:1px solid var(--cy);
117
120
  color:var(--cr); font-size:12px; pointer-events:none; opacity:0; transition:opacity .1s; white-space:nowrap; }
118
121
  .brand { color:var(--cy); font-weight:700; }
119
122
  </style></head><body>
@@ -127,9 +130,9 @@ export function renderHtml(data) {
127
130
  <div class="tip" id="tip"></div>
128
131
  <script>
129
132
  const DATA = ${payload};
130
- const KIND_COLORS = { function:'#17C1C1', method:'#3FB950', class:'#E3B341',
131
- interface:'#8B989E', type:'#8B989E', enum:'#8B989E' };
132
- const colorOf = k => KIND_COLORS[k] || '#6E7B80';
133
+ const KIND_COLORS = { function:'#00e3fd', method:'#3ecf7a', class:'#f5b73d',
134
+ interface:'#babdc1', type:'#babdc1', enum:'#babdc1' };
135
+ const colorOf = k => KIND_COLORS[k] || '#7c8083';
133
136
 
134
137
  const cv = document.getElementById('c'), ctx = cv.getContext('2d');
135
138
  const tip = document.getElementById('tip');
@@ -179,7 +182,7 @@ function draw(){
179
182
  ctx.lineWidth = 1;
180
183
  for(const l of L){ const a=toScreen(N[l.s]), b=toScreen(N[l.t]);
181
184
  const on = hover>=0 && (l.s===hover||l.t===hover);
182
- ctx.strokeStyle = on ? 'rgba(23,193,193,.7)' : 'rgba(110,123,128,.16)';
185
+ ctx.strokeStyle = on ? 'rgba(0,227,253,.7)' : 'rgba(124,128,131,.16)';
183
186
  ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y); ctx.stroke();
184
187
  }
185
188
  for(let i=0;i<N.length;i++){ const n=N[i], p=toScreen(n);
@@ -187,7 +190,7 @@ function draw(){
187
190
  ctx.globalAlpha = dim ? 0.28 : 1;
188
191
  ctx.beginPath(); ctx.arc(p.x,p.y,n.r*Math.sqrt(scale),0,7); ctx.fillStyle=colorOf(n.kind); ctx.fill();
189
192
  if((n.r>7 || i===hover || (hoverSet&&hoverSet.has(i))) && scale>0.5){
190
- ctx.globalAlpha = dim?0.4:0.9; ctx.fillStyle='#F4F1EA'; ctx.font='11px monospace';
193
+ ctx.globalAlpha = dim?0.4:0.9; ctx.fillStyle='#f4f4f3'; ctx.font="11px 'Chivo Mono','SFMono-Regular',Menlo,monospace";
191
194
  ctx.fillText(n.name, p.x+n.r+3, p.y+3);
192
195
  }
193
196
  }
@@ -208,7 +211,7 @@ addEventListener('mousemove', e=>{
208
211
  lastX=e.clientX; lastY=e.clientY;
209
212
  const i=pick(e.clientX,e.clientY); hover=i;
210
213
  if(i>=0){ const n=N[i]; tip.style.opacity=1; tip.style.left=(e.clientX+12)+'px'; tip.style.top=(e.clientY+12)+'px';
211
- tip.innerHTML='<b>'+n.name+'</b> <span style="color:#6E7B80">'+n.kind+' · '+n.file+':'+n.line+'</span>'; }
214
+ tip.innerHTML='<b>'+n.name+'</b> <span style="color:#999ea3">'+n.kind+' · '+n.file+':'+n.line+'</span>'; }
212
215
  else tip.style.opacity=0;
213
216
  });
214
217
  addEventListener('mouseup', ()=>{ dragging=null; panning=false; });
@@ -20,7 +20,8 @@ Claude-specific notes: [`CLAUDE.md`](CLAUDE.md). The law: [`LAWS.md`](LAWS.md).
20
20
  `compass_explore` **before** any grep/sed/cat/Read,
21
21
  including files you already know by name. Fall back to manual file tools only
22
22
  after Compass returns nothing useful, the graph is missing (`compass_index`
23
- first), or the target isn't indexed code (stylesheets, config, logs). Cheat
23
+ first; Claude Code sessions refresh an existing index at start), or the
24
+ target isn't indexed code (stylesheets, config, logs). Cheat
24
25
  sheet: [`docs/compass.md`](docs/compass.md).
25
26
  3. **Follow Cortex** (*One brain. Many agents.*) for every non-trivial change;
26
27
  archive within the same PR. The primary agent is the **coordinator**
@@ -35,7 +35,8 @@ where it lives, how a value flows — call Compass **first**: `compass_find` to
35
35
  locate, `compass_explore` to read a symbol with its callers and callees (include
36
36
  `blast_radius` for what could break, `to` for a call path), and
37
37
  `compass_diff_context` for the context of a diff.
38
- Run `compass_index` first if the graph is missing.
38
+ Run `compass_index` first if the graph is missing. Under Claude Code a
39
+ `SessionStart` hook refreshes an existing index when each session starts.
39
40
 
40
41
  This includes files you already know the name of: to learn what `Foo` imports,
41
42
  uses, or depends on, run `compass_explore Foo` — do **not** `cat`/`sed`/`grep`/
@@ -37,6 +37,12 @@ a request, not whole files.
37
37
  `compass_index` also takes `action: start|stop|status` to keep the index fresh
38
38
  automatically (a debounced incremental re-index on file change).
39
39
 
40
+ Under Claude Code, a `SessionStart` hook refreshes an existing index when each
41
+ session starts (`speclaw session-start`: silent, never fails, skips
42
+ when `.speclaw/index.db` is absent, and leaves this file alone when nothing
43
+ changed). The first build is always an explicit `compass_index` /
44
+ `speclaw index`.
45
+
40
46
  If the graph is missing (no `.speclaw/index.db`), run `compass_index` first —
41
47
  a missing graph is not license to skip Compass. The only legitimate fallbacks
42
48
  to Grep/Read: a Compass call returned nothing useful for your query, or the
@@ -3,6 +3,39 @@ import path from "node:path";
3
3
  import { agentById } from "../../shared/agents.js";
4
4
  import { sha256 } from "../../shared/install.js";
5
5
  import { globError, hasBackend } from "./laws.js";
6
+ /**
7
+ * Substring every speclaw session-start command contains, in each of its
8
+ * resolution branches. It is the merge identity of the `SessionStart` hook.
9
+ */
10
+ export const SESSION_START_MARKER = "speclaw session-start";
11
+ /**
12
+ * The marker of the unreleased `index --session-start` hook shape that
13
+ * pre-release 2.0.7 builds wrote. Still recognized so a merge replaces such an
14
+ * entry instead of keeping it next to the current one.
15
+ */
16
+ const LEGACY_SESSION_START_MARKER = "speclaw index --session-start";
17
+ /**
18
+ * The POSIX `sh` command the `SessionStart` hook runs. The `index.db` guard runs
19
+ * in the shell, so a project without an index pays no Node start-up. It invokes
20
+ * the top-level `session-start` command, which a speclaw older than 2.0.7
21
+ * rejects as unknown (exit 1) before indexing, logging, or notifying, so a stale
22
+ * binary first in the resolution order does nothing. speclaw resolves as local
23
+ * `node_modules/.bin`, then `PATH`, then `npx --no-install` with
24
+ * `npm_config_offline=true` and `npm_config_update_notifier=false`, so npx
25
+ * neither installs nor contacts the registry (offline alone still lets npm's
26
+ * own update notifier query it).
27
+ * Output is discarded because `SessionStart` stdout enters the agent's context,
28
+ * and `|| true` keeps the exit code 0 in every case.
29
+ */
30
+ export const SESSION_START_COMMAND = 'cd "${CLAUDE_PROJECT_DIR:-.}" 2>/dev/null && [ -f .speclaw/index.db ] && { ' +
31
+ "if [ -x node_modules/.bin/speclaw ]; then node_modules/.bin/speclaw session-start; " +
32
+ "elif command -v speclaw >/dev/null 2>&1; then speclaw session-start; " +
33
+ "else npm_config_update_notifier=false npm_config_offline=true npx --no-install @esneiderbravo/speclaw session-start; fi; " +
34
+ "} >/dev/null 2>&1 || true";
35
+ /** `SessionStart` matcher covering every session source Claude Code reports. */
36
+ const SESSION_START_MATCHER = "startup|resume|clear|compact";
37
+ /** Seconds the blocking session-start hook may run (index wait plus a real refresh). */
38
+ const SESSION_START_TIMEOUT = 30;
6
39
  /** Claude Code `${path}` templates — see https://code.claude.com/docs/en/hooks */
7
40
  const SPECLAW_HOOK_INPUT = {
8
41
  projectPath: "${cwd}",
@@ -36,17 +69,32 @@ const MUTATION_MATCHER = "Write|Edit|MultiEdit|NotebookEdit";
36
69
  * and the event cannot gate the tool; a `PreToolUse` "allow" would auto-approve.
37
70
  */
38
71
  export const NUDGE_MATCHER = "Read|Grep|Glob";
39
- /** True when a hook object is one speclaw owns (safe to replace on merge). */
40
- function isSpeclawHook(h) {
72
+ /**
73
+ * True when a hook object is one speclaw owns (safe to replace on merge): an
74
+ * `mcp_tool` hook on the `speclaw` server, or a `command` hook whose command
75
+ * contains {@link SESSION_START_MARKER} (or the pre-release
76
+ * `index --session-start` marker). A user `command` hook without the
77
+ * marker is never speclaw's.
78
+ *
79
+ * @param h - A hook object read from an agent's settings (any shape).
80
+ * @returns Whether speclaw owns the hook.
81
+ */
82
+ export function isSpeclawHook(h) {
41
83
  const o = h;
42
- return o?.type === "mcp_tool" && o?.server === "speclaw";
84
+ if (o?.type === "mcp_tool" && o?.server === "speclaw")
85
+ return true;
86
+ return (o?.type === "command" &&
87
+ typeof o?.command === "string" &&
88
+ (o.command.includes(SESSION_START_MARKER) || o.command.includes(LEGACY_SESSION_START_MARKER)));
43
89
  }
44
90
  /**
45
91
  * Compile a law manifest into the hook groups speclaw contributes, one per event
46
92
  * the laws demand: `PreToolUse` when any `bloqueo` law exists, `PostToolUse` for
47
93
  * `feedback`, `Stop` for `gate`, and `InstructionsLoaded` whenever any law exists
48
- * (the context-coverage audit). One `PostToolUse` group matching `Read|Grep|Glob`
49
- * is always emitted for the Compass-first nudge, even with no laws. A law whose scope contains a malformed glob is
94
+ * (the context-coverage audit). Two groups are always emitted, even with no
95
+ * laws: one `PostToolUse` group matching `Read|Grep|Glob` for the Compass-first
96
+ * nudge, and one `SessionStart` group whose `command` hook refreshes the index
97
+ * silently when a session starts. A law whose scope contains a malformed glob is
50
98
  * excluded and reported, so a bad pattern fails loudly at generation rather than
51
99
  * silently matching nothing at runtime.
52
100
  *
@@ -77,13 +125,20 @@ export function compileHooks(manifest) {
77
125
  byEvent.Stop = [{ hooks: [{ ...SPECLAW_HOOK }] }];
78
126
  if (valid.length > 0)
79
127
  byEvent.InstructionsLoaded = [{ hooks: [{ ...SPECLAW_HOOK }] }];
128
+ // Covers: req~session-start-hook~1
129
+ byEvent.SessionStart = [
130
+ {
131
+ matcher: SESSION_START_MATCHER,
132
+ hooks: [{ type: "command", command: SESSION_START_COMMAND, timeout: SESSION_START_TIMEOUT }],
133
+ },
134
+ ];
80
135
  return { byEvent, invalid };
81
136
  }
82
137
  /**
83
138
  * Merge speclaw's compiled hook groups into an existing `hooks` object by
84
139
  * identity: for every event, drop the groups speclaw owns (a group whose hooks
85
140
  * are all speclaw's) and re-add the freshly compiled ones, never touching a
86
- * group with a foreign `server` or `type`. Idempotent, marker-free, and it
141
+ * hook that is not speclaw's (see {@link isSpeclawHook}). Idempotent, and it
87
142
  * cannot delete another tool's hooks.
88
143
  *
89
144
  * @param existing - The current `hooks` object from the agent's settings (any shape).
@@ -11,8 +11,9 @@ You are the **explorer** role in speclaw's **Cortex** (One brain. Many agents.).
11
11
 
12
12
  Investigate the idea or bug with Compass **before** any grep/read: locate with
13
13
  `compass_find`, read with `compass_explore`, and run `compass_index` only when
14
- find returns nothing or reports a missing or stale index. Read/Grep code only
15
- after naming which Rule 1 fallback holds. Produce a short brief for the
14
+ find returns nothing or reports a missing or stale index (under Claude Code an
15
+ existing index is already refreshed when the session starts). Read/Grep code
16
+ only after naming which Rule 1 fallback holds. Produce a short brief for the
16
17
  planner: relevant symbols, blast radius, risks, open questions, and
17
18
  `Compass calls made: N`. Do **not** draft change artifacts or edit source.
18
19
 
@@ -2,7 +2,8 @@
2
2
 
3
3
  - Use `compass_explore` before editing to see a symbol's callers/callees and
4
4
  blast radius; re-run `compass_index` after significant edits to keep the
5
- graph fresh.
5
+ graph fresh (the `SessionStart` hook refreshes it only when a session
6
+ starts).
6
7
  - Make the smallest correct change; match the surrounding code.
7
8
  - The code must satisfy the delta spec exactly. If reality diverges from the
8
9
  spec, update the spec in the change (not silently) — the two must agree.
@@ -6,9 +6,11 @@
6
6
  `compass_find` or `compass_explore` only for a gap the brief names. Re-read
7
7
  a file under `docs/standards/` only when the brief does not cite it.
8
8
  - When the handoff has no complete explorer brief, refresh the index with
9
- `compass_index`, then use `compass_explore` and `compass_find` (mode:
10
- concept) before grep/read to locate the code and its blast radius. Read the
11
- governing standards in `docs/standards/`.
9
+ `compass_index` (an existing index was already refreshed at session start
10
+ under Claude Code; refresh again after edits or when it is missing), then use
11
+ `compass_explore` and `compass_find` (mode: concept) before grep/read to
12
+ locate the code and its blast radius. Read the governing standards in
13
+ `docs/standards/`.
12
14
  - Clarify what the user wants (feature / fix / refactor) and confirm scope.
13
15
  - When `change.json` already has a `confirmedLevel` (the coordinator recorded
14
16
  it in the single question round), use that level and skip the separate level
@@ -6,7 +6,9 @@
6
6
  callees. Use `compass_diff_context` when the question is about a diff.
7
7
  - **Index only when needed.** Run `compass_index` only when `compass_find`
8
8
  returns nothing or reports a missing or stale index, then retry the find. It
9
- is incremental (unchanged files skipped by hash).
9
+ is incremental (unchanged files skipped by hash). Under Claude Code the
10
+ `SessionStart` hook already refreshes an existing index when the session
11
+ starts, so it is usually current.
10
12
  - **Read/Grep code only with a named reason.** Before any Read/Grep/Glob of
11
13
  indexed source code, name which Rule 1 fallback holds: (1) a Compass call ran
12
14
  and returned nothing useful for the query, (2) the graph is missing and
@@ -1,6 +1,8 @@
1
1
  # Investigate the bug
2
2
 
3
- - Refresh the index (`compass_index`).
3
+ - Refresh the index (`compass_index`). Under Claude Code the `SessionStart`
4
+ hook already refreshed an existing index when the session started; refresh
5
+ again when files changed since then or the index is missing.
4
6
  - Call **`lawbook_investigate`** with `stackTrace` and/or `symptom`.
5
7
  - **`compass_explore`** the top suspect — read the code yourself.
6
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esneiderbravo/speclaw",
3
- "version": "2.0.5",
3
+ "version": "2.0.7",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -59,6 +59,8 @@
59
59
  },
60
60
  "devDependencies": {
61
61
  "@eslint/js": "^10.0.1",
62
+ "@fontsource/chivo": "^5.3.0",
63
+ "@fontsource/chivo-mono": "^5.3.0",
62
64
  "@resvg/resvg-js": "^2.6.2",
63
65
  "@types/node": "^22.0.0",
64
66
  "eslint": "^10.8.0",
@@ -67,6 +69,7 @@
67
69
  "globals": "^17.9.0",
68
70
  "prettier": "^3.9.6",
69
71
  "typescript": "^5.6.0",
70
- "typescript-eslint": "^8.65.0"
72
+ "typescript-eslint": "^8.65.0",
73
+ "wawoff2": "^2.0.1"
71
74
  }
72
75
  }