@esneiderbravo/speclaw 2.0.16 → 2.0.17

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.
@@ -238,7 +238,7 @@ export function shipChange(projectPath, name, opts = {}) {
238
238
  archivedTo = specArchive(projectPath, name, date).archivedTo;
239
239
  }
240
240
  if (gatesPassed && isGitRepo(projectPath))
241
- markShipped(projectPath);
241
+ markShipped(projectPath, name);
242
242
  const t4 = Date.now();
243
243
  const gatesMs = t2 - t1;
244
244
  return {
@@ -278,32 +278,69 @@ function git(projectPath, args) {
278
278
  const r = spawnSync("git", args, { cwd: projectPath, encoding: "utf8" });
279
279
  return r.status === 0 ? r.stdout : "";
280
280
  }
281
- /** Fingerprint of the last shipped work; a manual ship and the hook share it. */
281
+ /** The last shipped work; a manual ship and the hook share it. */
282
282
  const SHIP_MARKER = path.join(".speclaw", "ship-last");
283
- function markShipped(projectPath) {
283
+ function currentBranch(projectPath) {
284
+ return git(projectPath, ["rev-parse", "--abbrev-ref", "HEAD"]).trim();
285
+ }
286
+ function markShipped(projectPath, change) {
284
287
  const marker = path.join(projectPath, SHIP_MARKER);
285
288
  fs.mkdirSync(path.dirname(marker), { recursive: true });
286
- fs.writeFileSync(marker, workFingerprint(projectPath));
289
+ const body = {
290
+ branch: currentBranch(projectPath),
291
+ change,
292
+ fingerprint: workFingerprint(projectPath),
293
+ };
294
+ fs.writeFileSync(marker, JSON.stringify(body) + "\n");
295
+ }
296
+ /** The last ship marker, or null; a pre-2.0.17 marker (a bare fingerprint) has no change name. */
297
+ function readMarker(projectPath) {
298
+ const marker = path.join(projectPath, SHIP_MARKER);
299
+ if (!fs.existsSync(marker))
300
+ return null;
301
+ const raw = fs.readFileSync(marker, "utf8").trim();
302
+ try {
303
+ const m = JSON.parse(raw);
304
+ if (typeof m.fingerprint === "string") {
305
+ return { branch: m.branch ?? "", change: m.change ?? "", fingerprint: m.fingerprint };
306
+ }
307
+ }
308
+ catch {
309
+ // Legacy marker: the fingerprint alone.
310
+ }
311
+ return { branch: "", change: "", fingerprint: raw };
287
312
  }
288
313
  /**
289
- * Fingerprint of the work outside `lawbook/changes/`: the diff against HEAD
290
- * plus untracked file names. Ship writes under `lawbook/changes/`, so its own
291
- * output never changes the fingerprint.
314
+ * Fingerprint of the branch's work outside `lawbook/changes/`: the working tree
315
+ * diffed against the merge base with `main`/`master`, plus untracked files
316
+ * (names and contents). Committing that work leaves it unchanged — a commit is not new work —
317
+ * and ship writes under `lawbook/changes/`, so its own output never changes it.
318
+ * Without a merge base it falls back to the diff against HEAD plus HEAD itself.
292
319
  */
293
320
  function workFingerprint(projectPath) {
294
- const diff = git(projectPath, ["diff", "HEAD", "--", ".", ":(exclude)lawbook/changes"]);
295
- const untracked = git(projectPath, ["ls-files", "--others", "--exclude-standard"])
296
- .split("\n")
297
- .filter((f) => f && !f.startsWith("lawbook/changes/") && !f.startsWith(".speclaw/"))
298
- .join("\n");
299
- const head = git(projectPath, ["rev-parse", "HEAD"]).trim();
300
- return createHash("sha256").update(`${head}\n${diff}\n${untracked}`).digest("hex");
321
+ const base = mergeBase(projectPath, "main") ?? mergeBase(projectPath, "master");
322
+ const diff = git(projectPath, ["diff", base ?? "HEAD", "--", ".", ":(exclude)lawbook/changes"]);
323
+ const hash = createHash("sha256");
324
+ for (const f of git(projectPath, ["ls-files", "--others", "--exclude-standard"]).split("\n")) {
325
+ if (!f || f.startsWith("lawbook/changes/") || f.startsWith(".speclaw/"))
326
+ continue;
327
+ // Contents, not just names: editing a new, not-yet-added file is new work.
328
+ hash.update(`${f}\n`);
329
+ try {
330
+ hash.update(fs.readFileSync(path.join(projectPath, f)));
331
+ }
332
+ catch {
333
+ // Vanished or unreadable since listing: its name stands in.
334
+ }
335
+ }
336
+ const anchor = base ?? git(projectPath, ["rev-parse", "HEAD"]).trim();
337
+ return hash.update(`\n${anchor}\n${diff}`).digest("hex");
301
338
  }
302
339
  /**
303
340
  * The `Stop` hook body: when the agent ends a turn on a feature branch with
304
341
  * work that changed since the last ship, run {@link shipChange} for the change
305
- * named after the branch. Skips in milliseconds otherwise, so a turn that only
306
- * answered a question costs nothing.
342
+ * last shipped on this branch, else the one named after the branch. Skips in
343
+ * milliseconds otherwise, so a turn that only answered a question costs nothing.
307
344
  *
308
345
  * @param projectPath - Project root.
309
346
  * @returns Why it skipped, or the ship result.
@@ -311,15 +348,19 @@ function workFingerprint(projectPath) {
311
348
  export function shipOnStop(projectPath) {
312
349
  if (!isGitRepo(projectPath))
313
350
  return { skipped: "not-git" };
314
- const branch = git(projectPath, ["rev-parse", "--abbrev-ref", "HEAD"]).trim();
351
+ const branch = currentBranch(projectPath);
315
352
  if (!branch || ["main", "master", "HEAD"].includes(branch))
316
353
  return { skipped: "base-branch" };
317
354
  if (branchFiles(projectPath).length === 0)
318
355
  return { skipped: "no-changes" };
319
- const marker = path.join(projectPath, SHIP_MARKER);
320
- if (fs.existsSync(marker) && fs.readFileSync(marker, "utf8") === workFingerprint(projectPath)) {
356
+ const marker = readMarker(projectPath);
357
+ if (marker?.fingerprint === workFingerprint(projectPath)) {
321
358
  return { skipped: "unchanged-since-last-ship" };
322
359
  }
323
- const change = changeNameForBranch(branch);
360
+ // A change shipped by name on this branch keeps receiving the branch's later
361
+ // work; the branch name would point at another (possibly archived) change.
362
+ const change = marker && marker.branch === branch && marker.change
363
+ ? marker.change
364
+ : changeNameForBranch(branch);
324
365
  return { skipped: null, change, result: shipChange(projectPath, change) };
325
366
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esneiderbravo/speclaw",
3
- "version": "2.0.16",
3
+ "version": "2.0.17",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },