@esneiderbravo/speclaw 0.1.14 → 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/dist/cli/commands/lawbook.js +6 -1
- package/dist/cli/commands/version.js +31 -0
- package/dist/cli/index.js +39 -1
- package/dist/cli/lib/ui.js +92 -14
- package/dist/cli/lib/update-check.js +33 -16
- package/dist/modules/lawbook/assets/skills/draft/SKILL.md +25 -6
- package/dist/modules/lawbook/assets/skills/explore/SKILL.md +4 -1
- package/dist/modules/lawbook/assets/skills/sync/SKILL.md +6 -2
- package/dist/modules/lawbook/engine.js +83 -7
- package/dist/modules/lawbook/register.js +2 -2
- package/package.json +3 -1
|
@@ -40,17 +40,22 @@ export async function runSpec(flags) {
|
|
|
40
40
|
ui.warn(`${r.change} has ${r.issues.length} issue(s):`);
|
|
41
41
|
r.issues.forEach((i) => ui.info(i));
|
|
42
42
|
}
|
|
43
|
+
if (r.warnings.length > 0) {
|
|
44
|
+
ui.warn(`${r.warnings.length} advisory warning(s):`);
|
|
45
|
+
r.warnings.forEach((w) => ui.info(w));
|
|
46
|
+
}
|
|
43
47
|
return;
|
|
44
48
|
}
|
|
45
49
|
case "sync": {
|
|
46
50
|
const r = specSync(cwd, req(change, "spec sync <change>"));
|
|
47
51
|
ui.ok(`promoted ${r.promoted.length} spec(s)`);
|
|
48
|
-
r.promoted.forEach((p) => ui.info(p));
|
|
52
|
+
r.promoted.forEach((p) => ui.info(`${r.created.includes(p) ? "created" : "updated"}: ${p}`));
|
|
49
53
|
return;
|
|
50
54
|
}
|
|
51
55
|
case "archive": {
|
|
52
56
|
const r = specArchive(cwd, req(change, "spec archive <change>"), today());
|
|
53
57
|
ui.ok(`archived to ${r.archivedTo} (${r.promoted.length} spec(s) promoted)`);
|
|
58
|
+
r.promoted.forEach((p) => ui.info(`${r.created.includes(p) ? "created" : "updated"}: ${p}`));
|
|
54
59
|
return;
|
|
55
60
|
}
|
|
56
61
|
default:
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { pkgVersion } from "../../shared/version.js";
|
|
2
|
+
import { checkForUpdates, upgradeNotice } from "../lib/update-check.js";
|
|
3
|
+
/**
|
|
4
|
+
* Print the locally installed speclaw version and, when a newer version is
|
|
5
|
+
* published on npm, a clickable suggestion to upgrade.
|
|
6
|
+
*
|
|
7
|
+
* The installed version (from the package's own `package.json`) always goes to
|
|
8
|
+
* **stdout** as a bare line, so it stays script- and pipe-friendly
|
|
9
|
+
* (`v=$(speclaw --version)`). The upgrade suggestion — which requires a network
|
|
10
|
+
* lookup — goes to **stderr** and only when stderr is an interactive TTY and
|
|
11
|
+
* the notifier is not disabled, so scripts and CI pay no network cost and get
|
|
12
|
+
* clean output. The lookup is forced (bypasses the daily cache) so an explicit
|
|
13
|
+
* version query reflects npm right now, and every failure is swallowed: a
|
|
14
|
+
* flaky or offline registry must never break `--version`.
|
|
15
|
+
*/
|
|
16
|
+
export async function runVersion() {
|
|
17
|
+
console.log(pkgVersion());
|
|
18
|
+
if (process.env.NO_UPDATE_NOTIFIER || process.env.SPECLAW_NO_UPDATE_NOTIFIER)
|
|
19
|
+
return;
|
|
20
|
+
if (!process.stderr.isTTY)
|
|
21
|
+
return;
|
|
22
|
+
try {
|
|
23
|
+
const { current, latest, updateAvailable } = await checkForUpdates({ force: true });
|
|
24
|
+
if (updateAvailable && latest) {
|
|
25
|
+
process.stderr.write("\n" + upgradeNotice(current, latest) + "\n\n");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
/* the update check is best-effort — never let it break `--version` */
|
|
30
|
+
}
|
|
31
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { parseFlags } from "./lib/args.js";
|
|
3
|
-
import { ui } from "./lib/ui.js";
|
|
3
|
+
import { ui, header } from "./lib/ui.js";
|
|
4
4
|
import { maybeNotifyUpdate } from "./lib/update-check.js";
|
|
5
5
|
const HELP = `speclaw — spec-driven, agent-ready projects (foundation + Compass + Lawbook)
|
|
6
6
|
|
|
@@ -36,7 +36,40 @@ Other
|
|
|
36
36
|
doctor Verify the installation
|
|
37
37
|
mcp Start the MCP server (used by your agent's config)
|
|
38
38
|
help Show this help
|
|
39
|
+
--version Print the installed speclaw version
|
|
39
40
|
`;
|
|
41
|
+
// Commands that open with the one-line branded header. These are the
|
|
42
|
+
// interactive, human-facing commands whose stdout is prose. Deliberately
|
|
43
|
+
// excluded: `version`/`--version`/`-v` (bare scriptable value), the Compass
|
|
44
|
+
// query family (`explore`/`search`/`recall`/`impact`/`trace`, machine-consumed
|
|
45
|
+
// output), `mcp` (a long-running stdio server), and `init` (already opens with
|
|
46
|
+
// the fuller `banner()`).
|
|
47
|
+
const HEADER_COMMANDS = new Set([
|
|
48
|
+
undefined,
|
|
49
|
+
"help",
|
|
50
|
+
"--help",
|
|
51
|
+
"-h",
|
|
52
|
+
"update",
|
|
53
|
+
"agent",
|
|
54
|
+
"doctor",
|
|
55
|
+
"index",
|
|
56
|
+
"watch",
|
|
57
|
+
"lawbook",
|
|
58
|
+
]);
|
|
59
|
+
/**
|
|
60
|
+
* Print the branded header once, ahead of a command's output, when it is a
|
|
61
|
+
* header-eligible command AND stdout is an interactive terminal (so pipes,
|
|
62
|
+
* redirection, and CI stay clean — mirroring the color gate in `ui.ts`). A
|
|
63
|
+
* forced-color signal counts as interactive so the header is exercisable in a
|
|
64
|
+
* child process.
|
|
65
|
+
*/
|
|
66
|
+
function maybeHeader(cmd) {
|
|
67
|
+
if (!process.stdout.isTTY && process.env.FORCE_COLOR !== "1")
|
|
68
|
+
return;
|
|
69
|
+
if (!HEADER_COMMANDS.has(cmd))
|
|
70
|
+
return;
|
|
71
|
+
header();
|
|
72
|
+
}
|
|
40
73
|
/** Run the handler for a single command. Returns when the command completes. */
|
|
41
74
|
async function dispatch(cmd, flags) {
|
|
42
75
|
switch (cmd) {
|
|
@@ -46,6 +79,10 @@ async function dispatch(cmd, flags) {
|
|
|
46
79
|
case "-h":
|
|
47
80
|
console.log(HELP);
|
|
48
81
|
return;
|
|
82
|
+
case "version":
|
|
83
|
+
case "--version":
|
|
84
|
+
case "-v":
|
|
85
|
+
return (await import("./commands/version.js")).runVersion();
|
|
49
86
|
case "mcp": {
|
|
50
87
|
const { startMcpServer } = await import("../server.js");
|
|
51
88
|
await startMcpServer();
|
|
@@ -83,6 +120,7 @@ async function dispatch(cmd, flags) {
|
|
|
83
120
|
async function main() {
|
|
84
121
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
85
122
|
const flags = parseFlags(rest);
|
|
123
|
+
maybeHeader(cmd);
|
|
86
124
|
await dispatch(cmd, flags);
|
|
87
125
|
await maybeNotifyUpdate(cmd);
|
|
88
126
|
}
|
package/dist/cli/lib/ui.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// = the "law", cream text, muted gray, green/amber for status) rendered as
|
|
3
3
|
// 24-bit truecolor ANSI — no dependency needed. Colors auto-disable when the
|
|
4
4
|
// output is not a TTY or NO_COLOR is set.
|
|
5
|
+
import { pkgVersion } from "../../shared/version.js";
|
|
5
6
|
const PALETTE = {
|
|
6
7
|
cyan: [46, 230, 230], // #2EE6E6 — the accent / "law"
|
|
7
8
|
cyanDim: [23, 193, 193], // #17C1C1
|
|
@@ -12,6 +13,44 @@ const PALETTE = {
|
|
|
12
13
|
red: [235, 90, 90],
|
|
13
14
|
};
|
|
14
15
|
const colorOn = (Boolean(process.stdout.isTTY) || process.env.FORCE_COLOR === "1") && !process.env.NO_COLOR;
|
|
16
|
+
// Whether the terminal reliably renders the unicode box/block glyphs the brand
|
|
17
|
+
// output uses. Non-Windows terminals are assumed capable; a Windows console is
|
|
18
|
+
// trusted only under a modern-terminal signal (Windows Terminal, an embedding
|
|
19
|
+
// program like VS Code, or CI) — a legacy conhost with a non-UTF-8 code page
|
|
20
|
+
// would otherwise show mojibake. No dependency; the check runs once at load.
|
|
21
|
+
const unicodeOn = process.platform !== "win32" ||
|
|
22
|
+
Boolean(process.env.WT_SESSION || process.env.TERM_PROGRAM || process.env.CI);
|
|
23
|
+
// The brand glyph set, resolved once against terminal capability. Every branded
|
|
24
|
+
// renderer (header, banner, box, progress) draws from this so unicode and ASCII
|
|
25
|
+
// terminals degrade together instead of one surface emitting unrenderable
|
|
26
|
+
// glyphs. The ASCII fallbacks are chosen to preserve each drawing's shape.
|
|
27
|
+
const G = unicodeOn
|
|
28
|
+
? {
|
|
29
|
+
diamond: "◈",
|
|
30
|
+
dot: "·",
|
|
31
|
+
boxTL: "╭",
|
|
32
|
+
boxTR: "╮",
|
|
33
|
+
boxBL: "╰",
|
|
34
|
+
boxBR: "╯",
|
|
35
|
+
boxV: "│",
|
|
36
|
+
boxH: "─",
|
|
37
|
+
bar: "▇",
|
|
38
|
+
fill: "█",
|
|
39
|
+
track: "░",
|
|
40
|
+
}
|
|
41
|
+
: {
|
|
42
|
+
diamond: ">",
|
|
43
|
+
dot: "-",
|
|
44
|
+
boxTL: "+",
|
|
45
|
+
boxTR: "+",
|
|
46
|
+
boxBL: "+",
|
|
47
|
+
boxBR: "+",
|
|
48
|
+
boxV: "|",
|
|
49
|
+
boxH: "-",
|
|
50
|
+
bar: "#",
|
|
51
|
+
fill: "#",
|
|
52
|
+
track: "-",
|
|
53
|
+
};
|
|
15
54
|
function paint(rgb, s) {
|
|
16
55
|
if (!colorOn)
|
|
17
56
|
return s;
|
|
@@ -20,6 +59,22 @@ function paint(rgb, s) {
|
|
|
20
59
|
function bold(s) {
|
|
21
60
|
return colorOn ? `\x1b[1m${s}\x1b[0m` : s;
|
|
22
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Wrap `label` in an OSC 8 terminal hyperlink pointing at `url`, so a
|
|
64
|
+
* capable terminal renders it as a clickable link. Terminals that don't
|
|
65
|
+
* support OSC 8 simply ignore the escapes and show the label. Falls back to a
|
|
66
|
+
* plain `label (url)` when rich output is off (non-TTY / NO_COLOR) so piped and
|
|
67
|
+
* dumb-terminal output stays legible.
|
|
68
|
+
*
|
|
69
|
+
* @param label - The visible, clickable text.
|
|
70
|
+
* @param url - The target the terminal opens on click.
|
|
71
|
+
* @returns The label wrapped as a hyperlink, or `label (url)` when off.
|
|
72
|
+
*/
|
|
73
|
+
export function link(label, url) {
|
|
74
|
+
if (!colorOn)
|
|
75
|
+
return `${label} (${url})`;
|
|
76
|
+
return `\x1b]8;;${url}\x1b\\${label}\x1b]8;;\x1b\\`;
|
|
77
|
+
}
|
|
23
78
|
/** Brand color helpers for composing styled strings. */
|
|
24
79
|
export const c = {
|
|
25
80
|
cyan: (s) => paint(PALETTE.cyan, s),
|
|
@@ -42,21 +97,43 @@ export const ui = {
|
|
|
42
97
|
plain: (s = "") => console.log(s),
|
|
43
98
|
code: (s) => c.cyan(s),
|
|
44
99
|
};
|
|
100
|
+
/**
|
|
101
|
+
* A single-line branded header — mark · name · installed version · tagline —
|
|
102
|
+
* printed once at the top of interactive commands (see `src/cli/index.ts`). The
|
|
103
|
+
* version comes from the cached {@link pkgVersion}. Glyphs degrade to ASCII on
|
|
104
|
+
* terminals without reliable unicode, and the styling no-ops to plain text when
|
|
105
|
+
* color is off, so the line stays legible everywhere.
|
|
106
|
+
*
|
|
107
|
+
* Example: `◈ speclaw v0.1.15 · where specs become law`
|
|
108
|
+
*/
|
|
109
|
+
export function header() {
|
|
110
|
+
const mark = c.cyan(G.diamond);
|
|
111
|
+
const name = bold(c.cream("speclaw"));
|
|
112
|
+
const ver = c.muted("v" + pkgVersion());
|
|
113
|
+
const tag = c.muted(G.dot + " where specs become law");
|
|
114
|
+
console.log(`${mark} ${name} ${ver} ${tag}`);
|
|
115
|
+
}
|
|
45
116
|
/**
|
|
46
117
|
* The speclaw wordmark + logo mark (a document whose bottom line — the law — is
|
|
47
118
|
* highlighted in cyan). Printed at the top of `speclaw init`.
|
|
48
119
|
*/
|
|
49
120
|
export function banner() {
|
|
50
|
-
const
|
|
51
|
-
const
|
|
121
|
+
const H = G.boxH;
|
|
122
|
+
const bar = c.cyan(G.bar.repeat(6));
|
|
123
|
+
const line = c.muted(H.repeat(6));
|
|
52
124
|
const edge = c.muted;
|
|
53
125
|
console.log();
|
|
54
|
-
console.log(" " + edge(
|
|
55
|
-
console.log(" " + edge("
|
|
56
|
-
console.log(" " +
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
126
|
+
console.log(" " + edge(G.boxTL + H.repeat(8) + G.boxTR));
|
|
127
|
+
console.log(" " + edge(G.boxV + " ") + line + edge(" " + G.boxV) + " " + bold(c.cream("s p e c l a w")));
|
|
128
|
+
console.log(" " +
|
|
129
|
+
edge(G.boxV + " ") +
|
|
130
|
+
c.muted(H.repeat(4) + " ") +
|
|
131
|
+
edge(" " + G.boxV) +
|
|
132
|
+
" " +
|
|
133
|
+
c.muted("where specs become law"));
|
|
134
|
+
console.log(" " + edge(G.boxV + " ") + c.muted(H.repeat(5)) + " " + edge(" " + G.boxV));
|
|
135
|
+
console.log(" " + edge(G.boxV + " ") + bar + edge(" " + G.boxV));
|
|
136
|
+
console.log(" " + edge(G.boxBL + H.repeat(8) + G.boxBR));
|
|
60
137
|
console.log();
|
|
61
138
|
}
|
|
62
139
|
/** Render a single-line progress bar on stderr (so stdout stays clean). */
|
|
@@ -66,7 +143,7 @@ export function renderProgress(done, total, label) {
|
|
|
66
143
|
const width = 26;
|
|
67
144
|
const ratio = total > 0 ? done / total : 1;
|
|
68
145
|
const filled = Math.round(ratio * width);
|
|
69
|
-
const bar = c.cyan(
|
|
146
|
+
const bar = c.cyan(G.fill.repeat(filled)) + c.muted(G.track.repeat(width - filled));
|
|
70
147
|
const pct = c.cyanDim(String(Math.round(ratio * 100)).padStart(3) + "%");
|
|
71
148
|
const shortLabel = label.length > 38 ? "…" + label.slice(-37) : label;
|
|
72
149
|
process.stderr.write(`\r ${bar} ${pct} ${c.muted(shortLabel.padEnd(38))}`);
|
|
@@ -78,11 +155,12 @@ export function clearProgress() {
|
|
|
78
155
|
/** Draw a cyan-bordered block (used for the copy-paste agent prompt). */
|
|
79
156
|
export function box(lines, title) {
|
|
80
157
|
const width = Math.min(72, Math.max(...lines.map((l) => l.length), title?.length ?? 0) + 2);
|
|
158
|
+
const H = G.boxH;
|
|
81
159
|
const top = title
|
|
82
|
-
? "
|
|
83
|
-
:
|
|
84
|
-
console.log(" " + c.muted(top) + c.muted(
|
|
160
|
+
? G.boxTL + H + " " + c.cyanDim(title) + " " + H.repeat(Math.max(0, width - title.length - 3))
|
|
161
|
+
: G.boxTL + H.repeat(width);
|
|
162
|
+
console.log(" " + c.muted(top) + c.muted(G.boxTR));
|
|
85
163
|
for (const l of lines)
|
|
86
|
-
console.log(" " + c.muted("
|
|
87
|
-
console.log(" " + c.muted(
|
|
164
|
+
console.log(" " + c.muted(G.boxV + " ") + c.cream(l.padEnd(width - 2)) + c.muted(" " + G.boxV));
|
|
165
|
+
console.log(" " + c.muted(G.boxBL + H.repeat(width) + G.boxBR));
|
|
88
166
|
}
|
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { pkgName, pkgVersion } from "../../shared/version.js";
|
|
5
|
-
import { c } from "./ui.js";
|
|
5
|
+
import { c, link } from "./ui.js";
|
|
6
6
|
// A lightweight, best-effort update notifier. The registry is queried at most
|
|
7
7
|
// once a day (result cached under ~/.speclaw/), the lookup is time-boxed, and
|
|
8
8
|
// every failure is swallowed — checking for updates must never slow down or
|
|
@@ -95,9 +95,36 @@ export async function checkForUpdates(opts = {}) {
|
|
|
95
95
|
}
|
|
96
96
|
return { current, latest, updateAvailable: !!latest && isNewer(latest, current) };
|
|
97
97
|
}
|
|
98
|
+
/** The public npm page for a package, where an upgrade can be reviewed. */
|
|
99
|
+
export function npmPackageUrl(name) {
|
|
100
|
+
return `https://www.npmjs.com/package/${name}`;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Build the two-line "update available" notice. The latest version is rendered
|
|
104
|
+
* as a clickable link to the package's npm page, so a capable terminal lets the
|
|
105
|
+
* user open the release with a single click while `speclaw update` remains the
|
|
106
|
+
* command that performs the upgrade.
|
|
107
|
+
*
|
|
108
|
+
* @param current - The installed version.
|
|
109
|
+
* @param latest - The newest published version.
|
|
110
|
+
* @returns The formatted, styled notice (no leading/trailing blank lines).
|
|
111
|
+
*/
|
|
112
|
+
export function upgradeNotice(current, latest) {
|
|
113
|
+
const latestLink = c.cyan(link(latest, npmPackageUrl(pkgName())));
|
|
114
|
+
return (" " +
|
|
115
|
+
c.amber("⬆ speclaw ") +
|
|
116
|
+
c.muted(current + " → ") +
|
|
117
|
+
latestLink +
|
|
118
|
+
c.muted(" available") +
|
|
119
|
+
"\n" +
|
|
120
|
+
" " +
|
|
121
|
+
c.muted("run ") +
|
|
122
|
+
c.cyan("speclaw update") +
|
|
123
|
+
c.muted(" — upgrades and applies only what's new"));
|
|
124
|
+
}
|
|
98
125
|
/**
|
|
99
|
-
* Print
|
|
100
|
-
*
|
|
126
|
+
* Print the "update available" notice to stderr when a newer version exists.
|
|
127
|
+
* No-op for the `mcp`/`update`/`help`/`version` commands, on non-TTY stderr, or
|
|
101
128
|
* when NO_UPDATE_NOTIFIER / SPECLAW_NO_UPDATE_NOTIFIER is set. Never throws.
|
|
102
129
|
*
|
|
103
130
|
* @param cmd - The command that just ran (used to skip noisy contexts).
|
|
@@ -110,23 +137,13 @@ export async function maybeNotifyUpdate(cmd) {
|
|
|
110
137
|
return;
|
|
111
138
|
// `init` shows its own prominent up-front warning and ends on the clean
|
|
112
139
|
// copy-paste prompt — don't append a second notice after it.
|
|
113
|
-
if (!cmd ||
|
|
140
|
+
if (!cmd ||
|
|
141
|
+
["mcp", "update", "init", "help", "--help", "-h", "version", "--version", "-v"].includes(cmd))
|
|
114
142
|
return;
|
|
115
143
|
const { current, latest, updateAvailable } = await checkForUpdates();
|
|
116
144
|
if (!updateAvailable || !latest)
|
|
117
145
|
return;
|
|
118
|
-
process.stderr.write("\n" +
|
|
119
|
-
" " +
|
|
120
|
-
c.amber("⬆ speclaw ") +
|
|
121
|
-
c.muted(current + " → ") +
|
|
122
|
-
c.cyan(latest) +
|
|
123
|
-
c.muted(" available") +
|
|
124
|
-
"\n" +
|
|
125
|
-
" " +
|
|
126
|
-
c.muted("run ") +
|
|
127
|
-
c.cyan("speclaw update") +
|
|
128
|
-
c.muted(" — upgrades and applies only what's new") +
|
|
129
|
-
"\n\n");
|
|
146
|
+
process.stderr.write("\n" + upgradeNotice(current, latest) + "\n\n");
|
|
130
147
|
}
|
|
131
148
|
catch {
|
|
132
149
|
/* the notifier is best-effort — never let it break a command */
|
|
@@ -15,17 +15,27 @@ If `lawbook/` is missing, run the `lawbook_init` tool once to create it.
|
|
|
15
15
|
|
|
16
16
|
## Step 1 — Understand the request and the code
|
|
17
17
|
|
|
18
|
+
- **Refresh the index first.** Run `compass_index` before reasoning about the
|
|
19
|
+
code — it is incremental (unchanged files are skipped by hash), so this is
|
|
20
|
+
cheap and guarantees your decisions rest on the current graph, not a stale one.
|
|
18
21
|
- Clarify what the user wants (feature / fix / refactor) and confirm scope.
|
|
19
22
|
- Use `compass_explore` and `compass_recall` (speclaw's code index) BEFORE
|
|
20
23
|
grep/read to locate the real code the change touches and its blast radius.
|
|
21
|
-
If the index is stale or missing, run `compass_index` first.
|
|
22
24
|
- Read the governing standards in `docs/standards/` (architecture, backend,
|
|
23
25
|
frontend, testing) so the change complies with the project's law.
|
|
24
26
|
|
|
25
|
-
## Step 2 — Pick a change name
|
|
27
|
+
## Step 2 — Pick a change name and its capabilities
|
|
26
28
|
|
|
27
|
-
|
|
28
|
-
the folder under `lawbook/changes
|
|
29
|
+
- **Change name:** kebab-case, action-oriented (e.g. `add-login`,
|
|
30
|
+
`fix-shift-overlap`). This is the folder under `lawbook/changes/`, and it is
|
|
31
|
+
per-feature — always distinct.
|
|
32
|
+
- **Capabilities:** run `lawbook_list` to see the canonical capabilities. A
|
|
33
|
+
capability is the living contract for an area of behavior — it is *not* the
|
|
34
|
+
change. When your change modifies behavior an existing capability already
|
|
35
|
+
governs, reuse that capability's **exact** name so `sync` updates its spec.
|
|
36
|
+
Introduce a new capability only as a deliberate choice for a genuinely distinct
|
|
37
|
+
area of behavior — never as a near-duplicate (`transfer` next to an existing
|
|
38
|
+
`transfers`) of one that already exists.
|
|
29
39
|
|
|
30
40
|
## Step 3 — Write the artifacts
|
|
31
41
|
|
|
@@ -34,7 +44,12 @@ Create under `lawbook/changes/<name>/`:
|
|
|
34
44
|
- **proposal.md** — the why, the what, non-goals, and whether migrations are
|
|
35
45
|
needed. Reference the team's tracker ticket if there is one.
|
|
36
46
|
- **specs/<capability>/spec.md** — the delta spec for each affected capability.
|
|
37
|
-
|
|
47
|
+
`sync` promotes this by overwriting the whole canonical file, so the delta must
|
|
48
|
+
carry the capability's **full** intended spec. When you are updating an existing
|
|
49
|
+
capability, **start from the current `lawbook/specs/<capability>/spec.md`** and
|
|
50
|
+
edit on top of it, so its existing requirements are carried forward — do not
|
|
51
|
+
author it from scratch, or promotion will silently drop them. Use normative
|
|
52
|
+
language and testable scenarios:
|
|
38
53
|
```markdown
|
|
39
54
|
# <Capability>
|
|
40
55
|
|
|
@@ -63,7 +78,11 @@ Create under `lawbook/changes/<name>/`:
|
|
|
63
78
|
|
|
64
79
|
Run the `lawbook_validate` tool for the change and fix every issue it reports
|
|
65
80
|
(missing artifacts, non-normative specs, missing scenarios) before handing off
|
|
66
|
-
to implementation.
|
|
81
|
+
to implementation. Read its advisory **warnings** too: a near-duplicate
|
|
82
|
+
capability name usually means you should reuse the existing capability's exact
|
|
83
|
+
name, and a dropped-requirement warning means the delta should start from the
|
|
84
|
+
canonical. Warnings do not block, but resolve them unless the divergence is
|
|
85
|
+
intentional.
|
|
67
86
|
|
|
68
87
|
## Step 5 — Hand off
|
|
69
88
|
|
|
@@ -11,9 +11,12 @@ understanding and a recommended direction.
|
|
|
11
11
|
|
|
12
12
|
## How to explore
|
|
13
13
|
|
|
14
|
+
- **Refresh the index first.** Run `compass_index` before investigating — it is
|
|
15
|
+
incremental (unchanged files skipped by hash), so it is cheap and keeps your
|
|
16
|
+
reasoning on the current graph rather than a stale one.
|
|
14
17
|
- **Understand the code first.** Use `compass_recall` to find relevant code by
|
|
15
18
|
meaning and `compass_explore` to read a symbol's source plus its callers and
|
|
16
|
-
callees — before grep/read.
|
|
19
|
+
callees — before grep/read.
|
|
17
20
|
- **Ask sharp questions** to surface hidden assumptions, constraints, and edge
|
|
18
21
|
cases. Confirm scope and non-goals.
|
|
19
22
|
- **Check the law.** Read the relevant `docs/standards/` so any direction you
|
|
@@ -35,7 +35,11 @@ specs that become canonical describe reality, not just the original draft.
|
|
|
35
35
|
|
|
36
36
|
4. Run the `lawbook_sync` tool for the change. It copies each
|
|
37
37
|
`lawbook/changes/<name>/specs/<capability>/spec.md` over the canonical
|
|
38
|
-
`lawbook/specs/<capability>/spec.md` and reports what it promoted
|
|
38
|
+
`lawbook/specs/<capability>/spec.md` and reports what it promoted, flagging
|
|
39
|
+
each as **created** (new capability) or **updated** (overwrote an existing
|
|
40
|
+
one). A capability you expected to update showing up as *created* means the
|
|
41
|
+
delta forked a near-duplicate — fix the name before promoting.
|
|
39
42
|
|
|
40
43
|
5. Report to the user what you reconciled (or that nothing drifted) and the
|
|
41
|
-
promoted files. The change stays active — `archive` it
|
|
44
|
+
promoted files (created vs updated). The change stays active — `archive` it
|
|
45
|
+
when it's fully done.
|
|
@@ -84,6 +84,47 @@ export function specInit(projectPath) {
|
|
|
84
84
|
ensure("README.md", README_MD);
|
|
85
85
|
return { created, alreadyExisted };
|
|
86
86
|
}
|
|
87
|
+
/** Names of the canonical capabilities (directories under lawbook/specs/). */
|
|
88
|
+
function canonicalCapabilities(root) {
|
|
89
|
+
const specsDir = path.join(root, "specs");
|
|
90
|
+
if (!fs.existsSync(specsDir))
|
|
91
|
+
return [];
|
|
92
|
+
return fs
|
|
93
|
+
.readdirSync(specsDir, { withFileTypes: true })
|
|
94
|
+
.filter((e) => e.isDirectory())
|
|
95
|
+
.map((e) => e.name);
|
|
96
|
+
}
|
|
97
|
+
/** The "### Requirement:" titles declared in a spec markdown document. */
|
|
98
|
+
function requirementHeaders(markdown) {
|
|
99
|
+
const out = [];
|
|
100
|
+
for (const m of markdown.matchAll(/^###\s+Requirement:\s*(.+?)\s*$/gm))
|
|
101
|
+
out.push(m[1]);
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
/** Levenshtein edit distance between two strings (small, dependency-free). */
|
|
105
|
+
function editDistance(a, b) {
|
|
106
|
+
const rows = a.length + 1;
|
|
107
|
+
const cols = b.length + 1;
|
|
108
|
+
let prev = Array.from({ length: cols }, (_, j) => j);
|
|
109
|
+
for (let i = 1; i < rows; i++) {
|
|
110
|
+
const curr = [i];
|
|
111
|
+
for (let j = 1; j < cols; j++) {
|
|
112
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
113
|
+
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
|
|
114
|
+
}
|
|
115
|
+
prev = curr;
|
|
116
|
+
}
|
|
117
|
+
return prev[cols - 1];
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The existing canonical capability that a name is a near-match of (edit
|
|
121
|
+
* distance ≤ 2 and not equal), or undefined when the name is exact or unrelated.
|
|
122
|
+
*/
|
|
123
|
+
function nearMatchCapability(name, capabilities) {
|
|
124
|
+
if (capabilities.includes(name))
|
|
125
|
+
return undefined;
|
|
126
|
+
return capabilities.find((c) => editDistance(name, c) <= 2);
|
|
127
|
+
}
|
|
87
128
|
/** Recursively collect every .md file under a change's specs/ directory. */
|
|
88
129
|
function deltaSpecFiles(changeDir) {
|
|
89
130
|
const specsDir = path.join(changeDir, "specs");
|
|
@@ -120,6 +161,7 @@ export function specValidate(projectPath, change) {
|
|
|
120
161
|
change,
|
|
121
162
|
valid: false,
|
|
122
163
|
issues: [`change "${change}" not found under lawbook/changes/`],
|
|
164
|
+
warnings: [],
|
|
123
165
|
deltaSpecs: [],
|
|
124
166
|
};
|
|
125
167
|
}
|
|
@@ -131,6 +173,10 @@ export function specValidate(projectPath, change) {
|
|
|
131
173
|
const deltas = deltaSpecFiles(changeDir);
|
|
132
174
|
if (deltas.length === 0)
|
|
133
175
|
issues.push("no delta specs under specs/ (a change should specify what it changes)");
|
|
176
|
+
const root = specRoot(projectPath);
|
|
177
|
+
const changeSpecs = path.join(changeDir, "specs");
|
|
178
|
+
const capabilities = canonicalCapabilities(root);
|
|
179
|
+
const warnings = [];
|
|
134
180
|
for (const file of deltas) {
|
|
135
181
|
const rel = path.relative(changeDir, file);
|
|
136
182
|
const content = fs.readFileSync(file, "utf8");
|
|
@@ -143,21 +189,46 @@ export function specValidate(projectPath, change) {
|
|
|
143
189
|
if (!/^###\s+Requirement:/m.test(content)) {
|
|
144
190
|
issues.push(`${rel}: no "### Requirement:" header`);
|
|
145
191
|
}
|
|
192
|
+
// Advisory divergence checks against the canonical specs.
|
|
193
|
+
const relFromSpecs = path.relative(changeSpecs, file);
|
|
194
|
+
const capability = relFromSpecs.split(path.sep)[0];
|
|
195
|
+
const nearMatch = nearMatchCapability(capability, capabilities);
|
|
196
|
+
if (nearMatch) {
|
|
197
|
+
warnings.push(`${rel}: capability "${capability}" is not canonical but resembles ` +
|
|
198
|
+
`"${nearMatch}" — did you mean to update it? Reuse the exact name to ` +
|
|
199
|
+
`update the existing spec instead of forking a near-duplicate.`);
|
|
200
|
+
}
|
|
201
|
+
else if (capabilities.includes(capability)) {
|
|
202
|
+
const canonicalFile = path.join(root, "specs", relFromSpecs);
|
|
203
|
+
if (fs.existsSync(canonicalFile)) {
|
|
204
|
+
const deltaReqs = new Set(requirementHeaders(content));
|
|
205
|
+
const dropped = requirementHeaders(fs.readFileSync(canonicalFile, "utf8")).filter((r) => !deltaReqs.has(r));
|
|
206
|
+
if (dropped.length > 0) {
|
|
207
|
+
warnings.push(`${rel}: delta drops ${dropped.length} requirement(s) present in the ` +
|
|
208
|
+
`canonical "${capability}" spec (${dropped.join("; ")}) — start the ` +
|
|
209
|
+
`delta from the canonical unless the removal is intentional.`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
146
213
|
}
|
|
147
214
|
return {
|
|
148
215
|
change,
|
|
149
216
|
valid: issues.length === 0,
|
|
150
217
|
issues,
|
|
218
|
+
warnings,
|
|
151
219
|
deltaSpecs: deltas.map((f) => path.relative(projectPath, f)),
|
|
152
220
|
};
|
|
153
221
|
}
|
|
154
222
|
/**
|
|
155
223
|
* Promote a change's delta specs into the canonical specs/, overwriting the
|
|
156
|
-
* file for each affected capability.
|
|
224
|
+
* file for each affected capability. Each promoted path is also classified as
|
|
225
|
+
* `created` (no canonical file existed) or `updated` (one was overwritten) so an
|
|
226
|
+
* unintended new capability is visible in the result — a pure path check that
|
|
227
|
+
* keeps this a deterministic, code-blind copy.
|
|
157
228
|
*
|
|
158
229
|
* @param projectPath - Absolute path to the project root.
|
|
159
230
|
* @param change - Change name (folder under lawbook/changes/).
|
|
160
|
-
* @returns The change name
|
|
231
|
+
* @returns The change name, the promoted spec paths, and the created/updated split.
|
|
161
232
|
* @throws If the change directory does not exist.
|
|
162
233
|
*/
|
|
163
234
|
export function specSync(projectPath, change) {
|
|
@@ -167,8 +238,10 @@ export function specSync(projectPath, change) {
|
|
|
167
238
|
throw new Error(`change "${change}" not found`);
|
|
168
239
|
const changeSpecs = path.join(changeDir, "specs");
|
|
169
240
|
const promoted = [];
|
|
241
|
+
const created = [];
|
|
242
|
+
const updated = [];
|
|
170
243
|
if (!fs.existsSync(changeSpecs))
|
|
171
|
-
return { change, promoted };
|
|
244
|
+
return { change, promoted, created, updated };
|
|
172
245
|
const walk = (dir) => {
|
|
173
246
|
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
174
247
|
const full = path.join(dir, e.name);
|
|
@@ -177,14 +250,17 @@ export function specSync(projectPath, change) {
|
|
|
177
250
|
else if (e.name.endsWith(".md")) {
|
|
178
251
|
const rel = path.relative(changeSpecs, full);
|
|
179
252
|
const dest = path.join(root, "specs", rel);
|
|
253
|
+
const existed = fs.existsSync(dest);
|
|
180
254
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
181
255
|
fs.copyFileSync(full, dest);
|
|
182
|
-
|
|
256
|
+
const promotedPath = path.join("lawbook/specs", rel);
|
|
257
|
+
promoted.push(promotedPath);
|
|
258
|
+
(existed ? updated : created).push(promotedPath);
|
|
183
259
|
}
|
|
184
260
|
}
|
|
185
261
|
};
|
|
186
262
|
walk(changeSpecs);
|
|
187
|
-
return { change, promoted };
|
|
263
|
+
return { change, promoted, created, updated };
|
|
188
264
|
}
|
|
189
265
|
/**
|
|
190
266
|
* Deterministic completeness checks that gate archiving a change. Returns the
|
|
@@ -256,13 +332,13 @@ export function specArchive(projectPath, change, date) {
|
|
|
256
332
|
if (blockers.length > 0) {
|
|
257
333
|
throw new Error(`cannot archive "${change}" — resolve first:\n${blockers.map((b) => ` - ${b}`).join("\n")}`);
|
|
258
334
|
}
|
|
259
|
-
const { promoted } = specSync(projectPath, change);
|
|
335
|
+
const { promoted, created, updated } = specSync(projectPath, change);
|
|
260
336
|
const archiveDir = path.join(root, "changes", "archive", `${date}-${change}`);
|
|
261
337
|
fs.mkdirSync(path.dirname(archiveDir), { recursive: true });
|
|
262
338
|
if (fs.existsSync(archiveDir))
|
|
263
339
|
throw new Error(`archive target already exists: ${archiveDir}`);
|
|
264
340
|
fs.renameSync(changeDir, archiveDir);
|
|
265
|
-
return { change, promoted, archivedTo: path.relative(projectPath, archiveDir) };
|
|
341
|
+
return { change, promoted, created, updated, archivedTo: path.relative(projectPath, archiveDir) };
|
|
266
342
|
}
|
|
267
343
|
/**
|
|
268
344
|
* List the spec workspace: active changes, archived changes, and canonical
|
|
@@ -29,14 +29,14 @@ export function registerSpec(server) {
|
|
|
29
29
|
inputSchema: { projectPath: z.string().describe("Absolute path to the project") },
|
|
30
30
|
}, async ({ projectPath }) => text(specList(projectPath)));
|
|
31
31
|
server.registerTool("lawbook_validate", {
|
|
32
|
-
description: "Validate a change's artifacts: proposal.md and tasks.md present, and delta specs use normative language (SHALL/MUST), '### Requirement:' headers, and '#### Scenario:' acceptance criteria. Returns the issues to fix. Used by the draft/build commands before proceeding.",
|
|
32
|
+
description: "Validate a change's artifacts: proposal.md and tasks.md present, and delta specs use normative language (SHALL/MUST), '### Requirement:' headers, and '#### Scenario:' acceptance criteria. Returns the blocking issues to fix plus advisory (non-blocking) warnings — a capability name that resembles an existing canonical one, or requirements dropped versus the canonical. Used by the draft/build commands before proceeding.",
|
|
33
33
|
inputSchema: {
|
|
34
34
|
projectPath: z.string().describe("Absolute path to the project"),
|
|
35
35
|
change: z.string().describe("Change name (folder under lawbook/changes/)"),
|
|
36
36
|
},
|
|
37
37
|
}, async ({ projectPath, change }) => text(specValidate(projectPath, change)));
|
|
38
38
|
server.registerTool("lawbook_sync", {
|
|
39
|
-
description: "Promote a change's delta specs into the canonical lawbook/specs/ (per capability), without archiving. Backs the `sync` command.",
|
|
39
|
+
description: "Promote a change's delta specs into the canonical lawbook/specs/ (per capability), without archiving. Reports each promoted spec as created (new capability) or updated (overwrote an existing one). Backs the `sync` command.",
|
|
40
40
|
inputSchema: {
|
|
41
41
|
projectPath: z.string().describe("Absolute path to the project"),
|
|
42
42
|
change: z.string().describe("Change name (folder under lawbook/changes/)"),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@esneiderbravo/speclaw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -41,6 +41,8 @@
|
|
|
41
41
|
"lint": "eslint .",
|
|
42
42
|
"format": "prettier --write .",
|
|
43
43
|
"check": "prettier --check . && eslint .",
|
|
44
|
+
"pretest": "tsc -p tsconfig.test.json && node scripts/prep-test-assets.mjs",
|
|
45
|
+
"test": "node --test --experimental-test-coverage --test-coverage-lines=80 --test-coverage-functions=80 --test-coverage-branches=80 --test-coverage-exclude='dist-test/test/**' --test-coverage-exclude='dist/**' 'dist-test/test/**/*.test.js'",
|
|
44
46
|
"prepublishOnly": "npm run build"
|
|
45
47
|
},
|
|
46
48
|
"engines": {
|