@esneiderbravo/speclaw 0.3.10 → 0.3.12

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 (30) hide show
  1. package/dist/cli/commands/lawbook.js +43 -6
  2. package/dist/cli/commands/query.js +49 -2
  3. package/dist/cli/commands/quick.js +35 -0
  4. package/dist/cli/commands/update.js +18 -0
  5. package/dist/cli/index.js +17 -3
  6. package/dist/modules/compass/db.js +15 -2
  7. package/dist/modules/compass/extract.js +44 -0
  8. package/dist/modules/compass/git-history-cache.js +19 -2
  9. package/dist/modules/compass/hotspots.js +230 -0
  10. package/dist/modules/compass/indexer.js +2 -0
  11. package/dist/modules/compass/languages.js +39 -0
  12. package/dist/modules/compass/register.js +17 -0
  13. package/dist/modules/foundation/doctor.js +63 -0
  14. package/dist/modules/lawbook/assets/commands/archive.md +5 -6
  15. package/dist/modules/lawbook/assets/commands/draft.md +6 -7
  16. package/dist/modules/lawbook/assets/commands/quick.md +14 -0
  17. package/dist/modules/lawbook/assets/skills/archive/steps/03-validate-and-sync.md +4 -3
  18. package/dist/modules/lawbook/assets/skills/draft/SKILL.md +1 -1
  19. package/dist/modules/lawbook/assets/skills/draft/steps/02-understand.md +3 -0
  20. package/dist/modules/lawbook/assets/skills/draft/steps/04-write-artifacts.md +28 -25
  21. package/dist/modules/lawbook/assets/skills/quick/SKILL.md +11 -0
  22. package/dist/modules/lawbook/assets/skills/quick/steps/01-scaffold.md +6 -0
  23. package/dist/modules/lawbook/assets/skills/quick/steps/02-implement.md +7 -0
  24. package/dist/modules/lawbook/engine.js +115 -55
  25. package/dist/modules/lawbook/levels.js +421 -0
  26. package/dist/modules/lawbook/quick.js +86 -0
  27. package/dist/modules/lawbook/register.js +10 -0
  28. package/dist/shared/exposure.js +3 -0
  29. package/dist/shared/git-history.js +85 -5
  30. package/package.json +1 -1
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { coverageArchiveBlockers } from "./coverage.js";
4
4
  import { sealCapability } from "./anchors.js";
5
+ import { artifactNeeds, confirmedLevel, countUncheckedTasks, gatherSignals, hasDisciplineReport, loadCeremonyConfig, proposeLevel, } from "./levels.js";
5
6
  // speclaw's own spec-driven workflow engine. Inspired by OpenSpec's model
6
7
  // (proposals, delta specs, changes, archive) but implemented from scratch and
7
8
  // deliberately simpler: a change's specs/ holds the full intended spec for each
@@ -33,25 +34,33 @@ mandatory_task_steps:
33
34
  - "Archive the change within the same PR (lawbook:archive)."
34
35
 
35
36
  # A change is required for new behavior, endpoints, schema changes, or UI flows;
36
- # one-line fixes need not have one.
37
+ # one-line fixes may use ceremony level 0 (\`speclaw quick\`) instead of full artifacts.
38
+
39
+ # Ceremony levels (adaptive). Defaults match speclaw's built-in thresholds.
40
+ ceremony:
41
+ cuts: [3, 8, 15]
42
+ hotspotFloor: 0.7
37
43
  `;
38
44
  const README_MD = `# lawbook/ — the spec-driven workflow (speclaw)
39
45
 
40
46
  This directory is managed by speclaw's **lawbook** module.
41
47
 
42
48
  - \`specs/\` — the canonical specifications (the current source of truth).
43
- - \`changes/<name>/\` — an in-flight change: \`proposal.md\`, \`tasks.md\`,
44
- \`design.md\`, and \`specs/<capability>/spec.md\` delta specs.
49
+ - \`changes/<name>/\` — an in-flight change. Artifact volume follows the
50
+ confirmed ceremony level in \`change.json\` (0=quick … 3=full). Missing
51
+ \`change.json\` means level 3 (proposal, design, tasks, delta specs).
45
52
  - \`changes/archive/\` — completed, archived changes.
46
- - \`config.yaml\` — mandatory task steps and workflow rules.
53
+ - \`config.yaml\` — mandatory task steps, coverage, and optional ceremony cuts.
47
54
 
48
55
  ## Workflow
49
56
 
50
- 1. \`lawbook:draft\` — describe the change; generates proposal, delta specs, tasks.
51
- 2. \`lawbook:build\` — implement the tasks.
52
- 3. \`lawbook:sync\` — promote the change's delta specs into \`specs/\`.
53
- 4. \`lawbook:archive\` — sync + move the change to \`changes/archive/\`.
54
- 5. \`lawbook:explore\` — think through an idea before or during a change.
57
+ 1. \`lawbook:explore\` — think through an idea before or during a change.
58
+ 2. \`lawbook:draft\` / \`speclaw quick\` propose/confirm a ceremony level, then
59
+ scaffold only the artifacts that level requires.
60
+ 3. \`lawbook:build\` — implement the tasks.
61
+ 4. \`lawbook:sync\` — promote the change's delta specs into \`specs/\` (when the
62
+ level requires specs).
63
+ 5. \`lawbook:archive\` — sync (if needed) + move the change to \`changes/archive/\`.
55
64
  `;
56
65
  /**
57
66
  * Initialize the spec/ workspace, creating the specs/, changes/, and archive
@@ -146,18 +155,17 @@ function deltaSpecFiles(changeDir) {
146
155
  return out;
147
156
  }
148
157
  /**
149
- * Validate a change's artifacts: proposal.md and tasks.md must be present, and
150
- * each delta spec must use normative language (SHALL/MUST), a "### Requirement:"
151
- * header, and a "#### Scenario:" acceptance criterion.
158
+ * Validate a change's artifacts against its confirmed ceremony level
159
+ * (missing `change.json` level 3 / full ceremony).
152
160
  *
153
161
  * @param projectPath - Absolute path to the project root.
154
162
  * @param change - Change name (folder under lawbook/changes/).
155
- * @returns The validation verdict and the list of issues to fix; never throws
156
- * for a missing change — it is reported as an issue with `valid: false`.
163
+ * @param remeasure - Optional targets to re-score scope growth (paths/symbols).
157
164
  */
158
- export function specValidate(projectPath, change) {
165
+ export function specValidate(projectPath, change, remeasure) {
159
166
  const changeDir = path.join(specRoot(projectPath), "changes", change);
160
167
  const issues = [];
168
+ const warnings = [];
161
169
  if (!fs.existsSync(changeDir)) {
162
170
  return {
163
171
  change,
@@ -167,18 +175,43 @@ export function specValidate(projectPath, change) {
167
175
  deltaSpecs: [],
168
176
  };
169
177
  }
170
- if (!fs.existsSync(path.join(changeDir, "proposal.md")))
171
- issues.push("missing proposal.md");
172
- const tasksPath = path.join(changeDir, "tasks.md");
173
- if (!fs.existsSync(tasksPath))
174
- issues.push("missing tasks.md");
178
+ const level = confirmedLevel(projectPath, change);
179
+ const needs = artifactNeeds(level);
180
+ if (needs.record && !fs.existsSync(path.join(changeDir, "record.md"))) {
181
+ issues.push(`missing record.md (required at ceremony level ${level})`);
182
+ }
183
+ if (needs.proposal && !fs.existsSync(path.join(changeDir, "proposal.md"))) {
184
+ issues.push(`missing proposal.md (required at ceremony level ${level})`);
185
+ }
186
+ if (needs.design && !fs.existsSync(path.join(changeDir, "design.md"))) {
187
+ issues.push(`missing design.md (required at ceremony level ${level})`);
188
+ }
189
+ if (needs.designOptionalWithJustification && !fs.existsSync(path.join(changeDir, "design.md"))) {
190
+ const record = path.join(changeDir, "record.md");
191
+ const text = fs.existsSync(record) ? fs.readFileSync(record, "utf8") : "";
192
+ if (!/design\s*(omitted|skipped|n\/a)/i.test(text) && !/why.*design/i.test(text)) {
193
+ issues.push(`level ${level}: design.md omitted without justification in record.md`);
194
+ }
195
+ }
196
+ if (needs.tasksFile && !fs.existsSync(path.join(changeDir, "tasks.md"))) {
197
+ issues.push(`missing tasks.md (required at ceremony level ${level})`);
198
+ }
175
199
  const deltas = deltaSpecFiles(changeDir);
176
- if (deltas.length === 0)
177
- issues.push("no delta specs under specs/ (a change should specify what it changes)");
200
+ if (needs.deltaSpecs && deltas.length === 0) {
201
+ issues.push(`no delta specs under specs/ (required at ceremony level ${level})`);
202
+ }
203
+ // Scope-growth: when remeasure targets provided (or change.json has prior signals
204
+ // with paths we cannot recover), only check if caller passes targets.
205
+ if (remeasure && (remeasure.paths.length > 0 || remeasure.symbols.length > 0)) {
206
+ const { thresholds } = loadCeremonyConfig(projectPath);
207
+ const measured = proposeLevel(gatherSignals(projectPath, remeasure, thresholds), thresholds);
208
+ if (measured.level !== null && measured.level >= level + 2) {
209
+ issues.push(`scope grew: measured level ${measured.level}, recorded ${level} — run promote or justify (${measured.rationale})`);
210
+ }
211
+ }
178
212
  const root = specRoot(projectPath);
179
213
  const changeSpecs = path.join(changeDir, "specs");
180
214
  const capabilities = canonicalCapabilities(root);
181
- const warnings = [];
182
215
  for (const file of deltas) {
183
216
  const rel = path.relative(changeDir, file);
184
217
  const content = fs.readFileSync(file, "utf8");
@@ -191,7 +224,6 @@ export function specValidate(projectPath, change) {
191
224
  if (!/^###\s+Requirement:/m.test(content)) {
192
225
  issues.push(`${rel}: no "### Requirement:" header`);
193
226
  }
194
- // Advisory divergence checks against the canonical specs.
195
227
  const relFromSpecs = path.relative(changeSpecs, file);
196
228
  const capability = relFromSpecs.split(path.sep)[0];
197
229
  const nearMatch = nearMatchCapability(capability, capabilities);
@@ -268,10 +300,9 @@ export function specSync(projectPath, change) {
268
300
  * Deterministic completeness checks that gate archiving a change. Returns the
269
301
  * blocking reasons; an empty array means the change may be archived.
270
302
  *
271
- * A change is blocked when any task is still unchecked, when it has no discipline
272
- * report under reports/, or when its delta specs are not synced — the canonical
273
- * spec is missing for, or differs from, a delta (meaning sync was not run after
274
- * the last spec edit). The reports/README.md scaffold does not count as a report.
303
+ * Gates respect the confirmed ceremony level (missing `change.json` level 3).
304
+ * Every level still requires checked tasks and a discipline report. Delta-spec
305
+ * sync is required only when the level demands delta specs.
275
306
  *
276
307
  * @param projectPath - Absolute path to the project root.
277
308
  * @param change - Change name (folder under lawbook/changes/).
@@ -283,33 +314,46 @@ export function specArchivePreconditions(projectPath, change) {
283
314
  if (!fs.existsSync(changeDir))
284
315
  return [`change "${change}" not found under lawbook/changes/`];
285
316
  const blockers = [];
286
- // 1. Every task must be checked.
287
- const tasksPath = path.join(changeDir, "tasks.md");
288
- if (!fs.existsSync(tasksPath)) {
289
- blockers.push("missing tasks.md");
317
+ const level = confirmedLevel(projectPath, change);
318
+ const needs = artifactNeeds(level);
319
+ // 1. Every task must be checked (tasks.md, or checklist in record.md at level 0).
320
+ if (needs.tasksFile) {
321
+ const tasksPath = path.join(changeDir, "tasks.md");
322
+ if (!fs.existsSync(tasksPath)) {
323
+ blockers.push("missing tasks.md");
324
+ }
325
+ else {
326
+ const unchecked = countUncheckedTasks(fs.readFileSync(tasksPath, "utf8"));
327
+ if (unchecked > 0)
328
+ blockers.push(`${unchecked} unchecked task(s) in tasks.md`);
329
+ }
290
330
  }
291
- else {
292
- const unchecked = (fs.readFileSync(tasksPath, "utf8").match(/^\s*[-*]\s+\[ \]/gm) ?? []).length;
293
- if (unchecked > 0)
294
- blockers.push(`${unchecked} unchecked task(s) in tasks.md`);
331
+ else if (needs.record) {
332
+ const recordPath = path.join(changeDir, "record.md");
333
+ if (!fs.existsSync(recordPath)) {
334
+ blockers.push("missing record.md");
335
+ }
336
+ else {
337
+ const unchecked = countUncheckedTasks(fs.readFileSync(recordPath, "utf8"));
338
+ if (unchecked > 0)
339
+ blockers.push(`${unchecked} unchecked task(s) in record.md`);
340
+ }
295
341
  }
296
342
  // 2. At least one discipline report must exist (README.md scaffold aside).
297
- const reportsDir = path.join(changeDir, "reports");
298
- const reports = fs.existsSync(reportsDir)
299
- ? fs.readdirSync(reportsDir).filter((n) => n.endsWith(".md") && n.toLowerCase() !== "readme.md")
300
- : [];
301
- if (reports.length === 0) {
343
+ if (!hasDisciplineReport(changeDir)) {
302
344
  blockers.push("no discipline report under reports/ (build must record what was tested)");
303
345
  }
304
- // 3. Delta specs must already be synced into the canonical specs.
305
- for (const file of deltaSpecFiles(changeDir)) {
306
- const rel = path.relative(path.join(changeDir, "specs"), file);
307
- const canonical = path.join(root, "specs", rel);
308
- if (!fs.existsSync(canonical)) {
309
- blockers.push(`spec not synced: lawbook/specs/${rel} missing (run sync first)`);
310
- }
311
- else if (fs.readFileSync(file, "utf8") !== fs.readFileSync(canonical, "utf8")) {
312
- blockers.push(`spec not synced: lawbook/specs/${rel} differs from the delta (run sync first)`);
346
+ // 3. Delta specs must already be synced only when the level requires them.
347
+ if (needs.deltaSpecs) {
348
+ for (const file of deltaSpecFiles(changeDir)) {
349
+ const rel = path.relative(path.join(changeDir, "specs"), file);
350
+ const canonical = path.join(root, "specs", rel);
351
+ if (!fs.existsSync(canonical)) {
352
+ blockers.push(`spec not synced: lawbook/specs/${rel} missing (run sync first)`);
353
+ }
354
+ else if (fs.readFileSync(file, "utf8") !== fs.readFileSync(canonical, "utf8")) {
355
+ blockers.push(`spec not synced: lawbook/specs/${rel} differs from the delta (run sync first)`);
356
+ }
313
357
  }
314
358
  }
315
359
  // 4. Opt-in coverage gate: only when the change's delta specs declare ids.
@@ -317,8 +361,8 @@ export function specArchivePreconditions(projectPath, change) {
317
361
  return blockers;
318
362
  }
319
363
  /**
320
- * Finalize a change: promote its delta specs (via {@link specSync}), then move
321
- * it to changes/archive/<date>-<name>/.
364
+ * Finalize a change: promote its delta specs (via {@link specSync}) when the
365
+ * ceremony level requires them, then move it to changes/archive/<date>-<name>/.
322
366
  *
323
367
  * @param projectPath - Absolute path to the project root.
324
368
  * @param change - Change name (folder under lawbook/changes/).
@@ -336,7 +380,11 @@ export function specArchive(projectPath, change, date) {
336
380
  if (blockers.length > 0) {
337
381
  throw new Error(`cannot archive "${change}" — resolve first:\n${blockers.map((b) => ` - ${b}`).join("\n")}`);
338
382
  }
339
- const { promoted, created, updated } = specSync(projectPath, change);
383
+ const level = confirmedLevel(projectPath, change);
384
+ const sync = artifactNeeds(level).deltaSpecs
385
+ ? specSync(projectPath, change)
386
+ : { change, promoted: [], created: [], updated: [] };
387
+ const { promoted, created, updated } = sync;
340
388
  const seals = sealPromotedCapabilities(projectPath, change, [
341
389
  ...promoted,
342
390
  ...created,
@@ -391,7 +439,13 @@ function sealPromotedCapabilities(projectPath, change, promotedPaths) {
391
439
  export function specList(projectPath) {
392
440
  const root = specRoot(projectPath);
393
441
  if (!fs.existsSync(root)) {
394
- return { initialized: false, activeChanges: [], archivedChanges: [], capabilities: [] };
442
+ return {
443
+ initialized: false,
444
+ activeChanges: [],
445
+ activeLevels: {},
446
+ archivedChanges: [],
447
+ capabilities: [],
448
+ };
395
449
  }
396
450
  const dirsIn = (rel) => {
397
451
  const abs = path.join(root, rel);
@@ -402,9 +456,15 @@ export function specList(projectPath) {
402
456
  .filter((e) => e.isDirectory() && e.name !== "archive")
403
457
  .map((e) => e.name);
404
458
  };
459
+ const activeChanges = dirsIn("changes");
460
+ const activeLevels = {};
461
+ for (const name of activeChanges) {
462
+ activeLevels[name] = confirmedLevel(projectPath, name);
463
+ }
405
464
  return {
406
465
  initialized: true,
407
- activeChanges: dirsIn("changes"),
466
+ activeChanges,
467
+ activeLevels,
408
468
  archivedChanges: dirsIn("changes/archive"),
409
469
  capabilities: dirsIn("specs"),
410
470
  };