@esneiderbravo/speclaw 2.0.22 → 2.0.24

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.
@@ -30,12 +30,21 @@ export function registerCompass(server, opts = {}) {
30
30
  };
31
31
  add("compass_explore", "Symbol context in one call: source, callers, callees, blast radius, tests. maxDepth>1 returns the whole callee chain with source.", {
32
32
  projectPath: z.string(),
33
- node: z.string(),
33
+ node: z.string().optional(),
34
+ query: z.string().optional(),
34
35
  to: z.string().optional(),
35
36
  include: includeEnum.optional(),
36
37
  mode: z.enum(["brief", "full"]).optional(),
37
38
  maxDepth: z.number().int().min(1).max(8).optional(),
38
- }, async ({ projectPath, node, to, include, mode, maxDepth }) => {
39
+ }, async ({ projectPath, node, query, to, include, mode, maxDepth }) => {
40
+ if (!node) {
41
+ // Agents send a search phrase here; answer it as a find instead of a
42
+ // validation error that costs a second call.
43
+ if (!query)
44
+ throw new Error("compass_explore requires 'node' (a symbol name)");
45
+ const found = await findSymbols(projectPath, query, "concept");
46
+ return text(formatFindResponse(found), { maxTokens: found.cap });
47
+ }
39
48
  const result = await exploreRich({
40
49
  projectPath,
41
50
  node,
@@ -37,7 +37,17 @@ function nowIso() {
37
37
  function requireChangeDir(projectPath, change) {
38
38
  const resolved = resolveChangeDir(projectPath, change);
39
39
  if (!resolved) {
40
- throw new Error(`change "${change}" not found under lawbook/changes/`);
40
+ const changesDir = path.join(specRoot(projectPath), "changes");
41
+ const active = fs.existsSync(changesDir)
42
+ ? fs
43
+ .readdirSync(changesDir, { withFileTypes: true })
44
+ .filter((e) => e.isDirectory() && e.name !== "archive")
45
+ .map((e) => e.name)
46
+ : [];
47
+ // Name the next step: most misses are a start before the change was drafted.
48
+ throw new Error(`change "${change}" not found under lawbook/changes/ — draft it first ` +
49
+ `(lawbook_change action 'draft')` +
50
+ (active.length ? `; active: ${active.join(", ")}` : ""));
41
51
  }
42
52
  return resolved;
43
53
  }
@@ -25,7 +25,7 @@ Claude-specific notes: [`CLAUDE.md`](CLAUDE.md). The law: [`LAWS.md`](LAWS.md).
25
25
  sheet: [`docs/compass.md`](docs/compass.md).
26
26
  3. **Cortex, always — one brain on the critical path** (*One brain. Many
27
27
  agents.*). Branch `<type>/<slug>`, locate with Compass, implement the
28
- change and its test yourself, run the tests, finish. The `Stop` hook
28
+ change and its test yourself, run the tests that cover it, finish. The `Stop` hook
29
29
  (`speclaw ship-on-stop`) — or `speclaw ship <change> --summary "<what and
30
30
  why>"` as your last step on agents without hooks — sizes the change from
31
31
  its diff, names the artifacts that level owes while you edit (the why,
@@ -36,7 +36,10 @@ Claude-specific notes: [`CLAUDE.md`](CLAUDE.md). The law: [`LAWS.md`](LAWS.md).
36
36
  agents only to run three or more large, independent parts in parallel.
37
37
  Details: the `cortex` skill and
38
38
  [`docs/standards/lawbook.md`](docs/standards/lawbook.md).
39
- 4. **Run the quality gates yourself** before declaring anything done — see
39
+ 4. **The quality gates run once** before anything is declared done — by the
40
+ `Stop` hook where there is one, scoped to the tests the diff reaches (run only
41
+ those yourself: `speclaw affected-tests --file <path>`; the full suite runs
42
+ in CI), else by you or `speclaw ship` — see
40
43
  [`docs/standards/testing-standards.md`](docs/standards/testing-standards.md):
41
44
  - Tests: `{{test_commands}}`
42
45
  - Lint / type-check: `{{lint_commands}}`
@@ -54,9 +54,11 @@ See [`docs/compass.md`](docs/compass.md).
54
54
 
55
55
  Every change is built with **Cortex** (*One brain. Many agents.*), and Cortex
56
56
  costs no more time than working alone: branch `<type>/<slug>`, locate with
57
- Compass, implement the change and its test yourself, run the tests, finish.
57
+ Compass, implement the change and its test yourself, run only the tests that
58
+ cover it (`speclaw affected-tests --file <path>` prints the command), finish.
58
59
  Then stop: the `Stop` hook (`speclaw ship-on-stop`) sizes the change from its
59
- diff, runs the gates once, writes the report from their real output, and
60
+ diff, runs the gates once — lint, build and the tests the diff reaches (the
61
+ full suite runs in CI) — writes the report from their real output, and
60
62
  archives level-0 work with no agent turns. What the level owes (the why — a
61
63
  commit body counts — tasks, delta specs, proposal/design) is named while you
62
64
  edit: write it in the same turn. A missing piece or a failing gate comes back
@@ -72,8 +74,10 @@ parallel. Details: the `cortex` skill and
72
74
  - Tests: `{{test_commands}}`
73
75
  - Lint / type-check: `{{lint_commands}}`
74
76
 
75
- The **tester** role runs them and reports real output. Never claim success you
76
- did not observe. Full rules:
77
+ With the `Stop` hook they run once, at the stop, scoped to the tests the diff
78
+ reaches — never run the full suite yourself; a failure comes back to you. Without the hook, run them
79
+ yourself (or `speclaw ship`) before declaring anything done. Never claim
80
+ success you did not observe. Full rules:
77
81
  [`docs/standards/testing-standards.md`](docs/standards/testing-standards.md).
78
82
 
79
83
  ## Rule 4 — Conventions
@@ -1,8 +1,10 @@
1
1
  # Testing & Quality Gates — {{project_name}}
2
2
 
3
3
  The quality law of the project — see [`../../LAWS.md`](../../LAWS.md). These
4
- gates are non-negotiable: an agent runs them itself and reports real output
5
- before declaring anything done.
4
+ gates are non-negotiable: they run once, with real output, before anything is
5
+ declared done — by the `Stop` hook where installed (the agent then runs only
6
+ the tests its change touches, never the full suite twice), else by the agent
7
+ itself or `speclaw ship`.
6
8
 
7
9
  ## Gates
8
10
 
@@ -6,5 +6,6 @@ Archive the completed change: $ARGUMENTS
6
6
 
7
7
  Follow the `archive` skill: confirm every task (or level-0 checklist) is done
8
8
  and gates are green, reconcile if the level has delta specs, run
9
- `lawbook_change` (action: validate), then `lawbook_change` (action: archive) with today's date (YYYY-MM-DD). Sync
10
- runs only when the ceremony level requires specs. Never move the folder by hand.
9
+ `lawbook_change` (action: validate), then `lawbook_change` (action: archive). Archive dates
10
+ the folder today and syncs the delta specs itself when the level carries them. Never move
11
+ the folder by hand.
@@ -1,7 +1,7 @@
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 { shipChange } from "./ship.js";
4
+ import { resolveActiveChange, shipChange } from "./ship.js";
5
5
  import { scaffoldBugfix } from "./bugfix.js";
6
6
  import { scaffoldFeature } from "./scaffold-change.js";
7
7
  import { buildCoverageReport, loadCoverageConfig, renderCoverageAgent } from "./coverage.js";
@@ -21,17 +21,27 @@ export const lawbookChangeActions = [
21
21
  "harness",
22
22
  "ship",
23
23
  ];
24
+ // Words agents reach for that name a real action; each one used to cost a failed call.
25
+ const ACTION_SYNONYMS = { create: "draft", new: "draft" };
26
+ /** A list arg sent as one comma- or newline-separated string still means the list. */
27
+ const looseList = z.preprocess((v) => typeof v === "string"
28
+ ? v
29
+ .split(/[,\n]/)
30
+ .map((s) => s.trim())
31
+ .filter(Boolean)
32
+ : v, z.array(z.string()));
24
33
  export const lawbookChangeSchema = {
25
34
  projectPath: z.string(),
26
- action: z.enum(lawbookChangeActions),
35
+ action: z.preprocess((v) => (typeof v === "string" ? (ACTION_SYNONYMS[v] ?? v) : v), z.enum(lawbookChangeActions)),
27
36
  change: z.string().optional(),
37
+ name: z.string().optional(),
28
38
  date: z
29
39
  .string()
30
40
  .regex(/^\d{4}-\d{2}-\d{2}$/)
31
41
  .optional(),
32
42
  mode: z.enum(["propose", "set", "promote", "explain"]).optional(),
33
- paths: z.array(z.string()).optional(),
34
- symbols: z.array(z.string()).optional(),
43
+ paths: looseList.optional(),
44
+ symbols: looseList.optional(),
35
45
  level: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)]).optional(),
36
46
  reason: z.string().optional(),
37
47
  onlyDefects: z.boolean().optional(),
@@ -52,6 +62,21 @@ function requireField(args, field) {
52
62
  return v;
53
63
  throw new Error(`lawbook_change: action '${args.action}' requires '${String(field)}'`);
54
64
  }
65
+ /** The change named by `change` or `name`, else the one the project and branch point at. */
66
+ function targetChange(args) {
67
+ const named = args.change || args.name;
68
+ if (named)
69
+ return named;
70
+ const inferred = resolveActiveChange(args.projectPath);
71
+ if (inferred)
72
+ return inferred;
73
+ const active = specList(args.projectPath).activeChanges;
74
+ throw new Error(`lawbook_change: action '${args.action}' requires 'change'` +
75
+ (active.length ? ` — active: ${active.join(", ")}` : " — no active change; draft one first"));
76
+ }
77
+ function today() {
78
+ return new Date().toISOString().slice(0, 10);
79
+ }
55
80
  /**
56
81
  * Dispatch `lawbook_change` by action.
57
82
  *
@@ -65,7 +90,7 @@ export function handleLawbookChange(args) {
65
90
  return specList(args.projectPath);
66
91
  case "draft": {
67
92
  // Covers: req~feature-draft~1
68
- const name = requireField(args, "change");
93
+ const name = args.change || args.name || requireField(args, "change");
69
94
  if (args.bug)
70
95
  return scaffoldBugfix(args.projectPath, name, { level: args.level });
71
96
  return scaffoldFeature(args.projectPath, name, {
@@ -75,13 +100,13 @@ export function handleLawbookChange(args) {
75
100
  });
76
101
  }
77
102
  case "validate":
78
- return specValidate(args.projectPath, requireField(args, "change"));
103
+ return specValidate(args.projectPath, targetChange(args));
79
104
  case "sync":
80
- return specSync(args.projectPath, requireField(args, "change"));
105
+ return specSync(args.projectPath, targetChange(args));
81
106
  case "archive":
82
- return specArchive(args.projectPath, requireField(args, "change"), requireField(args, "date"));
107
+ return specArchive(args.projectPath, targetChange(args), args.date ?? today());
83
108
  case "ship":
84
- return shipChange(args.projectPath, requireField(args, "change"), {
109
+ return shipChange(args.projectPath, targetChange(args), {
85
110
  summary: args.note,
86
111
  date: args.date,
87
112
  });
@@ -91,7 +116,10 @@ export function handleLawbookChange(args) {
91
116
  return handleLevel({
92
117
  projectPath: args.projectPath,
93
118
  mode: args.mode,
94
- change: args.change,
119
+ // `propose` and `explain` only measure; `set` and `promote` act on a change.
120
+ change: args.mode === "propose" || args.mode === "explain"
121
+ ? args.change || args.name
122
+ : targetChange(args),
95
123
  paths: args.paths,
96
124
  symbols: args.symbols,
97
125
  level: args.level,
@@ -121,7 +149,7 @@ export function handleLawbookChange(args) {
121
149
  }
122
150
  const result = handleHarness({
123
151
  projectPath: args.projectPath,
124
- change: requireField(args, "change"),
152
+ change: targetChange(args),
125
153
  harnessOp: args.harnessOp,
126
154
  verdict: args.verdict ?? null,
127
155
  openQuestions: args.openQuestions,
@@ -380,9 +380,11 @@ export function specSync(projectPath, change) {
380
380
  *
381
381
  * @param projectPath - Absolute path to the project root.
382
382
  * @param change - Change name (folder under lawbook/changes/).
383
+ * @param opts.syncing - The caller promotes the delta specs itself (archive
384
+ * does), so an unsynced delta is not a blocker.
383
385
  * @returns Human-readable blockers; empty when the change is ready to archive.
384
386
  */
385
- export function specArchivePreconditions(projectPath, change) {
387
+ export function specArchivePreconditions(projectPath, change, opts = {}) {
386
388
  const root = specRoot(projectPath);
387
389
  const changeDir = path.join(root, "changes", change);
388
390
  if (!fs.existsSync(changeDir))
@@ -439,7 +441,7 @@ export function specArchivePreconditions(projectPath, change) {
439
441
  const bugNeedsDelta = needs.bugfix &&
440
442
  fs.existsSync(bugPath) &&
441
443
  preventionRequiresDelta(fs.readFileSync(bugPath, "utf8"));
442
- if (needs.deltaSpecs || bugNeedsDelta) {
444
+ if ((needs.deltaSpecs || bugNeedsDelta) && !opts.syncing) {
443
445
  for (const file of deltaSpecFiles(changeDir)) {
444
446
  const rel = path.relative(path.join(changeDir, "specs"), file);
445
447
  const canonical = path.join(root, "specs", rel);
@@ -474,7 +476,9 @@ export function specArchive(projectPath, change, date) {
474
476
  const changeDir = path.join(root, "changes", change);
475
477
  if (!fs.existsSync(changeDir))
476
478
  throw new Error(`change "${change}" not found`);
477
- const blockers = specArchivePreconditions(projectPath, change);
479
+ // Archive promotes the delta specs below; demanding a manual sync first only
480
+ // cost agents a failed call and a retry.
481
+ const blockers = specArchivePreconditions(projectPath, change, { syncing: true });
478
482
  if (blockers.length > 0) {
479
483
  throw new Error(`cannot archive "${change}" — resolve first:\n${blockers.map((b) => ` - ${b}`).join("\n")}`);
480
484
  }
@@ -67,13 +67,13 @@ export function registerSpec(server, opts = {}) {
67
67
  defineAliasTool(server, {
68
68
  name: "lawbook_validate",
69
69
  description: "Deprecated alias for lawbook_change action validate.",
70
- inputSchema: { projectPath: z.string(), change: z.string() },
70
+ inputSchema: { projectPath: z.string(), change: z.string().optional() },
71
71
  handler: aliasHandler("lawbook_validate", "validate"),
72
72
  });
73
73
  defineAliasTool(server, {
74
74
  name: "lawbook_sync",
75
75
  description: "Deprecated alias for lawbook_change action sync.",
76
- inputSchema: { projectPath: z.string(), change: z.string() },
76
+ inputSchema: { projectPath: z.string(), change: z.string().optional() },
77
77
  handler: aliasHandler("lawbook_sync", "sync"),
78
78
  });
79
79
  defineAliasTool(server, {
@@ -81,8 +81,11 @@ export function registerSpec(server, opts = {}) {
81
81
  description: "Deprecated alias for lawbook_change action archive.",
82
82
  inputSchema: {
83
83
  projectPath: z.string(),
84
- change: z.string(),
85
- date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
84
+ change: z.string().optional(),
85
+ date: z
86
+ .string()
87
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
88
+ .optional(),
86
89
  },
87
90
  handler: aliasHandler("lawbook_archive", "archive"),
88
91
  });
@@ -4,9 +4,11 @@ import { spawn, spawnSync } from "node:child_process";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { createHash } from "node:crypto";
6
6
  import { changedFiles, isGitRepo, mergeBase, worktreeChangedFiles } from "../../shared/git.js";
7
+ import { isTestPath, loadAffectedConfig, matchesAny } from "../compass/affected-config.js";
8
+ import { affectedTests } from "../compass/affected.js";
7
9
  import { COMPASS_DOC, stripCompassMapBlock } from "../../shared/compass-map.js";
8
10
  import { handleHarness, readHarness } from "../cortex/harness.js";
9
- import { deltaSpecFiles, specArchive, specArchivePreconditions } from "./engine.js";
11
+ import { deltaSpecFiles, specArchive, specArchivePreconditions, specList } from "./engine.js";
10
12
  import { artifactNeeds, confirmedLevel, gatherSignals, loadCeremonyConfig, promoteCeremonyLevel, proposeLevel, readCeremonyRecord, readChangeType, } from "./levels.js";
11
13
  import { scaffoldQuick } from "./quick.js";
12
14
  import { CHANGE_NAME_RE, isPlaceholderDelta, scaffoldFeature } from "./scaffold-change.js";
@@ -43,6 +45,9 @@ export function readShipConfig(projectPath) {
43
45
  const d = /^\s+discipline:\s*["']?([\w-]+)["']?\s*$/.exec(line);
44
46
  if (d)
45
47
  out.discipline = d[1];
48
+ const t = /^\s+tests:\s*["']?(affected|full)["']?\s*$/.exec(line);
49
+ if (t)
50
+ out.tests = t[1];
46
51
  }
47
52
  return out;
48
53
  }
@@ -66,6 +71,39 @@ export function detectGates(projectPath) {
66
71
  const deduped = picked.includes("check") ? picked.filter((s) => s !== "lint") : picked;
67
72
  return deduped.map((s) => (s === "test" ? "npm test" : `npm run ${s}`));
68
73
  }
74
+ /** The project's whole test suite, as `detectGates` and people write it. */
75
+ const TEST_GATE_RE = /^(npm|pnpm|yarn)( run)? test\s*$/;
76
+ /**
77
+ * The tests a branch diff reaches, to run at the stop instead of the whole
78
+ * suite: a one-line fix should not wait on every test in the repo, and the
79
+ * full suite still runs in CI before merge. Falls back to the full suite when
80
+ * the selection cannot be trusted — no index, a global file, or source changes
81
+ * no test reaches.
82
+ *
83
+ * @returns The command to run (null: nothing to run) and what it covers.
84
+ */
85
+ function scopedTestGate(projectPath, fullCommand, files) {
86
+ const full = (why) => ({ command: fullCommand, scope: `full suite: ${why}` });
87
+ try {
88
+ const at = affectedTests(projectPath, { files });
89
+ if (at.mode === "all")
90
+ return full(at.reason);
91
+ if (at.tests.length === 0 || !at.command) {
92
+ const { thresholds } = loadCeremonyConfig(projectPath);
93
+ const code = files.filter((f) => !matchesAny(f, thresholds.docGlobs));
94
+ if (code.length)
95
+ return full("no test reaches the changed code");
96
+ return { command: null, scope: "docs only: no test reaches the change" };
97
+ }
98
+ return {
99
+ command: at.command,
100
+ scope: `${at.tests.length} affected test file(s), ${at.skipped.files} skipped; the full suite runs in CI`,
101
+ };
102
+ }
103
+ catch (e) {
104
+ return full(e instanceof Error ? e.message : String(e));
105
+ }
106
+ }
69
107
  function runGate(projectPath, command) {
70
108
  const start = Date.now();
71
109
  const r = spawnSync(command, { cwd: projectPath, shell: true, encoding: "utf8" });
@@ -77,6 +115,18 @@ function runGate(projectPath, command) {
77
115
  tail: combined.slice(-GATE_TAIL_LINES).join("\n"),
78
116
  };
79
117
  }
118
+ /**
119
+ * What the lawbook writes itself: change folders and reports, the canonical
120
+ * specs an archive promotes, the anchors it seals, and the local index. None
121
+ * of it is the agent's work, so none of it may trigger, size or re-run a ship
122
+ * — archiving used to read as new work and re-run every gate.
123
+ */
124
+ const OWN_OUTPUT = ["lawbook/changes/", "lawbook/specs/", "lawbook/anchors/", ".speclaw/"];
125
+ function isOwnOutput(file) {
126
+ return OWN_OUTPUT.some((dir) => file.startsWith(dir));
127
+ }
128
+ /** The same paths as git pathspecs, for commands that list or log the work. */
129
+ const OWN_OUTPUT_EXCLUDES = OWN_OUTPUT.map((dir) => `:(exclude)${dir.replace(/\/$/, "")}`);
80
130
  function branchFiles(projectPath) {
81
131
  if (!isGitRepo(projectPath))
82
132
  return [];
@@ -89,7 +139,7 @@ function branchFiles(projectPath) {
89
139
  ...untrackedFiles(projectPath),
90
140
  ]),
91
141
  ]
92
- .filter((f) => !f.startsWith("lawbook/changes/"))
142
+ .filter((f) => !isOwnOutput(f))
93
143
  .filter((f) => f !== COMPASS_DOC || !onlyMapChanged(projectPath, base))
94
144
  .sort();
95
145
  }
@@ -112,7 +162,7 @@ function onlyMapChanged(projectPath, base) {
112
162
  return false;
113
163
  }
114
164
  }
115
- /** New files not yet added, outside ship's own output (`lawbook/changes/`, `.speclaw/`). */
165
+ /** New files not yet added, outside the lawbook's own output ({@link OWN_OUTPUT}). */
116
166
  function untrackedFiles(projectPath) {
117
167
  return git(projectPath, [
118
168
  "-c",
@@ -122,14 +172,14 @@ function untrackedFiles(projectPath) {
122
172
  "--exclude-standard",
123
173
  ])
124
174
  .split("\n")
125
- .filter((f) => f && !f.startsWith("lawbook/changes/") && !f.startsWith(".speclaw/"));
175
+ .filter((f) => f && !isOwnOutput(f));
126
176
  }
127
177
  function secs(ms) {
128
178
  return `${(ms / 1000).toFixed(1)} s`;
129
179
  }
130
180
  function renderReport(change, discipline, files, gates, passed, date) {
131
181
  const rows = gates
132
- .map((g) => `| \`${g.command}\` | ${g.exitCode === 0 ? "pass" : `FAIL (exit ${g.exitCode})`} | ${secs(g.durationMs)} |`)
182
+ .map((g) => `| \`${g.command}\`${g.scope ? ` (${g.scope})` : ""} | ${g.exitCode === 0 ? "pass" : `FAIL (exit ${g.exitCode})`} | ${secs(g.durationMs)} |`)
133
183
  .join("\n");
134
184
  const outputs = gates
135
185
  .map((g) => `### \`${g.command}\`\n\n\`\`\`text\n${g.tail}\n\`\`\``)
@@ -205,8 +255,7 @@ function commitSummary(projectPath) {
205
255
  `${base}..HEAD`,
206
256
  "--",
207
257
  ".",
208
- ":(exclude)lawbook/changes",
209
- ":(exclude).speclaw",
258
+ ...OWN_OUTPUT_EXCLUDES,
210
259
  ])
211
260
  .split("\n")
212
261
  .filter((l) => !/^[\w-]+-by:/i.test(l))
@@ -228,11 +277,61 @@ function isVersionBump(projectPath, base, file) {
228
277
  .filter((l) => /^[+-](?![+-]{2})/.test(l));
229
278
  return lines.length > 0 && lines.every((l) => /^[+-]\s*"version"\s*:/.test(l));
230
279
  }
280
+ /** Source lines a diff may change, in one source file, and still be a level-0 fix. */
281
+ const SMALL_FIX_LINES = 10;
231
282
  /**
232
283
  * Propose a ceremony level from the branch diff itself. A release bump of the
233
- * manifests is not a global change, so it does not raise the level.
284
+ * manifests is not a global change, so it does not raise the level, and a
285
+ * small fix stays at level 0 however central the code it touches: its blast
286
+ * radius is guarded by the gates and its test, which ceremony would not add to.
234
287
  */
235
288
  function measureDiff(projectPath, files) {
289
+ const proposal = measureDiffSignals(projectPath, files);
290
+ if (!proposal.level || proposal.signals.touchesPublicApi || proposal.signals.touchesGlobalFile) {
291
+ return proposal;
292
+ }
293
+ const fix = smallFix(projectPath, files);
294
+ if (!fix)
295
+ return proposal;
296
+ return {
297
+ ...proposal,
298
+ level: 0,
299
+ rationale: `${proposal.rationale}; small fix (${fix.lines} source line(s) in ${fix.files} file) → level 0`,
300
+ };
301
+ }
302
+ /**
303
+ * The size of a diff that changes at most one source file (tests and docs
304
+ * aside) by at most {@link SMALL_FIX_LINES} lines, or null when it is larger.
305
+ */
306
+ function smallFix(projectPath, files) {
307
+ const { testGlobs } = loadAffectedConfig(projectPath);
308
+ const { thresholds } = loadCeremonyConfig(projectPath);
309
+ const source = files.filter((f) => !isTestPath(f, testGlobs) && !matchesAny(f, thresholds.docGlobs));
310
+ if (source.length > 1)
311
+ return null;
312
+ if (source.length === 0)
313
+ return { files: 0, lines: 0 };
314
+ const base = mergeBase(projectPath, "main") ?? mergeBase(projectPath, "master");
315
+ const numstat = base ? git(projectPath, ["diff", "--numstat", base, "--", source[0]]) : "";
316
+ let lines = numstat
317
+ .split("\n")
318
+ .filter(Boolean)
319
+ .reduce((n, l) => {
320
+ const [add, del] = l.split("\t");
321
+ return n + (Number(add) || 0) + (Number(del) || 0);
322
+ }, 0);
323
+ if (!numstat.trim()) {
324
+ // Untracked: every line is new.
325
+ try {
326
+ lines = fs.readFileSync(path.join(projectPath, source[0]), "utf8").split("\n").length;
327
+ }
328
+ catch {
329
+ return null;
330
+ }
331
+ }
332
+ return lines <= SMALL_FIX_LINES ? { files: 1, lines } : null;
333
+ }
334
+ function measureDiffSignals(projectPath, files) {
236
335
  const cached = cachedMeasure(projectPath, files);
237
336
  if (cached)
238
337
  return cached;
@@ -524,8 +623,15 @@ export function shipChange(projectPath, name, opts = {}) {
524
623
  const commands = opts.gates ?? cfg.gates ?? detectGates(projectPath);
525
624
  const gates = [];
526
625
  for (const command of commands) {
527
- const g = runGate(projectPath, command);
528
- gates.push(g);
626
+ const scoped = cfg.tests !== "full" && TEST_GATE_RE.test(command.trim())
627
+ ? scopedTestGate(projectPath, command, files)
628
+ : null;
629
+ if (scoped && scoped.command === null) {
630
+ gates.push({ command, exitCode: 0, durationMs: 0, tail: "", scope: scoped.scope });
631
+ continue;
632
+ }
633
+ const g = runGate(projectPath, scoped?.command ?? command);
634
+ gates.push(scoped ? { ...g, scope: scoped.scope } : g);
529
635
  if (g.exitCode !== 0)
530
636
  break;
531
637
  }
@@ -550,7 +656,7 @@ export function shipChange(projectPath, name, opts = {}) {
550
656
  }
551
657
  else if (!opts.noArchive) {
552
658
  recordGateVerdict(projectPath, name);
553
- const pre = specArchivePreconditions(projectPath, name);
659
+ const pre = specArchivePreconditions(projectPath, name, { syncing: true });
554
660
  if (pre.length)
555
661
  next.push(...pre);
556
662
  else
@@ -617,7 +723,7 @@ export function docHint(projectPath) {
617
723
  return null;
618
724
  const quick = status
619
725
  .split("\n")
620
- .filter((l) => !l.includes(" lawbook/changes/") && !l.includes(" .speclaw/"))
726
+ .filter((l) => !OWN_OUTPUT.some((dir) => l.includes(` ${dir}`)))
621
727
  .join("\n");
622
728
  const statePath = path.join(projectPath, DOC_HINT_STATE);
623
729
  let prev = {};
@@ -711,6 +817,28 @@ export function changeNameForBranch(branch) {
711
817
  .replace(/[^a-z0-9-]+/g, "-")
712
818
  .replace(/^-+|-+$/g, "") || "change");
713
819
  }
820
+ /**
821
+ * The change an action means when the agent left `change` out: the only
822
+ * active change, else the one this branch ships (same rule as the stop hook).
823
+ *
824
+ * @param projectPath - Project root.
825
+ * @returns The change name, or null when no single change fits.
826
+ */
827
+ export function resolveActiveChange(projectPath) {
828
+ const active = specList(projectPath).activeChanges;
829
+ if (active.length === 1)
830
+ return active[0];
831
+ if (active.length === 0 || !isGitRepo(projectPath))
832
+ return null;
833
+ const branch = currentBranch(projectPath);
834
+ const named = branchChange(projectPath, branch);
835
+ if (active.includes(named))
836
+ return named;
837
+ // `feat/FAR-1360-default-cost-center` ships `default-cost-center` too.
838
+ const slug = changeNameForBranch(branch);
839
+ const fits = active.filter((c) => slug.endsWith(`-${c}`) || c.endsWith(`-${slug}`));
840
+ return fits.length === 1 ? fits[0] : null;
841
+ }
714
842
  function git(projectPath, args) {
715
843
  const r = spawnSync("git", args, { cwd: projectPath, encoding: "utf8" });
716
844
  return r.status === 0 ? r.stdout : "";
@@ -748,11 +876,11 @@ function readMarker(projectPath) {
748
876
  return { branch: "", change: "", fingerprint: raw };
749
877
  }
750
878
  /**
751
- * Fingerprint of the branch's work outside `lawbook/changes/` and `.speclaw/`: every path that
879
+ * Fingerprint of the branch's work outside the lawbook's own output: every path that
752
880
  * differs from the merge base with `main`/`master` or is untracked, with its
753
881
  * working-tree contents. A file hashes the same whether it is untracked, staged
754
882
  * or committed, so committing work — new files included — is not new work; ship
755
- * writes under `lawbook/changes/`, so its own output never changes it either.
883
+ * and archive write only {@link OWN_OUTPUT}, so their output never changes it either.
756
884
  * Without a merge base it falls back to the paths changed against HEAD plus HEAD itself.
757
885
  */
758
886
  function workFingerprint(projectPath) {
@@ -765,8 +893,7 @@ function workFingerprint(projectPath) {
765
893
  base ?? "HEAD",
766
894
  "--",
767
895
  ".",
768
- ":(exclude)lawbook/changes",
769
- ":(exclude).speclaw",
896
+ ...OWN_OUTPUT_EXCLUDES,
770
897
  ]).split("\n");
771
898
  const hash = createHash("sha256");
772
899
  const files = [...new Set([...tracked, ...untrackedFiles(projectPath)])]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esneiderbravo/speclaw",
3
- "version": "2.0.22",
3
+ "version": "2.0.24",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -43,8 +43,10 @@
43
43
  "check": "prettier --check . && eslint .",
44
44
  "budget:calibrate": "node scripts/budget-calibrate.mjs",
45
45
  "pretest": "tsc -p tsconfig.test.json && node scripts/prep-test-assets.mjs",
46
- "test": "node --test --test-concurrency=1 --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'",
47
- "prepublishOnly": "npm run build"
46
+ "test": "node --test 'dist-test/test/**/*.test.js'",
47
+ "prepublishOnly": "npm run build",
48
+ "test:ci": "node --test --test-concurrency=1 --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'",
49
+ "pretest:ci": "npm run pretest"
48
50
  },
49
51
  "engines": {
50
52
  "node": ">=22.16"