@esneiderbravo/speclaw 2.0.2 → 2.0.4

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.
Files changed (51) hide show
  1. package/dist/cli/commands/check.js +11 -1
  2. package/dist/cli/commands/cortex.js +17 -0
  3. package/dist/cli/commands/index-build.js +13 -2
  4. package/dist/cli/commands/lawbook.js +31 -10
  5. package/dist/cli/commands/query.js +13 -0
  6. package/dist/cli/index.js +4 -1
  7. package/dist/modules/compass/indexer.js +14 -0
  8. package/dist/modules/compass/query.js +209 -62
  9. package/dist/modules/compass/register.js +17 -1
  10. package/dist/modules/cortex/compass-gate.js +84 -0
  11. package/dist/modules/cortex/harness.js +20 -11
  12. package/dist/modules/cortex/types.js +14 -0
  13. package/dist/modules/foundation/assets/AGENTS.template.md +2 -2
  14. package/dist/modules/foundation/assets/CLAUDE.template.md +4 -3
  15. package/dist/modules/foundation/assets/docs/compass.template.md +7 -7
  16. package/dist/modules/foundation/assets/docs/standards/lawbook.template.md +2 -2
  17. package/dist/modules/foundation/check.js +23 -5
  18. package/dist/modules/foundation/compass-nudge.js +189 -0
  19. package/dist/modules/foundation/hooks.js +18 -3
  20. package/dist/modules/lawbook/assets/agents/archiver.md +1 -1
  21. package/dist/modules/lawbook/assets/agents/explorer.md +7 -4
  22. package/dist/modules/lawbook/assets/agents/implementer.md +1 -1
  23. package/dist/modules/lawbook/assets/agents/planner.md +7 -4
  24. package/dist/modules/lawbook/assets/agents/reviewer.md +1 -1
  25. package/dist/modules/lawbook/assets/agents/tester.md +1 -1
  26. package/dist/modules/lawbook/assets/commands/draft.md +2 -2
  27. package/dist/modules/lawbook/assets/commands/quick.md +2 -2
  28. package/dist/modules/lawbook/assets/rules/spec-reports-disciplines.md +1 -1
  29. package/dist/modules/lawbook/assets/rules/spec-tasks-mandatory-steps.md +4 -4
  30. package/dist/modules/lawbook/assets/skills/archive/steps/02-reconcile.md +1 -1
  31. package/dist/modules/lawbook/assets/skills/build/steps/01-load-change.md +1 -1
  32. package/dist/modules/lawbook/assets/skills/cortex/steps/02-dispatch-loop.md +22 -6
  33. package/dist/modules/lawbook/assets/skills/draft/steps/01-ensure-workspace.md +1 -1
  34. package/dist/modules/lawbook/assets/skills/draft/steps/02-understand.md +7 -4
  35. package/dist/modules/lawbook/assets/skills/draft/steps/03-name-capabilities.md +3 -1
  36. package/dist/modules/lawbook/assets/skills/draft/steps/04-write-artifacts.md +12 -2
  37. package/dist/modules/lawbook/assets/skills/explore/steps/01-investigate.md +15 -6
  38. package/dist/modules/lawbook/assets/skills/explore/steps/02-summarize.md +2 -0
  39. package/dist/modules/lawbook/assets/skills/investigate/steps/02-hand-off.md +1 -1
  40. package/dist/modules/lawbook/assets/skills/quick/steps/02-implement.md +1 -1
  41. package/dist/modules/lawbook/assets/skills/sync/steps/01-confirm.md +1 -1
  42. package/dist/modules/lawbook/assets/skills/sync/steps/02-reconcile.md +1 -1
  43. package/dist/modules/lawbook/bugfix.js +22 -26
  44. package/dist/modules/lawbook/change-tool.js +15 -0
  45. package/dist/modules/lawbook/engine.js +11 -0
  46. package/dist/modules/lawbook/levels.js +2 -0
  47. package/dist/modules/lawbook/quick.js +14 -23
  48. package/dist/modules/lawbook/register.js +1 -1
  49. package/dist/modules/lawbook/scaffold-change.js +238 -0
  50. package/dist/shared/compass-calls.js +197 -0
  51. package/package.json +1 -1
@@ -0,0 +1,238 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { gatherSignals, loadCeremonyConfig, proposeLevel, setCeremonyLevel, writeCeremonyRecord, } from "./levels.js";
4
+ // Covers: req~feature-draft~1
5
+ /** Change folder names are kebab-case: lowercase alphanumerics joined by single hyphens. */
6
+ export const CHANGE_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
7
+ /**
8
+ * Generic change scaffold shared by `quick`, bug drafts, and feature drafts.
9
+ *
10
+ * Refuses an existing change directory, proposes a
11
+ * ceremony level from `targets`, writes the artifacts the caller asks for plus
12
+ * `reports/README.md`, then records `change.json`. Every refusal happens
13
+ * before the first write, so a rejected scaffold leaves the tree untouched.
14
+ *
15
+ * @param projectPath - Project root with `lawbook/`.
16
+ * @param name - Change folder name (kebab-case).
17
+ * @param opts - Level, change type, and artifact templates.
18
+ * @throws When the change exists or the level is below the proposal without a
19
+ * reason. Name rules belong to the callers (feature drafts enforce kebab-case).
20
+ */
21
+ export function scaffoldChange(projectPath, name, opts) {
22
+ const changeDir = path.join(projectPath, "lawbook", "changes", name);
23
+ if (fs.existsSync(changeDir)) {
24
+ throw new Error(`change "${name}" already exists under lawbook/changes/`);
25
+ }
26
+ const targets = opts.targets ?? { paths: [], symbols: [] };
27
+ const { thresholds } = loadCeremonyConfig(projectPath);
28
+ const signals = gatherSignals(projectPath, targets, thresholds);
29
+ const proposal = proposeLevel(signals, thresholds);
30
+ const level = typeof opts.level === "function" ? opts.level(proposal) : opts.level;
31
+ const stored = opts.recordProposal ? opts.recordProposal(proposal) : proposal;
32
+ const reason = typeof opts.reason === "function" ? opts.reason(proposal) : opts.reason;
33
+ // setCeremonyLevel enforces this too, but only after files exist; check first.
34
+ if (level !== undefined && proposal.level !== null && level < proposal.level && !reason) {
35
+ throw new Error(`level ${level} is below the proposed level (${proposal.level}); pass a reason to confirm it`);
36
+ }
37
+ const files = opts.artifacts({ name, proposal, level });
38
+ fs.mkdirSync(path.join(changeDir, "reports"), { recursive: true });
39
+ for (const [rel, content] of Object.entries(files)) {
40
+ const abs = path.join(changeDir, rel);
41
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
42
+ fs.writeFileSync(abs, content);
43
+ }
44
+ fs.writeFileSync(path.join(changeDir, "reports", "README.md"), opts.reportsReadme);
45
+ let record;
46
+ if (level === undefined) {
47
+ record = opts.changeType ? { ...stored, changeType: opts.changeType } : { ...stored };
48
+ fs.writeFileSync(path.join(changeDir, "change.json"), JSON.stringify(record, null, 2) + "\n");
49
+ }
50
+ else {
51
+ let confirmed = setCeremonyLevel(projectPath, name, {
52
+ proposal: stored,
53
+ level,
54
+ confirmedBy: "human",
55
+ reason,
56
+ });
57
+ if (opts.changeType) {
58
+ confirmed = { ...confirmed, changeType: opts.changeType };
59
+ writeCeremonyRecord(projectPath, name, confirmed);
60
+ }
61
+ record = confirmed;
62
+ }
63
+ return {
64
+ change: name,
65
+ proposal,
66
+ record,
67
+ dir: changeDir,
68
+ level: level ?? null,
69
+ changeType: opts.changeType ?? "feature",
70
+ files: [...Object.keys(files), "reports/README.md", "change.json"],
71
+ };
72
+ }
73
+ const featureRecordMd = (name, level, proposal) => `# ${name}
74
+
75
+ **Level:** ${level} (proposed: ${proposal.level ?? "n/a"}, confirmed by: human)
76
+ **Why:** ${proposal.rationale}
77
+
78
+ ## What changes
79
+
80
+ <!-- 2–5 lines: what and why. -->
81
+
82
+ ## Steps
83
+
84
+ - [ ] Make the change
85
+ - [ ] Add or update tests
86
+ - [ ] Record evidence under reports/
87
+
88
+ ## Evidence
89
+
90
+ - \`reports/\` — add a discipline report before archive
91
+ `;
92
+ const featureTasksMd = (name) => `# Tasks — ${name}
93
+
94
+ ## 0. Branch
95
+
96
+ - [ ] 0.1 Create or check out the feature branch
97
+
98
+ ## 1. Implementation
99
+
100
+ - [ ] 1.1 Implement the change
101
+
102
+ ## 2. Verification
103
+
104
+ - [ ] 2.1 Review and update the affected tests
105
+ - [ ] 2.2 Run the quality gates and verify they pass
106
+ - [ ] 2.3 Write the discipline reports under reports/
107
+ - [ ] 2.4 Archive the change within the same PR
108
+ `;
109
+ const featureProposalMd = (name) => `# ${name}
110
+
111
+ ## Why
112
+
113
+ <!-- The problem and who it affects. -->
114
+
115
+ ## What changes
116
+
117
+ <!-- The behavior after this change. -->
118
+
119
+ ## Impact
120
+
121
+ <!-- Capabilities, modules, and APIs touched. -->
122
+ `;
123
+ const featureDesignMd = (name) => `# Design — ${name}
124
+
125
+ ## Approach
126
+
127
+ <!-- Module boundaries, data flow, and decisions. -->
128
+ `;
129
+ /**
130
+ * Marks an unedited delta stub. Validate warns while it is present and sync
131
+ * refuses to promote it, so a placeholder capability is never synced silently.
132
+ */
133
+ export const PLACEHOLDER_DELTA_MARKER = "<!-- speclaw:placeholder-delta -->";
134
+ const PLACEHOLDER_LINE_RE = /^<!-- speclaw:placeholder-delta -->[ \t]*$/m;
135
+ /**
136
+ * True when a delta still carries the placeholder marker on a line of its own.
137
+ * Prose that quotes the marker inline (e.g. in backticks) does not count, so a
138
+ * spec documenting the marker is never mistaken for a placeholder.
139
+ *
140
+ * @param content - Delta spec text.
141
+ */
142
+ export function isPlaceholderDelta(content) {
143
+ return PLACEHOLDER_LINE_RE.test(content);
144
+ }
145
+ /** Starting delta for a capability with no canonical spec yet (a placeholder). */
146
+ const featureDeltaMd = (capability, change) => `# ${capability}
147
+
148
+ ${PLACEHOLDER_DELTA_MARKER}
149
+ <!-- Placeholder delta for change ${change}. Replace it with the capability's
150
+ full intended spec (sync overwrites the canonical spec with this file) and
151
+ delete the marker above, or delete this file if the change targets another
152
+ capability (pass --capability <name> when drafting). -->
153
+
154
+ ### Requirement: ${capability} behavior
155
+
156
+ The system SHALL provide the behavior described in \`proposal.md\` for ${change}.
157
+
158
+ #### Scenario: The change's behavior is observable
159
+ - Given the change ${change} is implemented
160
+ - When the behavior is exercised
161
+ - Then the outcome described in \`proposal.md\` is observed
162
+ `;
163
+ /**
164
+ * Stub artifacts for a feature change at `level` (none when unconfirmed).
165
+ *
166
+ * Every level gets what `artifactNeeds` requires, so a fresh draft validates:
167
+ * level 2 includes a `design.md` stub (rather than a record justification),
168
+ * and levels 1–3 include a delta under `specs/<capability>/spec.md`.
169
+ *
170
+ * @param name - Change name.
171
+ * @param level - Confirmed level, or `undefined` for no stubs.
172
+ * @param proposal - Proposal quoted in `record.md`.
173
+ * @param delta - Capability and starting content for the delta (levels 1–3).
174
+ */
175
+ export function featureStubs(name, level, proposal, delta = {
176
+ capability: name,
177
+ content: featureDeltaMd(name, name),
178
+ }) {
179
+ const spec = { [`specs/${delta.capability}/spec.md`]: delta.content };
180
+ switch (level) {
181
+ case 0:
182
+ return { "record.md": featureRecordMd(name, 0, proposal) };
183
+ case 1:
184
+ return {
185
+ "record.md": featureRecordMd(name, 1, proposal),
186
+ "tasks.md": featureTasksMd(name),
187
+ ...spec,
188
+ };
189
+ case 2:
190
+ case 3:
191
+ return {
192
+ "proposal.md": featureProposalMd(name),
193
+ "design.md": featureDesignMd(name),
194
+ "tasks.md": featureTasksMd(name),
195
+ ...spec,
196
+ };
197
+ default:
198
+ return {};
199
+ }
200
+ }
201
+ /**
202
+ * Scaffold a feature change (`speclaw lawbook draft <name>` / `lawbook_change` `draft`).
203
+ *
204
+ * With `level`, the level is confirmed and its stubs are written; without it,
205
+ * `change.json` carries only the proposal and `changeType`, so validate applies
206
+ * level-3 rules until `lawbook level set` confirms one. The delta stub targets
207
+ * `capability` (default: the change name); when that capability already has a
208
+ * canonical spec, the delta starts from a copy of it, so an unedited sync
209
+ * changes nothing.
210
+ *
211
+ * @param projectPath - Project root with `lawbook/`.
212
+ * @param name - Change folder name (kebab-case).
213
+ * @param opts - Optional level, override reason, delta capability, and targets.
214
+ * @throws When `capability` is not kebab-case (plus every `scaffoldChange` refusal).
215
+ */
216
+ export function scaffoldFeature(projectPath, name, opts = {}) {
217
+ // Only feature drafts enforce kebab-case: quick and bug drafts keep their
218
+ // pre-existing (unvalidated) naming so their outputs stay unchanged.
219
+ if (!CHANGE_NAME_RE.test(name)) {
220
+ throw new Error(`change name "${name}" must be kebab-case (e.g. add-widget)`);
221
+ }
222
+ const capability = opts.capability ?? name;
223
+ if (!CHANGE_NAME_RE.test(capability)) {
224
+ throw new Error(`capability "${capability}" must be kebab-case (e.g. widgets)`);
225
+ }
226
+ const canonical = path.join(projectPath, "lawbook", "specs", capability, "spec.md");
227
+ const content = fs.existsSync(canonical)
228
+ ? fs.readFileSync(canonical, "utf8")
229
+ : featureDeltaMd(capability, name);
230
+ return scaffoldChange(projectPath, name, {
231
+ changeType: "feature",
232
+ targets: opts.targets,
233
+ level: opts.level,
234
+ reason: opts.reason,
235
+ reportsReadme: `# Reports — ${name}\n\nAdd at least one discipline report before archive.\n`,
236
+ artifacts: ({ level, proposal }) => featureStubs(name, level, proposal, { capability, content }),
237
+ });
238
+ }
@@ -0,0 +1,197 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ // Covers: req~compass-call-log~1
4
+ /** Live call-log file name under `.speclaw/`. */
5
+ export const COMPASS_CALL_LOG = "compass-calls.jsonl";
6
+ /** The live log rotates to `.1` once it grows past this many bytes. */
7
+ export const CALL_LOG_ROTATE_BYTES = 256 * 1024;
8
+ /** Readers never look further back than this many bytes of the live log. */
9
+ export const CALL_LOG_READ_BYTES = 64 * 1024;
10
+ /**
11
+ * Compass tools whose calls count as "looked through the graph" evidence.
12
+ * `compass_index` and nudge entries are logged too but are not evidence.
13
+ */
14
+ export const EVIDENCE_TOOLS = new Set([
15
+ "compass_explore",
16
+ "compass_find",
17
+ "compass_diff_context",
18
+ "compass_impact",
19
+ "compass_trace",
20
+ "compass_search",
21
+ "compass_recall",
22
+ ]);
23
+ /** Tool name the Compass-first nudge records for its rate limit. */
24
+ export const NUDGE_ENTRY = "nudge";
25
+ /** True when `tool` counts as Compass evidence. */
26
+ export function isEvidenceTool(tool) {
27
+ return EVIDENCE_TOOLS.has(tool);
28
+ }
29
+ function logPath(projectPath) {
30
+ return path.join(projectPath, ".speclaw", COMPASS_CALL_LOG);
31
+ }
32
+ /**
33
+ * Rotate the live log to `.1` when it is over the cap, tolerating a race with
34
+ * other processes doing the same.
35
+ *
36
+ * The live file is first renamed to a name unique to this process, so two
37
+ * rotators can never both move the same file onto `.1`. If what was claimed is
38
+ * not over the cap, another process rotated first and this one grabbed its
39
+ * fresh live file: those lines are appended back to the live log instead of
40
+ * replacing `.1`, so `.1` always holds a full previous generation and no
41
+ * recent entry disappears. A missing live file (lost race) is ignored.
42
+ *
43
+ * @param file - Absolute path of the live log.
44
+ */
45
+ export function rotateCallLog(file) {
46
+ const claimed = `${file}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
47
+ try {
48
+ fs.renameSync(file, claimed);
49
+ }
50
+ catch {
51
+ return; // nothing to rotate, or another process already took it
52
+ }
53
+ try {
54
+ if (fs.statSync(claimed).size > CALL_LOG_ROTATE_BYTES) {
55
+ fs.renameSync(claimed, `${file}.1`);
56
+ return;
57
+ }
58
+ fs.appendFileSync(file, fs.readFileSync(claimed));
59
+ fs.rmSync(claimed, { force: true });
60
+ }
61
+ catch {
62
+ /* best-effort: a leftover .tmp is harmless and never read */
63
+ }
64
+ }
65
+ /**
66
+ * Append one `{at, tool}` line to `.speclaw/compass-calls.jsonl`.
67
+ *
68
+ * Best effort: creates `.speclaw/` when missing, rotates the live log to
69
+ * `.jsonl.1` (replacing any earlier generation) when it exceeds 256 KiB — see
70
+ * {@link rotateCallLog} — and never throws: a failed write must not fail or
71
+ * delay the Compass tool.
72
+ *
73
+ * @param projectPath - Project root.
74
+ * @param tool - MCP tool name the call maps to.
75
+ * @param at - Call time; defaults to now.
76
+ */
77
+ export function recordCompassCall(projectPath, tool, at = new Date()) {
78
+ try {
79
+ const file = logPath(projectPath);
80
+ fs.mkdirSync(path.dirname(file), { recursive: true });
81
+ let size = 0;
82
+ try {
83
+ size = fs.statSync(file).size;
84
+ }
85
+ catch {
86
+ /* no live log yet */
87
+ }
88
+ if (size > CALL_LOG_ROTATE_BYTES)
89
+ rotateCallLog(file);
90
+ fs.appendFileSync(file, JSON.stringify({ at: at.toISOString(), tool }) + "\n", "utf8");
91
+ }
92
+ catch {
93
+ /* best-effort */
94
+ }
95
+ }
96
+ /** Read the last `maxBytes` of `file`; `fromStart` when the whole file was read. */
97
+ function readTail(file, maxBytes) {
98
+ let fd;
99
+ try {
100
+ fd = fs.openSync(file, "r");
101
+ const size = fs.fstatSync(fd).size;
102
+ // Read one byte before the window so a cut that lands exactly on a line
103
+ // boundary keeps that whole first line.
104
+ const start = Math.max(0, size - maxBytes - 1);
105
+ const len = size - start;
106
+ const buf = Buffer.alloc(len);
107
+ fs.readSync(fd, buf, 0, len, start);
108
+ let raw = buf.toString("utf8");
109
+ // Mid-file: everything up to the first newline is a partial (or the extra
110
+ // boundary byte) and is dropped.
111
+ if (start > 0) {
112
+ const nl = raw.indexOf("\n");
113
+ raw = nl === -1 ? "" : raw.slice(nl + 1);
114
+ }
115
+ return { raw, fromStart: start === 0 };
116
+ }
117
+ catch {
118
+ return null;
119
+ }
120
+ finally {
121
+ if (fd !== undefined) {
122
+ try {
123
+ fs.closeSync(fd);
124
+ }
125
+ catch {
126
+ /* ignore */
127
+ }
128
+ }
129
+ }
130
+ }
131
+ /** Parse log lines, skipping malformed ones. */
132
+ function parseLines(raw) {
133
+ const out = [];
134
+ for (const line of raw.split("\n")) {
135
+ if (!line.trim())
136
+ continue;
137
+ let row;
138
+ try {
139
+ row = JSON.parse(line);
140
+ }
141
+ catch {
142
+ continue;
143
+ }
144
+ if (!row || typeof row !== "object")
145
+ continue;
146
+ const { at, tool } = row;
147
+ if (typeof at !== "string" || typeof tool !== "string")
148
+ continue;
149
+ const ms = Date.parse(at);
150
+ if (Number.isNaN(ms))
151
+ continue;
152
+ out.push({ at, tool, ms });
153
+ }
154
+ return out;
155
+ }
156
+ /**
157
+ * Read the bounded tail of the call log.
158
+ *
159
+ * Reads at most `maxBytes` from the end of the live file, drops a partial
160
+ * first line, and skips lines that fail to parse. When the whole live file fit
161
+ * in that window but does not reach back to `sinceMs` (or no `sinceMs` was
162
+ * given), the tail of the previous generation `.1` is read too — at most
163
+ * another `maxBytes` — so a rotation never hides evidence from the current
164
+ * window. Returns `[]` when no log exists.
165
+ *
166
+ * @param projectPath - Project root.
167
+ * @param opts - Optional `sinceMs` filter and per-generation tail size.
168
+ */
169
+ export function readCompassCalls(projectPath, opts = {}) {
170
+ const maxBytes = Math.max(1, opts.maxBytes ?? CALL_LOG_READ_BYTES);
171
+ const file = logPath(projectPath);
172
+ const live = readTail(file, maxBytes);
173
+ const liveRows = live ? parseLines(live.raw) : [];
174
+ let rows = liveRows;
175
+ const oldest = liveRows.length ? Math.min(...liveRows.map((r) => r.ms)) : Infinity;
176
+ const reachesSince = opts.sinceMs !== undefined && oldest <= opts.sinceMs;
177
+ if ((!live || live.fromStart) && !reachesSince) {
178
+ const prev = readTail(`${file}.1`, maxBytes);
179
+ if (prev)
180
+ rows = [...parseLines(prev.raw), ...liveRows];
181
+ }
182
+ const out = [];
183
+ for (const { at, tool, ms } of rows) {
184
+ if (opts.sinceMs !== undefined && ms < opts.sinceMs)
185
+ continue;
186
+ out.push({ at, tool });
187
+ }
188
+ return out;
189
+ }
190
+ /** Count evidence calls in `calls`. */
191
+ export function countEvidence(calls) {
192
+ let n = 0;
193
+ for (const c of calls)
194
+ if (isEvidenceTool(c.tool))
195
+ n++;
196
+ return n;
197
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esneiderbravo/speclaw",
3
- "version": "2.0.2",
3
+ "version": "2.0.4",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },