sequant 1.9.0 → 1.10.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.
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Zod schemas for persistent workflow state tracking
3
+ *
4
+ * These schemas define the structure of `.sequant/state.json` which tracks
5
+ * the current state of all issues being processed through the workflow.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { WorkflowStateSchema, type WorkflowState } from './state-schema';
10
+ *
11
+ * // Validate state file
12
+ * const state = WorkflowStateSchema.parse(JSON.parse(stateContent));
13
+ *
14
+ * // Type-safe access
15
+ * const issue42 = state.issues["42"];
16
+ * console.log(issue42.currentPhase, issue42.status);
17
+ * ```
18
+ */
19
+ import { z } from "zod";
20
+ /**
21
+ * Workflow phases in order of execution
22
+ */
23
+ export const WORKFLOW_PHASES = [
24
+ "spec",
25
+ "security-review",
26
+ "exec",
27
+ "testgen",
28
+ "test",
29
+ "qa",
30
+ "loop",
31
+ ];
32
+ /**
33
+ * Phase status - tracks individual phase progress
34
+ */
35
+ export const PhaseStatusSchema = z.enum([
36
+ "pending", // Phase not yet started
37
+ "in_progress", // Phase currently executing
38
+ "completed", // Phase finished successfully
39
+ "failed", // Phase finished with errors
40
+ "skipped", // Phase intentionally skipped (e.g., bug labels skip spec)
41
+ ]);
42
+ /**
43
+ * Issue status - tracks overall issue progress
44
+ */
45
+ export const IssueStatusSchema = z.enum([
46
+ "not_started", // Issue tracked but no work begun
47
+ "in_progress", // Actively being worked on
48
+ "ready_for_merge", // All phases passed, PR ready for review
49
+ "merged", // PR merged, work complete
50
+ "blocked", // Waiting on external input or dependency
51
+ "abandoned", // Work stopped, will not continue
52
+ ]);
53
+ /**
54
+ * Phase type
55
+ */
56
+ export const PhaseSchema = z.enum([
57
+ "spec",
58
+ "security-review",
59
+ "exec",
60
+ "testgen",
61
+ "test",
62
+ "qa",
63
+ "loop",
64
+ ]);
65
+ /**
66
+ * Individual phase state within an issue
67
+ */
68
+ export const PhaseStateSchema = z.object({
69
+ /** Current status of the phase */
70
+ status: PhaseStatusSchema,
71
+ /** When the phase started (if started) */
72
+ startedAt: z.string().datetime().optional(),
73
+ /** When the phase completed (if completed/failed/skipped) */
74
+ completedAt: z.string().datetime().optional(),
75
+ /** Error message if phase failed */
76
+ error: z.string().optional(),
77
+ /** Number of loop iterations (for loop phase) */
78
+ iteration: z.number().int().nonnegative().optional(),
79
+ });
80
+ /**
81
+ * PR information for an issue
82
+ */
83
+ export const PRInfoSchema = z.object({
84
+ /** PR number */
85
+ number: z.number().int().positive(),
86
+ /** PR URL */
87
+ url: z.string().url(),
88
+ });
89
+ /**
90
+ * Quality loop state
91
+ */
92
+ export const LoopStateSchema = z.object({
93
+ /** Whether quality loop is enabled */
94
+ enabled: z.boolean(),
95
+ /** Current iteration number */
96
+ iteration: z.number().int().nonnegative(),
97
+ /** Maximum iterations allowed */
98
+ maxIterations: z.number().int().positive(),
99
+ });
100
+ /**
101
+ * Complete state for a single issue
102
+ */
103
+ export const IssueStateSchema = z.object({
104
+ /** GitHub issue number */
105
+ number: z.number().int().positive(),
106
+ /** Issue title */
107
+ title: z.string(),
108
+ /** Overall issue status */
109
+ status: IssueStatusSchema,
110
+ /** Path to the worktree (if created) */
111
+ worktree: z.string().optional(),
112
+ /** Branch name for this issue */
113
+ branch: z.string().optional(),
114
+ /** Current phase being executed or last executed */
115
+ currentPhase: PhaseSchema.optional(),
116
+ /** State of each phase (only phases that have been started/tracked) */
117
+ phases: z.record(z.string(), PhaseStateSchema),
118
+ /** PR information (if PR created) */
119
+ pr: PRInfoSchema.optional(),
120
+ /** Quality loop state (if loop enabled) */
121
+ loop: LoopStateSchema.optional(),
122
+ /** Claude session ID (for resume) */
123
+ sessionId: z.string().optional(),
124
+ /** Most recent activity timestamp */
125
+ lastActivity: z.string().datetime(),
126
+ /** When this issue was first tracked */
127
+ createdAt: z.string().datetime(),
128
+ });
129
+ /**
130
+ * Complete workflow state schema
131
+ *
132
+ * This is the top-level schema for `.sequant/state.json`
133
+ */
134
+ export const WorkflowStateSchema = z.object({
135
+ /** Schema version for backwards compatibility */
136
+ version: z.literal(1),
137
+ /** When the state file was last updated */
138
+ lastUpdated: z.string().datetime(),
139
+ /** State for all tracked issues, keyed by issue number */
140
+ issues: z.record(z.string(), IssueStateSchema),
141
+ });
142
+ /**
143
+ * Default state file path
144
+ */
145
+ export const STATE_FILE_PATH = ".sequant/state.json";
146
+ /**
147
+ * Create an empty workflow state
148
+ */
149
+ export function createEmptyState() {
150
+ return {
151
+ version: 1,
152
+ lastUpdated: new Date().toISOString(),
153
+ issues: {},
154
+ };
155
+ }
156
+ /**
157
+ * Create initial state for a new issue
158
+ */
159
+ export function createIssueState(issueNumber, title, options) {
160
+ const now = new Date().toISOString();
161
+ return {
162
+ number: issueNumber,
163
+ title,
164
+ status: "not_started",
165
+ worktree: options?.worktree,
166
+ branch: options?.branch,
167
+ phases: {},
168
+ loop: options?.qualityLoop
169
+ ? {
170
+ enabled: true,
171
+ iteration: 0,
172
+ maxIterations: options?.maxIterations ?? 3,
173
+ }
174
+ : undefined,
175
+ lastActivity: now,
176
+ createdAt: now,
177
+ };
178
+ }
179
+ /**
180
+ * Create initial phase state
181
+ */
182
+ export function createPhaseState(status = "pending") {
183
+ if (status === "in_progress") {
184
+ return {
185
+ status,
186
+ startedAt: new Date().toISOString(),
187
+ };
188
+ }
189
+ return { status };
190
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * State utilities for rebuilding and cleaning up workflow state
3
+ *
4
+ * @example
5
+ * ```typescript
6
+ * import { rebuildStateFromLogs, cleanupStaleEntries } from './state-utils';
7
+ *
8
+ * // Rebuild state from run logs
9
+ * await rebuildStateFromLogs();
10
+ *
11
+ * // Clean up orphaned entries
12
+ * const result = await cleanupStaleEntries({ dryRun: true });
13
+ * ```
14
+ */
15
+ export interface RebuildOptions {
16
+ /** Log directory path (default: .sequant/logs) */
17
+ logPath?: string;
18
+ /** State file path (default: .sequant/state.json) */
19
+ statePath?: string;
20
+ /** Enable verbose logging */
21
+ verbose?: boolean;
22
+ }
23
+ export interface RebuildResult {
24
+ /** Whether rebuild was successful */
25
+ success: boolean;
26
+ /** Number of log files processed */
27
+ logsProcessed: number;
28
+ /** Number of issues found */
29
+ issuesFound: number;
30
+ /** Error message if failed */
31
+ error?: string;
32
+ }
33
+ /**
34
+ * Rebuild workflow state from run logs
35
+ *
36
+ * Scans all run logs in .sequant/logs/ and reconstructs state
37
+ * based on the most recent activity for each issue.
38
+ */
39
+ export declare function rebuildStateFromLogs(options?: RebuildOptions): Promise<RebuildResult>;
40
+ export interface CleanupOptions {
41
+ /** State file path (default: .sequant/state.json) */
42
+ statePath?: string;
43
+ /** Only report what would be cleaned (don't modify) */
44
+ dryRun?: boolean;
45
+ /** Enable verbose logging */
46
+ verbose?: boolean;
47
+ /** Remove issues older than this many days */
48
+ maxAgeDays?: number;
49
+ }
50
+ export interface CleanupResult {
51
+ /** Whether cleanup was successful */
52
+ success: boolean;
53
+ /** Issues that were removed or would be removed */
54
+ removed: number[];
55
+ /** Issues that were marked as orphaned */
56
+ orphaned: number[];
57
+ /** Error message if failed */
58
+ error?: string;
59
+ }
60
+ /**
61
+ * Clean up stale and orphaned entries from workflow state
62
+ *
63
+ * - Removes issues with non-existent worktrees (orphaned)
64
+ * - Optionally removes old merged/abandoned issues
65
+ */
66
+ export declare function cleanupStaleEntries(options?: CleanupOptions): Promise<CleanupResult>;
@@ -0,0 +1,243 @@
1
+ /**
2
+ * State utilities for rebuilding and cleaning up workflow state
3
+ *
4
+ * @example
5
+ * ```typescript
6
+ * import { rebuildStateFromLogs, cleanupStaleEntries } from './state-utils';
7
+ *
8
+ * // Rebuild state from run logs
9
+ * await rebuildStateFromLogs();
10
+ *
11
+ * // Clean up orphaned entries
12
+ * const result = await cleanupStaleEntries({ dryRun: true });
13
+ * ```
14
+ */
15
+ import * as fs from "fs";
16
+ import * as path from "path";
17
+ import { spawnSync } from "child_process";
18
+ import { StateManager } from "./state-manager.js";
19
+ import { createEmptyState, createIssueState, createPhaseState, } from "./state-schema.js";
20
+ import { RunLogSchema, LOG_PATHS } from "./run-log-schema.js";
21
+ /**
22
+ * Rebuild workflow state from run logs
23
+ *
24
+ * Scans all run logs in .sequant/logs/ and reconstructs state
25
+ * based on the most recent activity for each issue.
26
+ */
27
+ export async function rebuildStateFromLogs(options = {}) {
28
+ const logPath = options.logPath ?? LOG_PATHS.project;
29
+ if (!fs.existsSync(logPath)) {
30
+ return {
31
+ success: false,
32
+ logsProcessed: 0,
33
+ issuesFound: 0,
34
+ error: `Log directory not found: ${logPath}`,
35
+ };
36
+ }
37
+ try {
38
+ // Find all log files
39
+ const files = fs.readdirSync(logPath).filter((f) => f.endsWith(".json"));
40
+ if (files.length === 0) {
41
+ return {
42
+ success: true,
43
+ logsProcessed: 0,
44
+ issuesFound: 0,
45
+ };
46
+ }
47
+ // Sort by timestamp (newest first)
48
+ files.sort().reverse();
49
+ // Build state from logs
50
+ const state = createEmptyState();
51
+ const issueMap = new Map();
52
+ for (const file of files) {
53
+ const filePath = path.join(logPath, file);
54
+ try {
55
+ const content = fs.readFileSync(filePath, "utf-8");
56
+ const logData = JSON.parse(content);
57
+ const log = RunLogSchema.safeParse(logData);
58
+ if (!log.success) {
59
+ if (options.verbose) {
60
+ console.log(`⚠️ Invalid log format: ${file}`);
61
+ }
62
+ continue;
63
+ }
64
+ const runLog = log.data;
65
+ // Process each issue in the log
66
+ for (const issueLog of runLog.issues) {
67
+ // Skip if we already have newer data for this issue
68
+ if (issueMap.has(issueLog.issueNumber)) {
69
+ continue;
70
+ }
71
+ // Create issue state from log
72
+ const issueState = createIssueState(issueLog.issueNumber, issueLog.title);
73
+ // Determine status from log
74
+ if (issueLog.status === "success") {
75
+ issueState.status = "ready_for_merge";
76
+ }
77
+ else if (issueLog.status === "failure") {
78
+ issueState.status = "in_progress";
79
+ }
80
+ else {
81
+ issueState.status = "in_progress";
82
+ }
83
+ // Add phase states from log
84
+ for (const phaseLog of issueLog.phases) {
85
+ const phaseState = createPhaseState(phaseLog.status === "success"
86
+ ? "completed"
87
+ : phaseLog.status === "failure"
88
+ ? "failed"
89
+ : phaseLog.status === "skipped"
90
+ ? "skipped"
91
+ : "completed");
92
+ phaseState.startedAt = phaseLog.startTime;
93
+ phaseState.completedAt = phaseLog.endTime;
94
+ if (phaseLog.error) {
95
+ phaseState.error = phaseLog.error;
96
+ }
97
+ issueState.phases[phaseLog.phase] = phaseState;
98
+ // Update current phase to last executed
99
+ issueState.currentPhase = phaseLog.phase;
100
+ }
101
+ // Set last activity from most recent phase
102
+ const lastPhase = issueLog.phases[issueLog.phases.length - 1];
103
+ if (lastPhase) {
104
+ issueState.lastActivity = lastPhase.endTime;
105
+ }
106
+ issueMap.set(issueLog.issueNumber, issueState);
107
+ }
108
+ if (options.verbose) {
109
+ console.log(`✓ Processed: ${file}`);
110
+ }
111
+ }
112
+ catch (err) {
113
+ if (options.verbose) {
114
+ console.log(`⚠️ Error reading ${file}: ${err}`);
115
+ }
116
+ }
117
+ }
118
+ // Copy issues to state
119
+ for (const [num, issueState] of issueMap) {
120
+ state.issues[String(num)] = issueState;
121
+ }
122
+ // Save rebuilt state
123
+ const manager = new StateManager({
124
+ statePath: options.statePath,
125
+ verbose: options.verbose,
126
+ });
127
+ await manager.saveState(state);
128
+ return {
129
+ success: true,
130
+ logsProcessed: files.length,
131
+ issuesFound: issueMap.size,
132
+ };
133
+ }
134
+ catch (error) {
135
+ return {
136
+ success: false,
137
+ logsProcessed: 0,
138
+ issuesFound: 0,
139
+ error: String(error),
140
+ };
141
+ }
142
+ }
143
+ /**
144
+ * Clean up stale and orphaned entries from workflow state
145
+ *
146
+ * - Removes issues with non-existent worktrees (orphaned)
147
+ * - Optionally removes old merged/abandoned issues
148
+ */
149
+ export async function cleanupStaleEntries(options = {}) {
150
+ const manager = new StateManager({
151
+ statePath: options.statePath,
152
+ verbose: options.verbose,
153
+ });
154
+ if (!manager.stateExists()) {
155
+ return {
156
+ success: true,
157
+ removed: [],
158
+ orphaned: [],
159
+ };
160
+ }
161
+ try {
162
+ const state = await manager.getState();
163
+ const removed = [];
164
+ const orphaned = [];
165
+ // Get list of active worktrees
166
+ const activeWorktrees = getActiveWorktrees();
167
+ for (const [issueNumStr, issueState] of Object.entries(state.issues)) {
168
+ const issueNum = parseInt(issueNumStr, 10);
169
+ // Check if worktree exists (if issue has one)
170
+ if (issueState.worktree &&
171
+ !activeWorktrees.includes(issueState.worktree)) {
172
+ orphaned.push(issueNum);
173
+ if (options.verbose) {
174
+ console.log(`🗑️ Orphaned: #${issueNum} (worktree not found: ${issueState.worktree})`);
175
+ }
176
+ if (!options.dryRun) {
177
+ // Mark as abandoned or remove based on status
178
+ if (issueState.status === "merged" ||
179
+ issueState.status === "abandoned") {
180
+ removed.push(issueNum);
181
+ delete state.issues[issueNumStr];
182
+ }
183
+ else {
184
+ // Update status to indicate orphaned state
185
+ issueState.status = "abandoned";
186
+ }
187
+ }
188
+ continue;
189
+ }
190
+ // Check age for merged/abandoned issues
191
+ if (options.maxAgeDays &&
192
+ (issueState.status === "merged" || issueState.status === "abandoned")) {
193
+ const lastActivity = new Date(issueState.lastActivity);
194
+ const ageDays = (Date.now() - lastActivity.getTime()) / (1000 * 60 * 60 * 24);
195
+ if (ageDays > options.maxAgeDays) {
196
+ removed.push(issueNum);
197
+ if (options.verbose) {
198
+ console.log(`🗑️ Stale: #${issueNum} (${Math.floor(ageDays)} days old)`);
199
+ }
200
+ if (!options.dryRun) {
201
+ delete state.issues[issueNumStr];
202
+ }
203
+ }
204
+ }
205
+ }
206
+ // Save updated state
207
+ if (!options.dryRun && (removed.length > 0 || orphaned.length > 0)) {
208
+ await manager.saveState(state);
209
+ }
210
+ return {
211
+ success: true,
212
+ removed,
213
+ orphaned,
214
+ };
215
+ }
216
+ catch (error) {
217
+ return {
218
+ success: false,
219
+ removed: [],
220
+ orphaned: [],
221
+ error: String(error),
222
+ };
223
+ }
224
+ }
225
+ /**
226
+ * Get list of active worktree paths
227
+ */
228
+ function getActiveWorktrees() {
229
+ const result = spawnSync("git", ["worktree", "list", "--porcelain"], {
230
+ stdio: "pipe",
231
+ });
232
+ if (result.status !== 0) {
233
+ return [];
234
+ }
235
+ const output = result.stdout.toString();
236
+ const paths = [];
237
+ for (const line of output.split("\n")) {
238
+ if (line.startsWith("worktree ")) {
239
+ paths.push(line.substring(9));
240
+ }
241
+ }
242
+ return paths;
243
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sequant",
3
- "version": "1.9.0",
3
+ "version": "1.10.1",
4
4
  "description": "Quantize your development workflow - Sequential AI phases with quality gates",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,9 +1,10 @@
1
1
  #!/bin/bash
2
2
 
3
3
  # Create a new feature worktree from a GitHub issue
4
- # Usage: ./scripts/new-feature.sh <issue-number> [--stash]
4
+ # Usage: ./scripts/new-feature.sh <issue-number> [--base <branch>] [--stash]
5
5
  # Example: ./scripts/new-feature.sh 4
6
6
  # Example: ./scripts/new-feature.sh 4 --stash # Auto-stash uncommitted changes
7
+ # Example: ./scripts/new-feature.sh 4 --base feature/dashboard # Branch from feature branch
7
8
 
8
9
  set -e
9
10
 
@@ -14,20 +15,27 @@ BLUE='\033[0;34m'
14
15
  YELLOW='\033[1;33m'
15
16
  NC='\033[0m' # No Color
16
17
 
17
- # Parse arguments (flexible position for --stash flag)
18
+ # Parse arguments (flexible position for flags)
18
19
  STASH_FLAG=false
19
20
  ISSUE_NUMBER=""
21
+ BASE_BRANCH="main"
20
22
 
21
- for arg in "$@"; do
22
- case $arg in
23
+ while [[ $# -gt 0 ]]; do
24
+ case $1 in
23
25
  --stash)
24
26
  STASH_FLAG=true
27
+ shift
28
+ ;;
29
+ --base)
30
+ BASE_BRANCH="$2"
31
+ shift 2
25
32
  ;;
26
33
  *)
27
34
  # First non-flag argument is the issue number
28
35
  if [ -z "$ISSUE_NUMBER" ]; then
29
- ISSUE_NUMBER=$arg
36
+ ISSUE_NUMBER=$1
30
37
  fi
38
+ shift
31
39
  ;;
32
40
  esac
33
41
  done
@@ -35,9 +43,10 @@ done
35
43
  # Check if issue number is provided
36
44
  if [ -z "$ISSUE_NUMBER" ]; then
37
45
  echo -e "${RED}❌ Error: Issue number required${NC}"
38
- echo "Usage: ./scripts/new-feature.sh <issue-number> [--stash]"
46
+ echo "Usage: ./scripts/new-feature.sh <issue-number> [--base <branch>] [--stash]"
39
47
  echo "Example: ./scripts/new-feature.sh 4"
40
48
  echo "Example: ./scripts/new-feature.sh 4 --stash"
49
+ echo "Example: ./scripts/new-feature.sh 4 --base feature/dashboard"
41
50
  exit 1
42
51
  fi
43
52
 
@@ -95,6 +104,7 @@ WORKTREE_DIR="../worktrees/${BRANCH_NAME}"
95
104
 
96
105
  echo -e "${GREEN}✨ Creating worktree for issue #${ISSUE_NUMBER}${NC}"
97
106
  echo -e "${BLUE}Branch: ${BRANCH_NAME}${NC}"
107
+ echo -e "${BLUE}Base: ${BASE_BRANCH}${NC}"
98
108
  echo -e "${BLUE}Worktree: ${WORKTREE_DIR}${NC}"
99
109
  echo ""
100
110
 
@@ -132,14 +142,14 @@ if git show-ref --verify --quiet "refs/heads/${BRANCH_NAME}"; then
132
142
  fi
133
143
  fi
134
144
 
135
- # Update main branch
136
- echo -e "${BLUE}📥 Updating main branch...${NC}"
137
- git fetch origin main
138
- git checkout main
139
- git pull origin main
145
+ # Update base branch
146
+ echo -e "${BLUE}📥 Updating ${BASE_BRANCH} branch...${NC}"
147
+ git fetch origin "$BASE_BRANCH"
148
+ git checkout "$BASE_BRANCH"
149
+ git pull origin "$BASE_BRANCH"
140
150
 
141
- # Create worktree
142
- echo -e "${BLUE}🌿 Creating new worktree...${NC}"
151
+ # Create worktree from base branch
152
+ echo -e "${BLUE}🌿 Creating new worktree from ${BASE_BRANCH}...${NC}"
143
153
  git worktree add "$WORKTREE_DIR" -b "$BRANCH_NAME"
144
154
 
145
155
  # Navigate to worktree