@esneiderbravo/speclaw 2.0.3 → 2.0.5
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/check.js +11 -1
- package/dist/cli/commands/cortex.js +17 -0
- package/dist/cli/commands/index-build.js +13 -2
- package/dist/cli/commands/lawbook.js +31 -10
- package/dist/cli/commands/query.js +13 -0
- package/dist/cli/index.js +4 -1
- package/dist/modules/compass/indexer.js +14 -0
- package/dist/modules/compass/register.js +17 -1
- package/dist/modules/cortex/compass-gate.js +84 -0
- package/dist/modules/cortex/harness.js +20 -11
- package/dist/modules/cortex/types.js +14 -0
- package/dist/modules/foundation/assets/AGENTS.template.md +2 -2
- package/dist/modules/foundation/assets/CLAUDE.template.md +4 -3
- package/dist/modules/foundation/assets/docs/compass.template.md +7 -7
- package/dist/modules/foundation/assets/docs/standards/lawbook.template.md +2 -2
- package/dist/modules/foundation/check.js +23 -5
- package/dist/modules/foundation/compass-nudge.js +189 -0
- package/dist/modules/foundation/hooks.js +18 -3
- package/dist/modules/foundation/integrity.js +20 -7
- package/dist/modules/foundation/lock.js +24 -7
- package/dist/modules/lawbook/assets/agents/archiver.md +1 -1
- package/dist/modules/lawbook/assets/agents/explorer.md +7 -4
- package/dist/modules/lawbook/assets/agents/implementer.md +1 -1
- package/dist/modules/lawbook/assets/agents/planner.md +7 -4
- package/dist/modules/lawbook/assets/agents/reviewer.md +1 -1
- package/dist/modules/lawbook/assets/agents/tester.md +1 -1
- package/dist/modules/lawbook/assets/commands/draft.md +2 -2
- package/dist/modules/lawbook/assets/commands/quick.md +2 -2
- package/dist/modules/lawbook/assets/rules/spec-reports-disciplines.md +1 -1
- package/dist/modules/lawbook/assets/rules/spec-tasks-mandatory-steps.md +4 -4
- package/dist/modules/lawbook/assets/skills/archive/steps/02-reconcile.md +1 -1
- package/dist/modules/lawbook/assets/skills/build/steps/01-load-change.md +1 -1
- package/dist/modules/lawbook/assets/skills/cortex/steps/02-dispatch-loop.md +22 -6
- package/dist/modules/lawbook/assets/skills/draft/steps/01-ensure-workspace.md +1 -1
- package/dist/modules/lawbook/assets/skills/draft/steps/02-understand.md +7 -4
- package/dist/modules/lawbook/assets/skills/draft/steps/03-name-capabilities.md +3 -1
- package/dist/modules/lawbook/assets/skills/draft/steps/04-write-artifacts.md +12 -2
- package/dist/modules/lawbook/assets/skills/explore/steps/01-investigate.md +15 -6
- package/dist/modules/lawbook/assets/skills/explore/steps/02-summarize.md +2 -0
- package/dist/modules/lawbook/assets/skills/investigate/steps/02-hand-off.md +1 -1
- package/dist/modules/lawbook/assets/skills/quick/steps/02-implement.md +1 -1
- package/dist/modules/lawbook/assets/skills/sync/steps/01-confirm.md +1 -1
- package/dist/modules/lawbook/assets/skills/sync/steps/02-reconcile.md +1 -1
- package/dist/modules/lawbook/bugfix.js +22 -26
- package/dist/modules/lawbook/change-tool.js +15 -0
- package/dist/modules/lawbook/engine.js +11 -0
- package/dist/modules/lawbook/levels.js +2 -0
- package/dist/modules/lawbook/quick.js +14 -23
- package/dist/modules/lawbook/register.js +1 -1
- package/dist/modules/lawbook/scaffold-change.js +238 -0
- package/dist/shared/compass-calls.js +197 -0
- package/package.json +1 -1
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
# Write the artifacts
|
|
2
2
|
|
|
3
3
|
Artifact volume follows the **confirmed ceremony level** in `change.json`
|
|
4
|
-
(propose + confirm with `
|
|
5
|
-
Missing `change.json` means level 3.
|
|
4
|
+
(propose + confirm with `lawbook_change` action `level` / the human **before**
|
|
5
|
+
scaffolding). Missing `change.json` means level 3.
|
|
6
|
+
|
|
7
|
+
Scaffold with `speclaw lawbook draft <name> --level N --capability <capability>`
|
|
8
|
+
(or `lawbook_change` action `draft` with `change`, `level`, and `capability`),
|
|
9
|
+
passing the exact capability name chosen in the previous step. It creates
|
|
10
|
+
`change.json`, `reports/README.md`, and the level's stub artifacts; fill them
|
|
11
|
+
in. When the capability is new, the delta stub is a marked placeholder:
|
|
12
|
+
replace it with the real spec and remove the
|
|
13
|
+
`<!-- speclaw:placeholder-delta -->` marker (validate warns and sync refuses
|
|
14
|
+
while it remains). For each further capability, add its delta by hand, and
|
|
15
|
+
delete any stub delta the change does not use.
|
|
6
16
|
|
|
7
17
|
Create under `lawbook/changes/<name>/` only what the level needs:
|
|
8
18
|
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
# Investigate
|
|
2
2
|
|
|
3
|
-
- **
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
- **Locate through Compass first.** Your first code-locating call is
|
|
4
|
+
`compass_find` (mode: concept for an idea, exact for a name) or
|
|
5
|
+
`compass_explore <symbol>` to read a symbol's source plus its callers and
|
|
6
|
+
callees. Use `compass_diff_context` when the question is about a diff.
|
|
7
|
+
- **Index only when needed.** Run `compass_index` only when `compass_find`
|
|
8
|
+
returns nothing or reports a missing or stale index, then retry the find. It
|
|
9
|
+
is incremental (unchanged files skipped by hash).
|
|
10
|
+
- **Read/Grep code only with a named reason.** Before any Read/Grep/Glob of
|
|
11
|
+
indexed source code, name which Rule 1 fallback holds: (1) a Compass call ran
|
|
12
|
+
and returned nothing useful for the query, (2) the graph is missing and
|
|
13
|
+
`compass_index` cannot build it, or (3) the target is not indexed code
|
|
14
|
+
(markdown, JSON/config, stylesheets, logs, generated files, lockfiles).
|
|
15
|
+
"I know which file it is" is not a reason — `compass_explore` takes a path.
|
|
16
|
+
- **Count your Compass calls.** Keep a tally of `compass_explore`,
|
|
17
|
+
`compass_find`, and `compass_diff_context` calls for the brief.
|
|
9
18
|
- **Ask sharp questions** to surface hidden assumptions, constraints, and edge
|
|
10
19
|
cases. Confirm scope and non-goals.
|
|
11
20
|
- **Check the law.** Read the relevant `docs/standards/` so any direction you
|
|
@@ -9,6 +9,8 @@ Produce a brief the planner can use as the code map. Include only these fields:
|
|
|
9
9
|
- recommended approach
|
|
10
10
|
- open questions
|
|
11
11
|
- gaps: the phrase "nothing unresolved", or each symbol that is still unresolved
|
|
12
|
+
- `Compass calls made: N` — the count of `compass_explore`, `compass_find`, and
|
|
13
|
+
`compass_diff_context` calls (and their aliases) you made
|
|
12
14
|
|
|
13
15
|
When the direction is clear, offer to `draft` the change.
|
|
14
16
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Hand off to draft
|
|
2
2
|
|
|
3
|
-
- **`
|
|
3
|
+
- **`compass_explore`** (include `blast_radius`) on the confirmed root cause.
|
|
4
4
|
- **`speclaw lawbook draft --bug <name>`** — pre-seed only; fill repro, fix, regression test, prevention.
|
|
5
5
|
|
|
6
6
|
Treat the ranking as **evidence**, not a verdict. No further steps remain — investigate workflow complete.
|
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
Make the fix, tick every `- [ ]` in `record.md`, and write at least one
|
|
4
4
|
discipline report under `reports/`. Archive with `lawbook_change` (action: archive) (no sync at
|
|
5
|
-
level 0). Promote via `
|
|
5
|
+
level 0). Promote via `lawbook_change` (action: level, mode: promote) if scope grew.
|
|
6
6
|
|
|
7
7
|
No further steps — workflow complete.
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Before promoting, compare what was built against the change's delta specs:
|
|
4
4
|
|
|
5
5
|
- Reconstruct what shipped: `git diff <branch-point>...HEAD` for the change's
|
|
6
|
-
branch, then `compass_explore`
|
|
6
|
+
branch, then `compass_explore` (include `blast_radius`) on the touched symbols to
|
|
7
7
|
understand behavior, not just changed lines.
|
|
8
8
|
- Diff intent vs reality: list behavior that is implemented but missing from,
|
|
9
9
|
or contradicted by, `lawbook/changes/<name>/specs/**`.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { scaffoldChange } from "./scaffold-change.js";
|
|
4
4
|
export const BUGFIX_HEADINGS = [
|
|
5
5
|
"1. Observed symptom",
|
|
6
6
|
"2. Minimal reproduction",
|
|
@@ -153,32 +153,28 @@ ${blast}
|
|
|
153
153
|
* Scaffold a bug change: `bugfix.md`, `change.json`, and `reports/`.
|
|
154
154
|
*/
|
|
155
155
|
export function scaffoldBugfix(projectPath, name, opts = {}) {
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
level,
|
|
177
|
-
confirmedBy: "human",
|
|
156
|
+
const r = scaffoldChange(projectPath, name, {
|
|
157
|
+
changeType: "bug",
|
|
158
|
+
targets: opts.targets,
|
|
159
|
+
level: (proposal) => opts.level ?? (proposal.level !== null && proposal.level <= 1 ? proposal.level : 1),
|
|
160
|
+
reportsReadme: `# Reports — ${name}\n\nBug reports MUST include the regression test **failing before the fix**.\n`,
|
|
161
|
+
artifacts: ({ level }) => {
|
|
162
|
+
const lvl = level ?? 1;
|
|
163
|
+
const files = {
|
|
164
|
+
"bugfix.md": bugfixTemplate(name, lvl, opts.seed),
|
|
165
|
+
};
|
|
166
|
+
if (lvl >= 1) {
|
|
167
|
+
files["tasks.md"] =
|
|
168
|
+
`- [ ] Reproduce and confirm root cause\n- [ ] Implement fix\n- [ ] Add regression test (red before, green after)\n- [ ] Complete prevention §7\n- [ ] Write discipline report under reports/\n`;
|
|
169
|
+
}
|
|
170
|
+
if (lvl >= 2) {
|
|
171
|
+
files["design.md"] =
|
|
172
|
+
`# Design — ${name}\n\n## Approach\n\n(structural bugfix — document the fix architecture)\n`;
|
|
173
|
+
}
|
|
174
|
+
return files;
|
|
175
|
+
},
|
|
178
176
|
});
|
|
179
|
-
|
|
180
|
-
writeCeremonyRecord(projectPath, name, updated);
|
|
181
|
-
return { change: name, proposal, record: updated, dir: changeDir };
|
|
177
|
+
return { change: r.change, proposal: r.proposal, record: r.record, dir: r.dir };
|
|
182
178
|
}
|
|
183
179
|
/** Read change type from an archived folder path. */
|
|
184
180
|
export function readChangeTypeFromDir(changeDir) {
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { specInit, specValidate, specSync, specArchive, specList } from "./engine.js";
|
|
3
3
|
import { handleLevel } from "./quick.js";
|
|
4
|
+
import { scaffoldBugfix } from "./bugfix.js";
|
|
5
|
+
import { scaffoldFeature } from "./scaffold-change.js";
|
|
4
6
|
import { buildCoverageReport, loadCoverageConfig, renderCoverageAgent } from "./coverage.js";
|
|
5
7
|
import { buildDriftReport, renderDriftAgent } from "./drift.js";
|
|
6
8
|
import { handleHarness, harnessOps } from "../cortex/harness.js";
|
|
7
9
|
export const lawbookChangeActions = [
|
|
8
10
|
"init",
|
|
9
11
|
"list",
|
|
12
|
+
"draft",
|
|
10
13
|
"validate",
|
|
11
14
|
"sync",
|
|
12
15
|
"archive",
|
|
@@ -38,6 +41,7 @@ export const lawbookChangeSchema = {
|
|
|
38
41
|
openQuestions: z.array(z.string()).optional(),
|
|
39
42
|
pauseForQuestions: z.boolean().optional(),
|
|
40
43
|
note: z.string().optional(),
|
|
44
|
+
bug: z.boolean().optional(),
|
|
41
45
|
};
|
|
42
46
|
function requireField(args, field) {
|
|
43
47
|
const v = args[field];
|
|
@@ -56,6 +60,17 @@ export function handleLawbookChange(args) {
|
|
|
56
60
|
return specInit(args.projectPath);
|
|
57
61
|
case "list":
|
|
58
62
|
return specList(args.projectPath);
|
|
63
|
+
case "draft": {
|
|
64
|
+
// Covers: req~feature-draft~1
|
|
65
|
+
const name = requireField(args, "change");
|
|
66
|
+
if (args.bug)
|
|
67
|
+
return scaffoldBugfix(args.projectPath, name, { level: args.level });
|
|
68
|
+
return scaffoldFeature(args.projectPath, name, {
|
|
69
|
+
level: args.level,
|
|
70
|
+
reason: args.reason,
|
|
71
|
+
capability: args.capability,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
59
74
|
case "validate":
|
|
60
75
|
return specValidate(args.projectPath, requireField(args, "change"));
|
|
61
76
|
case "sync":
|
|
@@ -5,6 +5,7 @@ import { sealCapability } from "./anchors.js";
|
|
|
5
5
|
import { classifyEars, diagnoseEars, extractNormativeBody, loadEarsConfig, splitRequirementBlocks, } from "./ears.js";
|
|
6
6
|
import { artifactNeeds, confirmedLevel, countUncheckedTasks, gatherSignals, hasDisciplineReport, loadCeremonyConfig, proposeLevel, readChangeType, readCeremonyRecord, } from "./levels.js";
|
|
7
7
|
import { inferBugResolution, preventionRequiresDelta, validateBugfixContent } from "./bugfix.js";
|
|
8
|
+
import { isPlaceholderDelta } from "./scaffold-change.js";
|
|
8
9
|
import { harnessArchiveBlockers } from "../cortex/harness.js";
|
|
9
10
|
// speclaw's own spec-driven workflow engine. Inspired by OpenSpec's model
|
|
10
11
|
// (proposals, delta specs, changes, archive) but implemented from scratch and
|
|
@@ -251,6 +252,9 @@ export function specValidate(projectPath, change, remeasure) {
|
|
|
251
252
|
for (const file of deltas) {
|
|
252
253
|
const rel = path.relative(changeDir, file);
|
|
253
254
|
const content = fs.readFileSync(file, "utf8");
|
|
255
|
+
if (isPlaceholderDelta(content)) {
|
|
256
|
+
warnings.push(`${rel}: placeholder delta from \`lawbook draft\` — replace it (and remove the marker) or delete it; sync refuses placeholders`);
|
|
257
|
+
}
|
|
254
258
|
if (!/\b(SHALL|MUST)\b/.test(content)) {
|
|
255
259
|
issues.push(`${rel}: no normative requirement (use SHALL/MUST)`);
|
|
256
260
|
}
|
|
@@ -328,6 +332,13 @@ export function specSync(projectPath, change) {
|
|
|
328
332
|
const updated = [];
|
|
329
333
|
if (!fs.existsSync(changeSpecs))
|
|
330
334
|
return { change, promoted, created, updated };
|
|
335
|
+
// Refuse before copying anything, so a placeholder never becomes canonical.
|
|
336
|
+
const placeholders = deltaSpecFiles(changeDir)
|
|
337
|
+
.filter((f) => isPlaceholderDelta(fs.readFileSync(f, "utf8")))
|
|
338
|
+
.map((f) => path.relative(changeDir, f));
|
|
339
|
+
if (placeholders.length > 0) {
|
|
340
|
+
throw new Error(`refusing to sync placeholder delta(s): ${placeholders.join(", ")} — fill them in and remove the marker, or delete them`);
|
|
341
|
+
}
|
|
331
342
|
const walk = (dir) => {
|
|
332
343
|
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
333
344
|
const full = path.join(dir, e.name);
|
|
@@ -385,6 +385,8 @@ export function setCeremonyLevel(projectPath, change, opts) {
|
|
|
385
385
|
confirmedAt: new Date().toISOString(),
|
|
386
386
|
overrideReason: opts.reason,
|
|
387
387
|
promotions: prev?.promotions ?? [],
|
|
388
|
+
// A drafted change keeps its type when its level is confirmed later.
|
|
389
|
+
...(prev?.changeType ? { changeType: prev.changeType } : {}),
|
|
388
390
|
};
|
|
389
391
|
writeCeremonyRecord(projectPath, change, record);
|
|
390
392
|
return record;
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
1
|
import { gatherSignals, loadCeremonyConfig, promoteCeremonyLevel, proposeLevel, setCeremonyLevel, } from "./levels.js";
|
|
2
|
+
import { scaffoldChange } from "./scaffold-change.js";
|
|
4
3
|
/**
|
|
5
4
|
* Scaffold a level-0 change: `record.md`, `change.json`, and `reports/`.
|
|
6
5
|
*
|
|
@@ -9,25 +8,23 @@ import { gatherSignals, loadCeremonyConfig, promoteCeremonyLevel, proposeLevel,
|
|
|
9
8
|
* @param targets - Optional paths/symbols used to propose the level (default empty → score 0).
|
|
10
9
|
*/
|
|
11
10
|
export function scaffoldQuick(projectPath, name, targets = { paths: [], symbols: [] }) {
|
|
12
|
-
const changeDir = path.join(projectPath, "lawbook", "changes", name);
|
|
13
|
-
if (fs.existsSync(changeDir)) {
|
|
14
|
-
throw new Error(`change "${name}" already exists under lawbook/changes/`);
|
|
15
|
-
}
|
|
16
|
-
const { thresholds } = loadCeremonyConfig(projectPath);
|
|
17
|
-
const signals = gatherSignals(projectPath, targets, thresholds);
|
|
18
|
-
const proposal = proposeLevel(signals, thresholds);
|
|
19
11
|
// quick always records level 0; if measurement says higher, still allow but note it.
|
|
20
|
-
const
|
|
21
|
-
fs.mkdirSync(path.join(changeDir, "reports"), { recursive: true });
|
|
22
|
-
const rationale = proposal.level === null
|
|
12
|
+
const quickRationale = (proposal) => proposal.level === null
|
|
23
13
|
? proposal.rationale
|
|
24
14
|
: proposal.level > 0
|
|
25
15
|
? `${proposal.rationale} — quick forced level 0; promote if scope grows`
|
|
26
16
|
: proposal.rationale;
|
|
27
|
-
const
|
|
17
|
+
const r = scaffoldChange(projectPath, name, {
|
|
18
|
+
targets,
|
|
19
|
+
level: 0,
|
|
20
|
+
recordProposal: (proposal) => ({ ...proposal, rationale: quickRationale(proposal) }),
|
|
21
|
+
reason: (proposal) => proposal.level !== null && proposal.level > 0 ? "speclaw quick" : undefined,
|
|
22
|
+
reportsReadme: `# Reports — ${name}\n\nAdd at least one discipline report before archive.\n`,
|
|
23
|
+
artifacts: ({ proposal }) => ({
|
|
24
|
+
"record.md": `# ${name}
|
|
28
25
|
|
|
29
26
|
**Level:** 0 (proposed: ${proposal.level ?? "n/a"}, confirmed by: human)
|
|
30
|
-
**Why:** ${
|
|
27
|
+
**Why:** ${quickRationale(proposal)}
|
|
31
28
|
|
|
32
29
|
## What changes
|
|
33
30
|
|
|
@@ -42,16 +39,10 @@ export function scaffoldQuick(projectPath, name, targets = { paths: [], symbols:
|
|
|
42
39
|
## Evidence
|
|
43
40
|
|
|
44
41
|
- \`reports/\` — add a discipline report before archive
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
fs.writeFileSync(path.join(changeDir, "reports", "README.md"), `# Reports — ${name}\n\nAdd at least one discipline report before archive.\n`);
|
|
48
|
-
const record = setCeremonyLevel(projectPath, name, {
|
|
49
|
-
proposal: { ...proposal, rationale },
|
|
50
|
-
level,
|
|
51
|
-
confirmedBy: "human",
|
|
52
|
-
reason: proposal.level !== null && proposal.level > 0 ? "speclaw quick" : undefined,
|
|
42
|
+
`,
|
|
43
|
+
}),
|
|
53
44
|
});
|
|
54
|
-
return { change:
|
|
45
|
+
return { change: r.change, proposal: r.proposal, record: r.record, dir: r.dir };
|
|
55
46
|
}
|
|
56
47
|
/**
|
|
57
48
|
* Handle `lawbook_level` modes: propose / set / promote / explain.
|
|
@@ -33,7 +33,7 @@ export function registerSpec(server, opts = {}) {
|
|
|
33
33
|
return;
|
|
34
34
|
defineTool(server, { name, description, inputSchema, handler });
|
|
35
35
|
};
|
|
36
|
-
add("lawbook_change", "Lawbook lifecycle: init, list, validate, sync, archive, level, coverage, drift.", lawbookChangeSchema, async (args) => text(handleLawbookChange(args)));
|
|
36
|
+
add("lawbook_change", "Lawbook lifecycle: init, list, draft, validate, sync, archive, level, coverage, drift.", lawbookChangeSchema, async (args) => text(handleLawbookChange(args)));
|
|
37
37
|
add("lawbook_investigate", "Rank bug origins from the graph. Pass stackTrace or symptom — evidence, not verdict.", {
|
|
38
38
|
projectPath: z.string(),
|
|
39
39
|
stackTrace: z.string().optional(),
|
|
@@ -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
|
+
}
|