sequant 1.5.6 → 1.6.1

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
@@ -121,7 +121,6 @@ npx sequant run 123 --quality-loop
121
121
  |---------|---------|
122
122
  | `/assess` | Issue triage and status assessment |
123
123
  | `/docs` | Generate feature documentation |
124
- | `/release` | Automated release workflow |
125
124
  | `/clean` | Repository cleanup |
126
125
  | `/security-review` | Deep security analysis |
127
126
  | `/reflect` | Workflow improvement analysis |
@@ -19,7 +19,7 @@ export async function doctorCommand() {
19
19
  message: `Outdated: ${versionResult.currentVersion} → ${versionResult.latestVersion} available`,
20
20
  });
21
21
  // Show remediation steps
22
- console.log(chalk.yellow(` ⚠️ ${getVersionWarning(versionResult.currentVersion, versionResult.latestVersion)}`));
22
+ console.log(chalk.yellow(` ⚠️ ${getVersionWarning(versionResult.currentVersion, versionResult.latestVersion, versionResult.isLocalInstall)}`));
23
23
  console.log("");
24
24
  }
25
25
  else {
@@ -742,7 +742,7 @@ export async function runCommand(issues, options) {
742
742
  try {
743
743
  const versionResult = await checkVersionCached();
744
744
  if (versionResult.isOutdated && versionResult.latestVersion) {
745
- console.log(chalk.yellow(` ⚠️ ${getVersionWarning(versionResult.currentVersion, versionResult.latestVersion)}`));
745
+ console.log(chalk.yellow(` ⚠️ ${getVersionWarning(versionResult.currentVersion, versionResult.latestVersion, versionResult.isLocalInstall)}`));
746
746
  console.log("");
747
747
  }
748
748
  }
@@ -12,6 +12,7 @@ export interface VersionCheckResult {
12
12
  currentVersion: string;
13
13
  latestVersion: string | null;
14
14
  isOutdated: boolean;
15
+ isLocalInstall?: boolean;
15
16
  error?: string;
16
17
  }
17
18
  /**
@@ -26,6 +27,17 @@ export declare function getCachePath(): string;
26
27
  * Get the current version from package.json
27
28
  */
28
29
  export declare function getCurrentVersion(): string;
30
+ /**
31
+ * Check if running from a local node_modules install (vs npx cache)
32
+ *
33
+ * Local installs are in: <project>/node_modules/sequant/
34
+ * npx installs are in: ~/.npm/_npx/<hash>/node_modules/sequant/
35
+ *
36
+ * This matters because:
37
+ * - Local installs should be updated with: npm update sequant
38
+ * - npx installs should be updated with: npx sequant@latest
39
+ */
40
+ export declare function isLocalNodeModulesInstall(): boolean;
29
41
  /**
30
42
  * Read the version cache
31
43
  */
@@ -53,8 +65,11 @@ export declare function compareVersions(a: string, b: string): number;
53
65
  export declare function isOutdated(currentVersion: string, latestVersion: string): boolean;
54
66
  /**
55
67
  * Get the version warning message
68
+ *
69
+ * For local node_modules installs, recommends `npm update sequant`
70
+ * For npx usage, recommends `npx sequant@latest`
56
71
  */
57
- export declare function getVersionWarning(currentVersion: string, latestVersion: string): string;
72
+ export declare function getVersionWarning(currentVersion: string, latestVersion: string, isLocal?: boolean): string;
58
73
  /**
59
74
  * Check version freshness (thorough - for doctor command)
60
75
  * Always fetches from npm registry
@@ -54,6 +54,25 @@ export function getCurrentVersion() {
54
54
  return "0.0.0";
55
55
  }
56
56
  }
57
+ /**
58
+ * Check if running from a local node_modules install (vs npx cache)
59
+ *
60
+ * Local installs are in: <project>/node_modules/sequant/
61
+ * npx installs are in: ~/.npm/_npx/<hash>/node_modules/sequant/
62
+ *
63
+ * This matters because:
64
+ * - Local installs should be updated with: npm update sequant
65
+ * - npx installs should be updated with: npx sequant@latest
66
+ */
67
+ export function isLocalNodeModulesInstall() {
68
+ // Check if our path contains node_modules/sequant but NOT in .npm/_npx
69
+ const normalizedPath = __dirname.replace(/\\/g, "/");
70
+ // Running from local node_modules (not npx cache)
71
+ const inNodeModules = normalizedPath.includes("/node_modules/sequant");
72
+ const inNpxCache = normalizedPath.includes("/.npm/_npx/") ||
73
+ normalizedPath.includes("\\.npm\\_npx\\");
74
+ return inNodeModules && !inNpxCache;
75
+ }
57
76
  /**
58
77
  * Read the version cache
59
78
  */
@@ -158,10 +177,19 @@ export function isOutdated(currentVersion, latestVersion) {
158
177
  }
159
178
  /**
160
179
  * Get the version warning message
180
+ *
181
+ * For local node_modules installs, recommends `npm update sequant`
182
+ * For npx usage, recommends `npx sequant@latest`
161
183
  */
162
- export function getVersionWarning(currentVersion, latestVersion) {
184
+ export function getVersionWarning(currentVersion, latestVersion, isLocal) {
185
+ const isLocalInstall = isLocal ?? isLocalNodeModulesInstall();
186
+ if (isLocalInstall) {
187
+ return `sequant ${latestVersion} is available (you have ${currentVersion})
188
+ Run: npm update sequant
189
+ Note: You have sequant as a local dependency. npx uses your node_modules version.`;
190
+ }
163
191
  return `sequant ${latestVersion} is available (you have ${currentVersion})
164
- Run: npx sequant@latest or npm update sequant`;
192
+ Run: npx sequant@latest`;
165
193
  }
166
194
  /**
167
195
  * Check version freshness (thorough - for doctor command)
@@ -169,12 +197,14 @@ export function getVersionWarning(currentVersion, latestVersion) {
169
197
  */
170
198
  export async function checkVersionThorough() {
171
199
  const currentVersion = getCurrentVersion();
200
+ const isLocal = isLocalNodeModulesInstall();
172
201
  const latestVersion = await fetchLatestVersion();
173
202
  if (!latestVersion) {
174
203
  return {
175
204
  currentVersion,
176
205
  latestVersion: null,
177
206
  isOutdated: false,
207
+ isLocalInstall: isLocal,
178
208
  error: "Could not fetch latest version",
179
209
  };
180
210
  }
@@ -184,6 +214,7 @@ export async function checkVersionThorough() {
184
214
  currentVersion,
185
215
  latestVersion,
186
216
  isOutdated: isOutdated(currentVersion, latestVersion),
217
+ isLocalInstall: isLocal,
187
218
  };
188
219
  }
189
220
  /**
@@ -192,6 +223,7 @@ export async function checkVersionThorough() {
192
223
  */
193
224
  export async function checkVersionCached() {
194
225
  const currentVersion = getCurrentVersion();
226
+ const isLocal = isLocalNodeModulesInstall();
195
227
  // Check cache first
196
228
  const cache = readCache();
197
229
  if (cache && isCacheFresh(cache)) {
@@ -199,6 +231,7 @@ export async function checkVersionCached() {
199
231
  currentVersion,
200
232
  latestVersion: cache.latestVersion,
201
233
  isOutdated: isOutdated(currentVersion, cache.latestVersion),
234
+ isLocalInstall: isLocal,
202
235
  };
203
236
  }
204
237
  // Fetch new version (with timeout)
@@ -210,12 +243,14 @@ export async function checkVersionCached() {
210
243
  currentVersion,
211
244
  latestVersion: cache.latestVersion,
212
245
  isOutdated: isOutdated(currentVersion, cache.latestVersion),
246
+ isLocalInstall: isLocal,
213
247
  };
214
248
  }
215
249
  return {
216
250
  currentVersion,
217
251
  latestVersion: null,
218
252
  isOutdated: false,
253
+ isLocalInstall: isLocal,
219
254
  };
220
255
  }
221
256
  // Update cache
@@ -224,5 +259,6 @@ export async function checkVersionCached() {
224
259
  currentVersion,
225
260
  latestVersion,
226
261
  isOutdated: isOutdated(currentVersion, latestVersion),
262
+ isLocalInstall: isLocal,
227
263
  };
228
264
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sequant",
3
- "version": "1.5.6",
3
+ "version": "1.6.1",
4
4
  "description": "Quantize your development workflow - Sequential AI phases with quality gates",
5
5
  "type": "module",
6
6
  "bin": {
@@ -108,6 +108,50 @@ if echo "$TOOL_INPUT" | grep -qE 'git push.*(--force| -f($| ))'; then
108
108
  exit 2
109
109
  fi
110
110
 
111
+ # --- Hard Reset Protection (Issue #85, enhanced) ---
112
+ # Block git reset --hard when there is local work that would be lost:
113
+ # - Unpushed commits on main/master
114
+ # - Uncommitted changes (staged or unstaged)
115
+ # - Unfinished merge in progress
116
+ if echo "$TOOL_INPUT" | grep -qE 'git reset.*(--hard|origin)'; then
117
+ CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
118
+ BLOCK_REASONS=""
119
+
120
+ # Check 1: Unpushed commits (only on main/master)
121
+ if [[ "$CURRENT_BRANCH" == "main" || "$CURRENT_BRANCH" == "master" ]]; then
122
+ UNPUSHED=$(git log origin/$CURRENT_BRANCH..HEAD --oneline 2>/dev/null | wc -l | tr -d ' ')
123
+ if [[ "$UNPUSHED" -gt 0 ]]; then
124
+ BLOCK_REASONS="${BLOCK_REASONS} - $UNPUSHED unpushed commit(s) on $CURRENT_BRANCH\n"
125
+ fi
126
+ fi
127
+
128
+ # Check 2: Uncommitted changes (staged or unstaged)
129
+ UNCOMMITTED=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
130
+ if [[ "$UNCOMMITTED" -gt 0 ]]; then
131
+ BLOCK_REASONS="${BLOCK_REASONS} - $UNCOMMITTED uncommitted file(s)\n"
132
+ fi
133
+
134
+ # Check 3: Unfinished merge
135
+ GIT_DIR=$(git rev-parse --git-dir 2>/dev/null || echo ".git")
136
+ if [[ -f "$GIT_DIR/MERGE_HEAD" ]]; then
137
+ BLOCK_REASONS="${BLOCK_REASONS} - Unfinished merge in progress\n"
138
+ fi
139
+
140
+ # Block if any reasons found
141
+ if [[ -n "$BLOCK_REASONS" ]]; then
142
+ {
143
+ echo "HOOK_BLOCKED: git reset --hard would lose local work:"
144
+ echo -e "$BLOCK_REASONS"
145
+ echo " Resolve with:"
146
+ echo " git push origin $CURRENT_BRANCH # push commits"
147
+ echo " git stash # save changes"
148
+ echo " git merge --abort # cancel merge"
149
+ echo " Or run directly in terminal (outside Claude Code) to bypass"
150
+ } | tee -a /tmp/claude-hook.log >&2
151
+ exit 2
152
+ fi
153
+ fi
154
+
111
155
  # CI/CD triggers (automation shouldn't trigger more automation)
112
156
  if echo "$TOOL_INPUT" | grep -qE 'gh workflow run'; then
113
157
  echo "HOOK_BLOCKED: Workflow trigger" | tee -a /tmp/claude-hook.log >&2
@@ -273,22 +317,33 @@ if [[ "$TOOL_NAME" == "Bash" ]] && echo "$TOOL_INPUT" | grep -qE 'git commit'; t
273
317
  fi
274
318
  fi
275
319
 
276
- # === WORKTREE PATH ENFORCEMENT FOR PARALLEL AGENTS ===
277
- # When a parallel marker exists with a worktree path, block edits outside that worktree
320
+ # === WORKTREE PATH ENFORCEMENT ===
321
+ # Enforces that file operations stay within the designated worktree
322
+ # Sources for worktree path (in priority order):
323
+ # 1. SEQUANT_WORKTREE env var - set by `sequant run` for isolated issue execution
324
+ # 2. Parallel marker file - for parallel agent execution
278
325
  # This prevents agents from accidentally editing the main repo instead of the worktree
279
- # Marker file format: First line contains the expected worktree path
280
326
  if [[ "$TOOL_NAME" == "Edit" || "$TOOL_NAME" == "Write" ]]; then
281
327
  EXPECTED_WORKTREE=""
282
- for marker in "${PARALLEL_MARKER_PREFIX}"*.marker; do
283
- if [[ -f "$marker" ]]; then
284
- # Read expected worktree path from marker file (first line)
285
- EXPECTED_WORKTREE=$(head -1 "$marker" 2>/dev/null || true)
286
- break
287
- fi
288
- done
328
+
329
+ # Priority 1: Check SEQUANT_WORKTREE environment variable (set by sequant run)
330
+ if [[ -n "${SEQUANT_WORKTREE:-}" ]]; then
331
+ EXPECTED_WORKTREE="$SEQUANT_WORKTREE"
332
+ fi
333
+
334
+ # Priority 2: Fall back to parallel marker file
335
+ if [[ -z "$EXPECTED_WORKTREE" ]]; then
336
+ for marker in "${PARALLEL_MARKER_PREFIX}"*.marker; do
337
+ if [[ -f "$marker" ]]; then
338
+ # Read expected worktree path from marker file (first line)
339
+ EXPECTED_WORKTREE=$(head -1 "$marker" 2>/dev/null || true)
340
+ break
341
+ fi
342
+ done
343
+ fi
289
344
 
290
345
  if [[ -n "$EXPECTED_WORKTREE" ]]; then
291
- # AC-1 (Issue #550): Check worktree directory exists before path validation
346
+ # AC-4 (Issue #31): Check worktree directory exists before path validation
292
347
  # Prevents Write tool from creating non-existent worktree directories
293
348
  if [[ ! -d "$EXPECTED_WORKTREE" ]]; then
294
349
  echo "HOOK_BLOCKED: Worktree does not exist: $EXPECTED_WORKTREE" | tee -a /tmp/claude-hook.log >&2
@@ -304,12 +359,23 @@ if [[ "$TOOL_NAME" == "Edit" || "$TOOL_NAME" == "Write" ]]; then
304
359
  fi
305
360
 
306
361
  if [[ -n "$FILE_PATH" ]]; then
362
+ # Resolve to absolute path for consistent comparison
363
+ REAL_FILE_PATH=$(realpath "$FILE_PATH" 2>/dev/null || echo "$FILE_PATH")
364
+ REAL_WORKTREE=$(realpath "$EXPECTED_WORKTREE" 2>/dev/null || echo "$EXPECTED_WORKTREE")
365
+
307
366
  # Check if file path is within the expected worktree
308
- if ! echo "$FILE_PATH" | grep -qF "$EXPECTED_WORKTREE"; then
367
+ if [[ "$REAL_FILE_PATH" != "$REAL_WORKTREE"* ]]; then
309
368
  echo "$(date +%H:%M:%S) WORKTREE_BLOCKED: Edit outside expected worktree" >> "$QUALITY_LOG"
310
369
  echo " Expected: $EXPECTED_WORKTREE" >> "$QUALITY_LOG"
311
370
  echo " Got: $FILE_PATH" >> "$QUALITY_LOG"
312
- echo "HOOK_BLOCKED: Edit must be in worktree: $EXPECTED_WORKTREE (got: $FILE_PATH)" | tee -a /tmp/claude-hook.log >&2
371
+ {
372
+ echo "HOOK_BLOCKED: File operation must be within worktree"
373
+ echo " Worktree: $EXPECTED_WORKTREE"
374
+ echo " File: $FILE_PATH"
375
+ if [[ -n "${SEQUANT_ISSUE:-}" ]]; then
376
+ echo " Issue: #$SEQUANT_ISSUE"
377
+ fi
378
+ } | tee -a /tmp/claude-hook.log >&2
313
379
  exit 2
314
380
  fi
315
381
  fi
@@ -357,6 +423,31 @@ if [[ "${CLAUDE_HOOKS_FILE_LOCKING:-true}" == "true" ]]; then
357
423
  fi
358
424
  fi
359
425
 
426
+ # === PRE-MERGE WORKTREE CLEANUP ===
427
+ # Auto-remove worktree before `gh pr merge` to prevent --delete-branch failure
428
+ # The worktree locks the branch, causing merge to partially fail
429
+ if [[ "$TOOL_NAME" == "Bash" ]] && echo "$TOOL_INPUT" | grep -qE 'gh pr merge'; then
430
+ # Extract PR number from command
431
+ PR_NUM=$(echo "$TOOL_INPUT" | grep -oE 'gh pr merge [0-9]+' | grep -oE '[0-9]+')
432
+
433
+ if [[ -n "$PR_NUM" ]]; then
434
+ # Get the branch name for this PR
435
+ BRANCH_NAME=$(gh pr view "$PR_NUM" --json headRefName --jq '.headRefName' 2>/dev/null || true)
436
+
437
+ if [[ -n "$BRANCH_NAME" ]]; then
438
+ # Check if a worktree exists for this branch
439
+ # Note: worktree line is 2 lines before branch line in porcelain output
440
+ WORKTREE_PATH=$(git worktree list --porcelain 2>/dev/null | grep -B2 "branch refs/heads/$BRANCH_NAME" | grep "^worktree " | sed 's/^worktree //' || true)
441
+
442
+ if [[ -n "$WORKTREE_PATH" && -d "$WORKTREE_PATH" ]]; then
443
+ # Remove the worktree before merge proceeds
444
+ git worktree remove "$WORKTREE_PATH" --force 2>/dev/null || true
445
+ echo "PRE-MERGE: Removed worktree $WORKTREE_PATH for branch $BRANCH_NAME" >> /tmp/claude-hook.log
446
+ fi
447
+ fi
448
+ fi
449
+ fi
450
+
360
451
  # === ALLOW EVERYTHING ELSE ===
361
452
  # Slash commands need: git, npm, file edits, gh pr/issue, MCP tools
362
453
  exit 0