gsd-pi 2.4.0 → 2.5.0

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/README.md CHANGED
@@ -252,17 +252,18 @@ Branch-per-slice with squash merge. Fully automated.
252
252
 
253
253
  ```
254
254
  main:
255
- feat(M001/S03): auth and session management
255
+ docs(M001/S04): workflow documentation and examples
256
+ fix(M001/S03): bug fixes and doc corrections
256
257
  feat(M001/S02): API endpoints and middleware
257
258
  feat(M001/S01): data model and type system
258
259
 
259
- gsd/M001/S01 (preserved):
260
+ gsd/M001/S01 (deleted after merge):
260
261
  feat(S01/T03): file writer with round-trip fidelity
261
262
  feat(S01/T02): markdown parser for plan files
262
263
  feat(S01/T01): core types and interfaces
263
264
  ```
264
265
 
265
- One commit per slice on main. Per-task history preserved on branches. Git bisect works. Individual slices are revertable.
266
+ One commit per slice on main. Squash commits are the permanent record — branches are deleted after merge. Git bisect works. Individual slices are revertable.
266
267
 
267
268
  ### Verification
268
269
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd-pi",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "GSD — Get Shit Done coding agent",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,7 +35,7 @@
35
35
  "scripts": {
36
36
  "build": "tsc && npm run copy-themes",
37
37
  "copy-themes": "node -e \"const{mkdirSync,cpSync}=require('fs');const{resolve}=require('path');const src=resolve(__dirname,'node_modules/@mariozechner/pi-coding-agent/dist/modes/interactive/theme');mkdirSync('pkg/dist/modes/interactive/theme',{recursive:true});cpSync(src,'pkg/dist/modes/interactive/theme',{recursive:true})\"",
38
- "test": "node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test 'src/resources/extensions/gsd/tests/*.test.ts' 'src/resources/extensions/gsd/tests/*.test.mjs' 'src/tests/*.test.ts'",
38
+ "test": "node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/*.test.ts src/resources/extensions/gsd/tests/*.test.mjs src/tests/*.test.ts",
39
39
  "dev": "tsc --watch",
40
40
  "postinstall": "node scripts/postinstall.js",
41
41
  "pi:install-global": "node scripts/install-pi-global.js",
@@ -548,7 +548,7 @@ If files disagree, **pause and surface to the user**:
548
548
  1. **Slice starts** → create branch `gsd/M001/S01` from main
549
549
  2. **Per-task commits** on the branch — atomic, descriptive, bisectable
550
550
  3. **Slice completes** → squash merge to main as one clean commit
551
- 4. **Branch kept** — not deleted, available for per-task history
551
+ 4. **Branch deleted** — squash commit on main is the permanent record
552
552
 
553
553
  ### What Main Looks Like
554
554
 
@@ -566,21 +566,21 @@ One commit per slice. Individually revertable. Reads like a changelog.
566
566
  gsd/M001/S01:
567
567
  test(S01): round-trip tests passing
568
568
  feat(S01/T03): file writer with round-trip fidelity
569
- checkpoint(S01/T03): pre-task
569
+ chore(S01/T03): auto-commit after task
570
570
  feat(S01/T02): markdown parser for plan files
571
- checkpoint(S01/T02): pre-task
571
+ chore(S01/T02): auto-commit after task
572
572
  feat(S01/T01): core types and interfaces
573
- checkpoint(S01/T01): pre-task
573
+ chore(S01/T01): auto-commit after task
574
574
  ```
575
575
 
576
576
  ### Commit Conventions
577
577
 
578
578
  | When | Format | Example |
579
579
  |------|--------|---------|
580
- | Before each task | `checkpoint(S01/T02): pre-task` | Safety net for `git reset` |
580
+ | Auto-commit (dirty state) | `chore(S01/T02): auto-commit after task` | Automatic save of work in progress |
581
581
  | After task verified | `feat(S01/T02): <what was built>` | The real work |
582
582
  | Plan/docs committed | `docs(S01): add slice plan` | Bundled with first task |
583
- | Slice squash to main | `feat(M001/S01): <slice title>` | Clean one-liner on main |
583
+ | Slice squash to main | `type(M001/S01): <slice title>` | Type inferred from title (`feat`, `fix`, `docs`, etc.) |
584
584
 
585
585
  Commit types: `feat`, `fix`, `test`, `refactor`, `docs`, `chore`
586
586
 
@@ -601,7 +601,7 @@ Tasks completed:
601
601
 
602
602
  | Problem | Fix |
603
603
  |---------|-----|
604
- | Bad task | `git reset --hard` to checkpoint on the branch |
604
+ | Bad task | `git reset --hard HEAD~1` to previous commit on the branch |
605
605
  | Bad slice | `git revert <squash commit>` on main |
606
606
  | UAT failure after merge | Fix tasks on `gsd/M001/S01-fix` branch, squash as `fix(M001/S01): <fix>` |
607
607
 
@@ -62,11 +62,12 @@ import {
62
62
  ensureSliceBranch,
63
63
  getCurrentBranch,
64
64
  getMainBranch,
65
- getSliceBranchName,
66
65
  parseSliceBranch,
67
66
  switchToMain,
68
67
  mergeSliceToMain,
69
68
  } from "./worktree.ts";
69
+ import { GitServiceImpl } from "./git-service.ts";
70
+ import type { GitPreferences } from "./git-service.ts";
70
71
  import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
71
72
  import { makeUI, GLYPH, INDENT } from "../shared/ui.js";
72
73
  import { showNextAction } from "../shared/next-action-ui.js";
@@ -93,6 +94,18 @@ function persistCompletedKey(base: string, key: string): void {
93
94
  }
94
95
  }
95
96
 
97
+ /** Remove a stale completed unit key from disk. */
98
+ function removePersistedKey(base: string, key: string): void {
99
+ const file = completedKeysPath(base);
100
+ try {
101
+ if (existsSync(file)) {
102
+ let keys: string[] = JSON.parse(readFileSync(file, "utf-8"));
103
+ keys = keys.filter(k => k !== key);
104
+ writeFileSync(file, JSON.stringify(keys), "utf-8");
105
+ }
106
+ } catch { /* non-fatal */ }
107
+ }
108
+
96
109
  /** Load all completed unit keys from disk into the in-memory set. */
97
110
  function loadPersistedKeys(base: string, target: Set<string>): void {
98
111
  const file = completedKeysPath(base);
@@ -112,6 +125,7 @@ let stepMode = false;
112
125
  let verbose = false;
113
126
  let cmdCtx: ExtensionCommandContext | null = null;
114
127
  let basePath = "";
128
+ let gitService: GitServiceImpl | null = null;
115
129
 
116
130
  /** Track total dispatches per unit to detect stuck loops (catches A→B→A→B patterns) */
117
131
  const unitDispatchCount = new Map<string, number>();
@@ -376,6 +390,9 @@ export async function startAuto(
376
390
  } catch { /* nothing to commit */ }
377
391
  }
378
392
 
393
+ // Initialize GitServiceImpl — basePath is set and git repo confirmed
394
+ gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
395
+
379
396
  // Check for crash from previous session
380
397
  const crashLock = readCrashLock(base);
381
398
  if (crashLock) {
@@ -1190,15 +1207,27 @@ async function dispatchNextUnit(
1190
1207
  // Idempotency: skip units already completed in a prior session.
1191
1208
  const idempotencyKey = `${unitType}/${unitId}`;
1192
1209
  if (completedKeySet.has(idempotencyKey)) {
1193
- ctx.ui.notify(
1194
- `Skipping ${unitType} ${unitId} — already completed in a prior session. Advancing.`,
1195
- "info",
1196
- );
1197
- // Yield to the event loop before re-dispatching to avoid tight recursion
1198
- // when many units are already completed (e.g., after crash recovery).
1199
- await new Promise(r => setImmediate(r));
1200
- await dispatchNextUnit(ctx, pi);
1201
- return;
1210
+ // Cross-validate: does the expected artifact actually exist?
1211
+ const artifactExists = verifyExpectedArtifact(unitType, unitId, basePath);
1212
+ if (artifactExists) {
1213
+ ctx.ui.notify(
1214
+ `Skipping ${unitType} ${unitId} already completed in a prior session. Advancing.`,
1215
+ "info",
1216
+ );
1217
+ // Yield to the event loop before re-dispatching to avoid tight recursion
1218
+ // when many units are already completed (e.g., after crash recovery).
1219
+ await new Promise(r => setImmediate(r));
1220
+ await dispatchNextUnit(ctx, pi);
1221
+ return;
1222
+ } else {
1223
+ // Stale completion record — artifact missing. Remove and re-run.
1224
+ completedKeySet.delete(idempotencyKey);
1225
+ removePersistedKey(basePath, idempotencyKey);
1226
+ ctx.ui.notify(
1227
+ `Re-running ${unitType} ${unitId} — marked complete but expected artifact missing.`,
1228
+ "warning",
1229
+ );
1230
+ }
1202
1231
  }
1203
1232
 
1204
1233
  // Stuck detection — tracks total dispatches per unit (not just consecutive repeats).
@@ -1234,20 +1263,26 @@ async function dispatchNextUnit(
1234
1263
  snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1235
1264
  saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1236
1265
 
1237
- // Persist completion to disk BEFORE updating memory — so a crash here is recoverable.
1266
+ // Only mark the previous unit as completed if:
1267
+ // 1. We're not about to re-dispatch the same unit (retry scenario)
1268
+ // 2. The expected artifact actually exists on disk
1238
1269
  const closeoutKey = `${currentUnit.type}/${currentUnit.id}`;
1239
- persistCompletedKey(basePath, closeoutKey);
1240
- completedKeySet.add(closeoutKey);
1241
-
1242
- completedUnits.push({
1243
- type: currentUnit.type,
1244
- id: currentUnit.id,
1245
- startedAt: currentUnit.startedAt,
1246
- finishedAt: Date.now(),
1247
- });
1248
- clearUnitRuntimeRecord(basePath, currentUnit.type, currentUnit.id);
1249
- unitDispatchCount.delete(`${currentUnit.type}/${currentUnit.id}`);
1250
- unitRecoveryCount.delete(`${currentUnit.type}/${currentUnit.id}`);
1270
+ const incomingKey = `${unitType}/${unitId}`;
1271
+ const artifactVerified = verifyExpectedArtifact(currentUnit.type, currentUnit.id, basePath);
1272
+ if (closeoutKey !== incomingKey && artifactVerified) {
1273
+ persistCompletedKey(basePath, closeoutKey);
1274
+ completedKeySet.add(closeoutKey);
1275
+
1276
+ completedUnits.push({
1277
+ type: currentUnit.type,
1278
+ id: currentUnit.id,
1279
+ startedAt: currentUnit.startedAt,
1280
+ finishedAt: Date.now(),
1281
+ });
1282
+ clearUnitRuntimeRecord(basePath, currentUnit.type, currentUnit.id);
1283
+ unitDispatchCount.delete(`${currentUnit.type}/${currentUnit.id}`);
1284
+ unitRecoveryCount.delete(`${currentUnit.type}/${currentUnit.id}`);
1285
+ }
1251
1286
  }
1252
1287
  currentUnit = { type: unitType, id: unitId, startedAt: Date.now() };
1253
1288
  writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
@@ -2587,6 +2622,15 @@ export function resolveExpectedArtifactPath(unitType: string, unitId: string, ba
2587
2622
  const dir = resolveSlicePath(base, mid, sid!);
2588
2623
  return dir ? join(dir, buildSliceFileName(sid!, "UAT-RESULT")) : null;
2589
2624
  }
2625
+ case "execute-task": {
2626
+ const tid = parts[2];
2627
+ const dir = resolveSlicePath(base, mid, sid!);
2628
+ return dir && tid ? join(dir, "tasks", buildTaskFileName(tid, "SUMMARY")) : null;
2629
+ }
2630
+ case "complete-slice": {
2631
+ const dir = resolveSlicePath(base, mid, sid!);
2632
+ return dir ? join(dir, buildSliceFileName(sid!, "SUMMARY")) : null;
2633
+ }
2590
2634
  case "complete-milestone": {
2591
2635
  const dir = resolveMilestonePath(base, mid);
2592
2636
  return dir ? join(dir, buildMilestoneFileName(mid, "SUMMARY")) : null;
@@ -2596,6 +2640,17 @@ export function resolveExpectedArtifactPath(unitType: string, unitId: string, ba
2596
2640
  }
2597
2641
  }
2598
2642
 
2643
+ /**
2644
+ * Check whether the expected artifact for a unit exists on disk.
2645
+ * Returns true if the artifact file exists, or if the unit type has no
2646
+ * single verifiable artifact (e.g., replan-slice).
2647
+ */
2648
+ function verifyExpectedArtifact(unitType: string, unitId: string, base: string): boolean {
2649
+ const absPath = resolveExpectedArtifactPath(unitType, unitId, base);
2650
+ if (!absPath) return true;
2651
+ return existsSync(absPath);
2652
+ }
2653
+
2599
2654
  /**
2600
2655
  * Write a placeholder artifact so the pipeline can advance past a stuck unit.
2601
2656
  * Returns the relative path written, or null if the path couldn't be resolved.
@@ -40,6 +40,14 @@ Full documentation for `~/.gsd/preferences.md` (global) and `.gsd/preferences.md
40
40
  - `idle_timeout_minutes`: minutes of inactivity before the supervisor intervenes (default: 10).
41
41
  - `hard_timeout_minutes`: minutes before the supervisor forces termination (default: 30).
42
42
 
43
+ - `git`: configures GSD's git behavior. All fields are optional — omit any to use defaults. Keys:
44
+ - `auto_push`: boolean — automatically push commits to the remote after committing. Default: `false`.
45
+ - `push_branches`: boolean — push newly created slice branches to the remote. Default: `false`.
46
+ - `remote`: string — git remote name to push to. Default: `"origin"`.
47
+ - `snapshots`: boolean — create snapshot commits (WIP saves) during long-running tasks. Default: `false`.
48
+ - `pre_merge_check`: boolean or `"auto"` — run pre-merge checks before merging a slice branch. `true` always runs, `false` never runs, `"auto"` runs when CI is detected. Default: `false`.
49
+ - `commit_type`: string — override the conventional commit type prefix. Must be one of: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, `ci`, `build`, `style`. Default: inferred from diff content.
50
+
43
51
  ---
44
52
 
45
53
  ## Best Practices
@@ -101,3 +109,22 @@ skill_rules:
101
109
  - find-skills
102
110
  ---
103
111
  ```
112
+
113
+ ---
114
+
115
+ ## Git Preferences Example
116
+
117
+ ```yaml
118
+ ---
119
+ version: 1
120
+ git:
121
+ auto_push: true
122
+ push_branches: true
123
+ remote: origin
124
+ snapshots: true
125
+ pre_merge_check: auto
126
+ commit_type: feat
127
+ ---
128
+ ```
129
+
130
+ All git fields are optional. Omit any field to use the default behavior. Project-level preferences override global preferences on a per-field basis.
@@ -9,7 +9,8 @@
9
9
  */
10
10
 
11
11
  import { execSync } from "node:child_process";
12
- import { sep } from "node:path";
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { join, sep } from "node:path";
13
14
 
14
15
  import {
15
16
  detectWorktreeName,
@@ -39,6 +40,13 @@ export interface MergeSliceResult {
39
40
  deletedBranch: boolean;
40
41
  }
41
42
 
43
+ export interface PreMergeCheckResult {
44
+ passed: boolean;
45
+ skipped?: boolean;
46
+ command?: string;
47
+ error?: string;
48
+ }
49
+
42
50
  // ─── Constants ─────────────────────────────────────────────────────────────
43
51
 
44
52
  /**
@@ -61,13 +69,15 @@ export const RUNTIME_EXCLUSION_PATHS: readonly string[] = [
61
69
  /**
62
70
  * Run a git command in the given directory.
63
71
  * Returns trimmed stdout. Throws on non-zero exit unless allowFailure is set.
72
+ * When `input` is provided, it is piped to stdin.
64
73
  */
65
- export function runGit(basePath: string, args: string[], options: { allowFailure?: boolean } = {}): string {
74
+ export function runGit(basePath: string, args: string[], options: { allowFailure?: boolean; input?: string } = {}): string {
66
75
  try {
67
76
  return execSync(`git ${args.join(" ")}`, {
68
77
  cwd: basePath,
69
- stdio: ["ignore", "pipe", "pipe"],
78
+ stdio: [options.input != null ? "pipe" : "ignore", "pipe", "pipe"],
70
79
  encoding: "utf-8",
80
+ ...(options.input != null ? { input: options.input } : {}),
71
81
  }).trim();
72
82
  } catch (error) {
73
83
  if (options.allowFailure) return "";
@@ -107,7 +117,7 @@ export class GitServiceImpl {
107
117
  }
108
118
 
109
119
  /** Convenience wrapper: run git in this repo's basePath. */
110
- private git(args: string[], options: { allowFailure?: boolean } = {}): string {
120
+ private git(args: string[], options: { allowFailure?: boolean; input?: string } = {}): string {
111
121
  return runGit(this.basePath, args, options);
112
122
  }
113
123
 
@@ -129,6 +139,7 @@ export class GitServiceImpl {
129
139
  /**
130
140
  * Stage files (smart staging) and commit.
131
141
  * Returns the commit message string on success, or null if nothing to commit.
142
+ * Uses `git commit -F -` with stdin pipe for safe multi-line message handling.
132
143
  */
133
144
  commit(opts: CommitOptions): string | null {
134
145
  this.smartStage();
@@ -137,7 +148,10 @@ export class GitServiceImpl {
137
148
  const staged = this.git(["diff", "--cached", "--stat"], { allowFailure: true });
138
149
  if (!staged && !opts.allowEmpty) return null;
139
150
 
140
- this.git(["commit", "-m", JSON.stringify(opts.message), ...(opts.allowEmpty ? ["--allow-empty"] : [])]);
151
+ this.git(
152
+ ["commit", "-F", "-", ...(opts.allowEmpty ? ["--allow-empty"] : [])],
153
+ { input: opts.message },
154
+ );
141
155
  return opts.message;
142
156
  }
143
157
 
@@ -158,7 +172,7 @@ export class GitServiceImpl {
158
172
  if (!staged) return null;
159
173
 
160
174
  const message = `chore(${unitId}): auto-commit after ${unitType}`;
161
- this.git(["commit", "-m", JSON.stringify(message)]);
175
+ this.git(["commit", "-F", "-"], { input: message });
162
176
  return message;
163
177
  }
164
178
 
@@ -235,6 +249,9 @@ export class GitServiceImpl {
235
249
  * branch (preserves planning artifacts). Falls back to main when on another
236
250
  * slice branch (avoids chaining slice branches).
237
251
  *
252
+ * When creating a new branch, fetches from remote first (best-effort) to
253
+ * ensure the local main is up-to-date.
254
+ *
238
255
  * Auto-commits dirty state via smart staging before checkout so runtime
239
256
  * files are never accidentally committed during branch switches.
240
257
  *
@@ -250,6 +267,24 @@ export class GitServiceImpl {
250
267
  let created = false;
251
268
 
252
269
  if (!this.branchExists(branch)) {
270
+ // Fetch from remote before creating a new branch (best-effort).
271
+ const remotes = this.git(["remote"], { allowFailure: true });
272
+ if (remotes) {
273
+ const remote = this.prefs.remote ?? "origin";
274
+ const fetchResult = this.git(["fetch", "--prune", remote], { allowFailure: true });
275
+ // fetchResult is empty string on both success and allowFailure-caught error.
276
+ // Check if local is behind upstream (informational only).
277
+ if (remotes.split("\n").includes(remote)) {
278
+ const behind = this.git(
279
+ ["rev-list", "--count", "HEAD..@{upstream}"],
280
+ { allowFailure: true },
281
+ );
282
+ if (behind && parseInt(behind, 10) > 0) {
283
+ console.error(`GitService: local branch is ${behind} commit(s) behind upstream`);
284
+ }
285
+ }
286
+ }
287
+
253
288
  // Branch from current when it's a normal working branch (not a slice).
254
289
  // If already on a slice branch, fall back to main to avoid chaining.
255
290
  const mainBranch = this.getMainBranch();
@@ -287,11 +322,170 @@ export class GitServiceImpl {
287
322
  this.git(["checkout", mainBranch]);
288
323
  }
289
324
 
325
+ // ─── S05 Features ─────────────────────────────────────────────────────
326
+
327
+ /**
328
+ * Create a snapshot ref for the given label (typically a slice branch name).
329
+ * Gated on prefs.snapshots === true. Ref path: refs/gsd/snapshots/<label>/<timestamp>
330
+ * The ref points at HEAD, capturing the current commit before destructive operations.
331
+ */
332
+ createSnapshot(label: string): void {
333
+ if (this.prefs.snapshots !== true) return;
334
+
335
+ const now = new Date();
336
+ const ts = now.getFullYear().toString()
337
+ + String(now.getMonth() + 1).padStart(2, "0")
338
+ + String(now.getDate()).padStart(2, "0")
339
+ + "-"
340
+ + String(now.getHours()).padStart(2, "0")
341
+ + String(now.getMinutes()).padStart(2, "0")
342
+ + String(now.getSeconds()).padStart(2, "0");
343
+
344
+ const refPath = `refs/gsd/snapshots/${label}/${ts}`;
345
+ this.git(["update-ref", refPath, "HEAD"]);
346
+ }
347
+
348
+ /**
349
+ * Run pre-merge verification check. Auto-detects test runner from project
350
+ * files, or uses custom command from prefs.pre_merge_check.
351
+ *
352
+ * Gating:
353
+ * - `false` → skip (return passed:true, skipped:true)
354
+ * - non-empty string (not "auto") → use as custom command
355
+ * - `true`, `"auto"`, or `undefined` → auto-detect from project files
356
+ *
357
+ * Auto-detection order:
358
+ * package.json scripts.test → npm test
359
+ * package.json scripts.build (only if no test) → npm run build
360
+ * Cargo.toml → cargo test
361
+ * Makefile with test: target → make test
362
+ * pyproject.toml → python -m pytest
363
+ *
364
+ * If no runner detected in auto mode, returns passed:true (don't block).
365
+ */
366
+ runPreMergeCheck(): PreMergeCheckResult {
367
+ const pref = this.prefs.pre_merge_check;
368
+
369
+ // Explicitly disabled
370
+ if (pref === false) {
371
+ return { passed: true, skipped: true };
372
+ }
373
+
374
+ let command: string | null = null;
375
+
376
+ // Custom string command (not "auto")
377
+ if (typeof pref === "string" && pref !== "auto" && pref.trim() !== "") {
378
+ command = pref.trim();
379
+ }
380
+
381
+ // Auto-detect (true, "auto", or undefined)
382
+ if (command === null) {
383
+ command = this.detectTestRunner();
384
+ }
385
+
386
+ if (command === null) {
387
+ return { passed: true, command: "none", error: "no test runner detected" };
388
+ }
389
+
390
+ // Execute the command
391
+ try {
392
+ execSync(command, {
393
+ cwd: this.basePath,
394
+ timeout: 300_000,
395
+ stdio: ["ignore", "pipe", "pipe"],
396
+ encoding: "utf-8",
397
+ });
398
+ return { passed: true, command };
399
+ } catch (err) {
400
+ const stderr = err instanceof Error && "stderr" in err
401
+ ? String((err as { stderr: unknown }).stderr).slice(0, 2000)
402
+ : String(err).slice(0, 2000);
403
+ return { passed: false, command, error: stderr };
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Detect a test/build runner from project files in basePath.
409
+ * Returns the command string or null if nothing detected.
410
+ */
411
+ private detectTestRunner(): string | null {
412
+ const pkgPath = join(this.basePath, "package.json");
413
+ if (existsSync(pkgPath)) {
414
+ try {
415
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
416
+ if (pkg?.scripts?.test) return "npm test";
417
+ if (pkg?.scripts?.build) return "npm run build";
418
+ } catch { /* invalid JSON — skip */ }
419
+ }
420
+
421
+ if (existsSync(join(this.basePath, "Cargo.toml"))) {
422
+ return "cargo test";
423
+ }
424
+
425
+ const makefilePath = join(this.basePath, "Makefile");
426
+ if (existsSync(makefilePath)) {
427
+ try {
428
+ const content = readFileSync(makefilePath, "utf-8");
429
+ if (/^test\s*:/m.test(content)) return "make test";
430
+ } catch { /* skip */ }
431
+ }
432
+
433
+ if (existsSync(join(this.basePath, "pyproject.toml"))) {
434
+ return "python -m pytest";
435
+ }
436
+
437
+ return null;
438
+ }
439
+
290
440
  // ─── Merge ─────────────────────────────────────────────────────────────
291
441
 
442
+ /**
443
+ * Build a rich squash-commit message with a task list from branch commits.
444
+ *
445
+ * Format:
446
+ * type(scope): title
447
+ *
448
+ * Tasks:
449
+ * - commit subject 1
450
+ * - commit subject 2
451
+ *
452
+ * Branch: gsd/M001/S01
453
+ */
454
+ private buildRichCommitMessage(
455
+ commitType: string,
456
+ milestoneId: string,
457
+ sliceId: string,
458
+ sliceTitle: string,
459
+ mainBranch: string,
460
+ branch: string,
461
+ ): string {
462
+ const subject = `${commitType}(${milestoneId}/${sliceId}): ${sliceTitle}`;
463
+
464
+ // Collect branch commit subjects
465
+ const logOutput = this.git(
466
+ ["log", "--oneline", "--format=%s", `${mainBranch}..${branch}`],
467
+ { allowFailure: true },
468
+ );
469
+
470
+ if (!logOutput) return subject;
471
+
472
+ const subjects = logOutput.split("\n").filter(Boolean);
473
+ const MAX_ENTRIES = 20;
474
+ const truncated = subjects.length > MAX_ENTRIES;
475
+ const displayed = truncated ? subjects.slice(0, MAX_ENTRIES) : subjects;
476
+
477
+ const taskLines = displayed.map(s => `- ${s}`).join("\n");
478
+ const truncationLine = truncated ? `\n- ... and ${subjects.length - MAX_ENTRIES} more` : "";
479
+
480
+ return `${subject}\n\nTasks:\n${taskLines}${truncationLine}\n\nBranch: ${branch}`;
481
+ }
482
+
292
483
  /**
293
484
  * Squash-merge a slice branch into main and delete it.
294
485
  *
486
+ * Flow: snapshot branch HEAD → squash merge → rich commit via stdin →
487
+ * auto-push (if enabled) → delete branch.
488
+ *
295
489
  * Must be called from the main branch. Uses `inferCommitType(sliceTitle)`
296
490
  * for the conventional commit type instead of hardcoding `feat`.
297
491
  *
@@ -328,20 +522,45 @@ export class GitServiceImpl {
328
522
  );
329
523
  }
330
524
 
525
+ // Snapshot the branch HEAD before merge (gated on prefs.snapshots)
526
+ this.createSnapshot(branch);
527
+
528
+ // Build rich commit message before squash (needs branch history)
529
+ const commitType = inferCommitType(sliceTitle);
530
+ const message = this.buildRichCommitMessage(
531
+ commitType, milestoneId, sliceId, sliceTitle, mainBranch, branch,
532
+ );
533
+
331
534
  // Squash merge
332
535
  this.git(["merge", "--squash", branch]);
333
536
 
334
- // Build conventional commit message
335
- const commitType = inferCommitType(sliceTitle);
336
- const message = `${commitType}(${milestoneId}/${sliceId}): ${sliceTitle}`;
337
- this.git(["commit", "-m", JSON.stringify(message)]);
537
+ // Pre-merge check: run after squash (tests merged result), reset on failure
538
+ const checkResult = this.runPreMergeCheck();
539
+ if (!checkResult.passed && !checkResult.skipped) {
540
+ // Undo the squash merge — nothing committed yet, reset staging area
541
+ this.git(["reset", "--hard", "HEAD"]);
542
+ const cmdInfo = checkResult.command ? ` (command: ${checkResult.command})` : "";
543
+ const errInfo = checkResult.error ? `\n${checkResult.error}` : "";
544
+ throw new Error(
545
+ `Pre-merge check failed${cmdInfo}. Merge aborted.${errInfo}`,
546
+ );
547
+ }
548
+
549
+ // Commit with rich message via stdin pipe
550
+ this.git(["commit", "-F", "-"], { input: message });
338
551
 
339
552
  // Delete the merged branch
340
553
  this.git(["branch", "-D", branch]);
341
554
 
555
+ // Auto-push to remote if enabled
556
+ if (this.prefs.auto_push === true) {
557
+ const remote = this.prefs.remote ?? "origin";
558
+ this.git(["push", remote, mainBranch], { allowFailure: true });
559
+ }
560
+
342
561
  return {
343
562
  branch,
344
- mergedCommitMessage: message,
563
+ mergedCommitMessage: `${commitType}(${milestoneId}/${sliceId}): ${sliceTitle}`,
345
564
  deletedBranch: true,
346
565
  };
347
566
  }