@esneiderbravo/speclaw 2.0.22 → 2.0.23

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" });
@@ -129,7 +167,7 @@ function secs(ms) {
129
167
  }
130
168
  function renderReport(change, discipline, files, gates, passed, date) {
131
169
  const rows = gates
132
- .map((g) => `| \`${g.command}\` | ${g.exitCode === 0 ? "pass" : `FAIL (exit ${g.exitCode})`} | ${secs(g.durationMs)} |`)
170
+ .map((g) => `| \`${g.command}\`${g.scope ? ` (${g.scope})` : ""} | ${g.exitCode === 0 ? "pass" : `FAIL (exit ${g.exitCode})`} | ${secs(g.durationMs)} |`)
133
171
  .join("\n");
134
172
  const outputs = gates
135
173
  .map((g) => `### \`${g.command}\`\n\n\`\`\`text\n${g.tail}\n\`\`\``)
@@ -228,11 +266,61 @@ function isVersionBump(projectPath, base, file) {
228
266
  .filter((l) => /^[+-](?![+-]{2})/.test(l));
229
267
  return lines.length > 0 && lines.every((l) => /^[+-]\s*"version"\s*:/.test(l));
230
268
  }
269
+ /** Source lines a diff may change, in one source file, and still be a level-0 fix. */
270
+ const SMALL_FIX_LINES = 10;
231
271
  /**
232
272
  * 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.
273
+ * manifests is not a global change, so it does not raise the level, and a
274
+ * small fix stays at level 0 however central the code it touches: its blast
275
+ * radius is guarded by the gates and its test, which ceremony would not add to.
234
276
  */
235
277
  function measureDiff(projectPath, files) {
278
+ const proposal = measureDiffSignals(projectPath, files);
279
+ if (!proposal.level || proposal.signals.touchesPublicApi || proposal.signals.touchesGlobalFile) {
280
+ return proposal;
281
+ }
282
+ const fix = smallFix(projectPath, files);
283
+ if (!fix)
284
+ return proposal;
285
+ return {
286
+ ...proposal,
287
+ level: 0,
288
+ rationale: `${proposal.rationale}; small fix (${fix.lines} source line(s) in ${fix.files} file) → level 0`,
289
+ };
290
+ }
291
+ /**
292
+ * The size of a diff that changes at most one source file (tests and docs
293
+ * aside) by at most {@link SMALL_FIX_LINES} lines, or null when it is larger.
294
+ */
295
+ function smallFix(projectPath, files) {
296
+ const { testGlobs } = loadAffectedConfig(projectPath);
297
+ const { thresholds } = loadCeremonyConfig(projectPath);
298
+ const source = files.filter((f) => !isTestPath(f, testGlobs) && !matchesAny(f, thresholds.docGlobs));
299
+ if (source.length > 1)
300
+ return null;
301
+ if (source.length === 0)
302
+ return { files: 0, lines: 0 };
303
+ const base = mergeBase(projectPath, "main") ?? mergeBase(projectPath, "master");
304
+ const numstat = base ? git(projectPath, ["diff", "--numstat", base, "--", source[0]]) : "";
305
+ let lines = numstat
306
+ .split("\n")
307
+ .filter(Boolean)
308
+ .reduce((n, l) => {
309
+ const [add, del] = l.split("\t");
310
+ return n + (Number(add) || 0) + (Number(del) || 0);
311
+ }, 0);
312
+ if (!numstat.trim()) {
313
+ // Untracked: every line is new.
314
+ try {
315
+ lines = fs.readFileSync(path.join(projectPath, source[0]), "utf8").split("\n").length;
316
+ }
317
+ catch {
318
+ return null;
319
+ }
320
+ }
321
+ return lines <= SMALL_FIX_LINES ? { files: 1, lines } : null;
322
+ }
323
+ function measureDiffSignals(projectPath, files) {
236
324
  const cached = cachedMeasure(projectPath, files);
237
325
  if (cached)
238
326
  return cached;
@@ -524,8 +612,15 @@ export function shipChange(projectPath, name, opts = {}) {
524
612
  const commands = opts.gates ?? cfg.gates ?? detectGates(projectPath);
525
613
  const gates = [];
526
614
  for (const command of commands) {
527
- const g = runGate(projectPath, command);
528
- gates.push(g);
615
+ const scoped = cfg.tests !== "full" && TEST_GATE_RE.test(command.trim())
616
+ ? scopedTestGate(projectPath, command, files)
617
+ : null;
618
+ if (scoped && scoped.command === null) {
619
+ gates.push({ command, exitCode: 0, durationMs: 0, tail: "", scope: scoped.scope });
620
+ continue;
621
+ }
622
+ const g = runGate(projectPath, scoped?.command ?? command);
623
+ gates.push(scoped ? { ...g, scope: scoped.scope } : g);
529
624
  if (g.exitCode !== 0)
530
625
  break;
531
626
  }
@@ -550,7 +645,7 @@ export function shipChange(projectPath, name, opts = {}) {
550
645
  }
551
646
  else if (!opts.noArchive) {
552
647
  recordGateVerdict(projectPath, name);
553
- const pre = specArchivePreconditions(projectPath, name);
648
+ const pre = specArchivePreconditions(projectPath, name, { syncing: true });
554
649
  if (pre.length)
555
650
  next.push(...pre);
556
651
  else
@@ -711,6 +806,28 @@ export function changeNameForBranch(branch) {
711
806
  .replace(/[^a-z0-9-]+/g, "-")
712
807
  .replace(/^-+|-+$/g, "") || "change");
713
808
  }
809
+ /**
810
+ * The change an action means when the agent left `change` out: the only
811
+ * active change, else the one this branch ships (same rule as the stop hook).
812
+ *
813
+ * @param projectPath - Project root.
814
+ * @returns The change name, or null when no single change fits.
815
+ */
816
+ export function resolveActiveChange(projectPath) {
817
+ const active = specList(projectPath).activeChanges;
818
+ if (active.length === 1)
819
+ return active[0];
820
+ if (active.length === 0 || !isGitRepo(projectPath))
821
+ return null;
822
+ const branch = currentBranch(projectPath);
823
+ const named = branchChange(projectPath, branch);
824
+ if (active.includes(named))
825
+ return named;
826
+ // `feat/FAR-1360-default-cost-center` ships `default-cost-center` too.
827
+ const slug = changeNameForBranch(branch);
828
+ const fits = active.filter((c) => slug.endsWith(`-${c}`) || c.endsWith(`-${slug}`));
829
+ return fits.length === 1 ? fits[0] : null;
830
+ }
714
831
  function git(projectPath, args) {
715
832
  const r = spawnSync("git", args, { cwd: projectPath, encoding: "utf8" });
716
833
  return r.status === 0 ? r.stdout : "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esneiderbravo/speclaw",
3
- "version": "2.0.22",
3
+ "version": "2.0.23",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },