sequant 1.12.0 → 1.13.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.
Files changed (53) hide show
  1. package/README.md +10 -8
  2. package/dist/bin/cli.js +19 -9
  3. package/dist/src/commands/doctor.js +42 -20
  4. package/dist/src/commands/init.js +152 -65
  5. package/dist/src/commands/logs.js +7 -6
  6. package/dist/src/commands/run.d.ts +13 -1
  7. package/dist/src/commands/run.js +122 -32
  8. package/dist/src/commands/stats.js +67 -48
  9. package/dist/src/commands/status.js +30 -12
  10. package/dist/src/commands/sync.d.ts +28 -0
  11. package/dist/src/commands/sync.js +102 -0
  12. package/dist/src/index.d.ts +6 -0
  13. package/dist/src/index.js +4 -0
  14. package/dist/src/lib/cli-ui.d.ts +196 -0
  15. package/dist/src/lib/cli-ui.js +544 -0
  16. package/dist/src/lib/content-analyzer.d.ts +89 -0
  17. package/dist/src/lib/content-analyzer.js +437 -0
  18. package/dist/src/lib/phase-signal.d.ts +94 -0
  19. package/dist/src/lib/phase-signal.js +171 -0
  20. package/dist/src/lib/phase-spinner.d.ts +146 -0
  21. package/dist/src/lib/phase-spinner.js +255 -0
  22. package/dist/src/lib/solve-comment-parser.d.ts +84 -0
  23. package/dist/src/lib/solve-comment-parser.js +200 -0
  24. package/dist/src/lib/stack-config.d.ts +51 -0
  25. package/dist/src/lib/stack-config.js +77 -0
  26. package/dist/src/lib/stacks.d.ts +52 -0
  27. package/dist/src/lib/stacks.js +173 -0
  28. package/dist/src/lib/templates.d.ts +2 -0
  29. package/dist/src/lib/templates.js +9 -2
  30. package/dist/src/lib/upstream/assessment.d.ts +70 -0
  31. package/dist/src/lib/upstream/assessment.js +385 -0
  32. package/dist/src/lib/upstream/index.d.ts +11 -0
  33. package/dist/src/lib/upstream/index.js +14 -0
  34. package/dist/src/lib/upstream/issues.d.ts +38 -0
  35. package/dist/src/lib/upstream/issues.js +267 -0
  36. package/dist/src/lib/upstream/relevance.d.ts +50 -0
  37. package/dist/src/lib/upstream/relevance.js +209 -0
  38. package/dist/src/lib/upstream/report.d.ts +29 -0
  39. package/dist/src/lib/upstream/report.js +391 -0
  40. package/dist/src/lib/upstream/types.d.ts +207 -0
  41. package/dist/src/lib/upstream/types.js +5 -0
  42. package/dist/src/lib/workflow/log-writer.d.ts +1 -1
  43. package/dist/src/lib/workflow/metrics-schema.d.ts +3 -3
  44. package/dist/src/lib/workflow/qa-cache.d.ts +199 -0
  45. package/dist/src/lib/workflow/qa-cache.js +440 -0
  46. package/dist/src/lib/workflow/run-log-schema.d.ts +34 -6
  47. package/dist/src/lib/workflow/run-log-schema.js +12 -1
  48. package/dist/src/lib/workflow/state-schema.d.ts +4 -4
  49. package/dist/src/lib/workflow/types.d.ts +4 -0
  50. package/package.json +6 -1
  51. package/templates/skills/qa/scripts/quality-checks.sh +509 -53
  52. package/templates/skills/solve/SKILL.md +375 -83
  53. package/templates/skills/spec/SKILL.md +107 -5
@@ -0,0 +1,267 @@
1
+ /**
2
+ * GitHub issue management for upstream assessments
3
+ * Handles issue creation, deduplication, and commenting
4
+ *
5
+ * Security: All gh CLI calls use spawn() with argument arrays to prevent
6
+ * command injection. No shell interpolation is used.
7
+ */
8
+ import { spawn } from "node:child_process";
9
+ import { writeFile, unlink } from "node:fs/promises";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { generateFindingIssue } from "./report.js";
13
+ /**
14
+ * Regex pattern for valid GitHub owner/repo names
15
+ * Only alphanumeric, hyphens, underscores, and dots allowed
16
+ */
17
+ const REPO_NAME_PATTERN = /^[a-zA-Z0-9._-]+$/;
18
+ /**
19
+ * Validate owner and repo names to prevent injection
20
+ */
21
+ function validateRepoParams(owner, repo) {
22
+ if (!REPO_NAME_PATTERN.test(owner)) {
23
+ throw new Error(`Invalid owner name: "${owner}"`);
24
+ }
25
+ if (!REPO_NAME_PATTERN.test(repo)) {
26
+ throw new Error(`Invalid repo name: "${repo}"`);
27
+ }
28
+ }
29
+ /**
30
+ * Execute a command safely using spawn with argument arrays
31
+ * This prevents command injection by not using shell interpolation
32
+ */
33
+ async function execCommand(command, args) {
34
+ return new Promise((resolve, reject) => {
35
+ const proc = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] });
36
+ let stdout = "";
37
+ let stderr = "";
38
+ proc.stdout.on("data", (data) => {
39
+ stdout += data.toString();
40
+ });
41
+ proc.stderr.on("data", (data) => {
42
+ stderr += data.toString();
43
+ });
44
+ proc.on("close", (code) => {
45
+ if (code === 0) {
46
+ resolve({ stdout, stderr });
47
+ }
48
+ else {
49
+ reject(new Error(`Command failed with exit code ${code}: ${stderr}`));
50
+ }
51
+ });
52
+ proc.on("error", (err) => {
53
+ reject(err);
54
+ });
55
+ });
56
+ }
57
+ /**
58
+ * Check if a similar upstream issue already exists
59
+ */
60
+ export async function checkForDuplicate(title, owner = "admarble", repo = "sequant") {
61
+ try {
62
+ validateRepoParams(owner, repo);
63
+ // Search for existing upstream issues with similar title
64
+ // Extract key terms from title for search
65
+ const searchTerms = extractSearchTerms(title);
66
+ // Use spawn with argument arrays - no shell interpolation
67
+ const { stdout } = await execCommand("gh", [
68
+ "issue",
69
+ "list",
70
+ "--repo",
71
+ `${owner}/${repo}`,
72
+ "--label",
73
+ "upstream",
74
+ "--search",
75
+ searchTerms,
76
+ "--json",
77
+ "number,title",
78
+ "--limit",
79
+ "10",
80
+ ]);
81
+ const issues = JSON.parse(stdout);
82
+ // Check for similarity
83
+ for (const issue of issues) {
84
+ if (isSimilarTitle(title, issue.title)) {
85
+ return {
86
+ isDuplicate: true,
87
+ existingIssue: issue.number,
88
+ existingTitle: issue.title,
89
+ };
90
+ }
91
+ }
92
+ return { isDuplicate: false };
93
+ }
94
+ catch (error) {
95
+ // If search fails, assume no duplicate
96
+ console.error("Error checking for duplicates:", error);
97
+ return { isDuplicate: false };
98
+ }
99
+ }
100
+ /**
101
+ * Extract search terms from a title
102
+ * Removes common words and version info
103
+ */
104
+ export function extractSearchTerms(title) {
105
+ const stopWords = [
106
+ "the",
107
+ "a",
108
+ "an",
109
+ "from",
110
+ "to",
111
+ "in",
112
+ "for",
113
+ "of",
114
+ "on",
115
+ "with",
116
+ "claude",
117
+ "code",
118
+ ];
119
+ // Remove version patterns like v2.1.29
120
+ let cleaned = title.replace(/v?\d+\.\d+\.\d+/g, "");
121
+ // Remove prefixes
122
+ cleaned = cleaned.replace(/^(BREAKING|Deprecated|New tool|Hook change|feat|fix|chore):?\s*/i, "");
123
+ // Split into words and filter
124
+ const words = cleaned
125
+ .toLowerCase()
126
+ .split(/\s+/)
127
+ .filter((w) => w.length > 2 && !stopWords.includes(w));
128
+ // Take first 5 meaningful words
129
+ return words.slice(0, 5).join(" ");
130
+ }
131
+ /**
132
+ * Check if two titles are similar enough to be duplicates
133
+ */
134
+ export function isSimilarTitle(title1, title2) {
135
+ const terms1 = new Set(extractSearchTerms(title1)
136
+ .split(" ")
137
+ .filter((t) => t.length > 0));
138
+ const terms2 = new Set(extractSearchTerms(title2)
139
+ .split(" ")
140
+ .filter((t) => t.length > 0));
141
+ // Calculate Jaccard similarity
142
+ const intersection = new Set([...terms1].filter((x) => terms2.has(x)));
143
+ const union = new Set([...terms1, ...terms2]);
144
+ // Handle edge case where both are empty
145
+ if (union.size === 0)
146
+ return false;
147
+ const similarity = intersection.size / union.size;
148
+ // Consider similar if > 60% overlap
149
+ return similarity > 0.6;
150
+ }
151
+ /**
152
+ * Create a GitHub issue using a temporary file for the body
153
+ * This avoids any shell escaping issues with complex markdown content
154
+ */
155
+ export async function createIssue(params, owner = "admarble", repo = "sequant") {
156
+ validateRepoParams(owner, repo);
157
+ // Write body to a temp file to avoid any escaping issues
158
+ const tempFile = join(tmpdir(), `gh-issue-body-${Date.now()}.md`);
159
+ try {
160
+ await writeFile(tempFile, params.body, "utf-8");
161
+ // Build args array
162
+ const args = [
163
+ "issue",
164
+ "create",
165
+ "--repo",
166
+ `${owner}/${repo}`,
167
+ "--title",
168
+ params.title,
169
+ "--body-file",
170
+ tempFile,
171
+ ];
172
+ // Add labels
173
+ for (const label of params.labels) {
174
+ args.push("--label", label);
175
+ }
176
+ const { stdout } = await execCommand("gh", args);
177
+ // Parse issue URL from output
178
+ const url = stdout.trim();
179
+ const numberMatch = url.match(/\/issues\/(\d+)$/);
180
+ const number = numberMatch ? parseInt(numberMatch[1], 10) : 0;
181
+ return { number, url };
182
+ }
183
+ finally {
184
+ // Clean up temp file
185
+ try {
186
+ await unlink(tempFile);
187
+ }
188
+ catch {
189
+ // Ignore cleanup errors
190
+ }
191
+ }
192
+ }
193
+ /**
194
+ * Add a comment to an existing issue
195
+ */
196
+ export async function addIssueComment(issueNumber, comment, owner = "admarble", repo = "sequant") {
197
+ validateRepoParams(owner, repo);
198
+ // Validate issue number
199
+ if (!Number.isInteger(issueNumber) || issueNumber < 1) {
200
+ throw new Error(`Invalid issue number: ${issueNumber}`);
201
+ }
202
+ // Write comment to a temp file to avoid escaping issues
203
+ const tempFile = join(tmpdir(), `gh-comment-${Date.now()}.md`);
204
+ try {
205
+ await writeFile(tempFile, comment, "utf-8");
206
+ await execCommand("gh", [
207
+ "issue",
208
+ "comment",
209
+ String(issueNumber),
210
+ "--repo",
211
+ `${owner}/${repo}`,
212
+ "--body-file",
213
+ tempFile,
214
+ ]);
215
+ }
216
+ finally {
217
+ // Clean up temp file
218
+ try {
219
+ await unlink(tempFile);
220
+ }
221
+ catch {
222
+ // Ignore cleanup errors
223
+ }
224
+ }
225
+ }
226
+ /**
227
+ * Create or link an issue for a finding
228
+ */
229
+ export async function createOrLinkFinding(finding, version, assessmentIssueNumber, dryRun = false, owner = "admarble", repo = "sequant") {
230
+ // Generate issue content
231
+ const issueContent = generateFindingIssue(finding, version, assessmentIssueNumber);
232
+ // Check for duplicate
233
+ const duplicate = await checkForDuplicate(issueContent.title, owner, repo);
234
+ if (duplicate.isDuplicate && duplicate.existingIssue) {
235
+ // Link to existing issue
236
+ if (!dryRun) {
237
+ await addIssueComment(duplicate.existingIssue, `Also relevant in Claude Code ${version} assessment${assessmentIssueNumber ? ` (#${assessmentIssueNumber})` : ""}.`, owner, repo);
238
+ }
239
+ return {
240
+ ...finding,
241
+ existingIssue: duplicate.existingIssue,
242
+ };
243
+ }
244
+ // Create new issue
245
+ if (!dryRun) {
246
+ const result = await createIssue(issueContent, owner, repo);
247
+ return {
248
+ ...finding,
249
+ issueNumber: result.number,
250
+ };
251
+ }
252
+ return finding;
253
+ }
254
+ /**
255
+ * Create the assessment summary issue
256
+ */
257
+ export async function createAssessmentIssue(title, body, dryRun = false, owner = "admarble", repo = "sequant") {
258
+ if (dryRun) {
259
+ return undefined;
260
+ }
261
+ const result = await createIssue({
262
+ title,
263
+ body,
264
+ labels: ["upstream", "assessment"],
265
+ }, owner, repo);
266
+ return result.number;
267
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Relevance detection for upstream release changes
3
+ * Matches changes against sequant's baseline to identify relevant items
4
+ */
5
+ import type { Baseline, DetectionPatterns, Finding, FindingCategory, ImpactLevel } from "./types.js";
6
+ /**
7
+ * Default detection patterns for categorizing changes
8
+ */
9
+ export declare const DEFAULT_PATTERNS: DetectionPatterns;
10
+ /**
11
+ * Extract individual change items from release body
12
+ * Handles various markdown formats
13
+ */
14
+ export declare function extractChanges(releaseBody: string): string[];
15
+ /**
16
+ * Check if a change matches any keywords from baseline
17
+ */
18
+ export declare function matchKeywords(change: string, keywords: string[]): string[];
19
+ /**
20
+ * Check which detection patterns match a change
21
+ */
22
+ export declare function matchPatterns(change: string, patterns?: DetectionPatterns): string[];
23
+ /**
24
+ * Determine the category of a change based on matched patterns
25
+ */
26
+ export declare function categorizeChange(matchedPatterns: string[]): FindingCategory;
27
+ /**
28
+ * Determine impact level based on category and matched keywords
29
+ */
30
+ export declare function determineImpact(category: FindingCategory, matchedKeywords: string[]): ImpactLevel;
31
+ /**
32
+ * Get affected sequant files from dependency map
33
+ */
34
+ export declare function getImpactFiles(matchedKeywords: string[], dependencyMap: Record<string, string[]>): string[];
35
+ /**
36
+ * Generate a title for a finding
37
+ */
38
+ export declare function generateTitle(category: FindingCategory, change: string): string;
39
+ /**
40
+ * Analyze a single change against the baseline
41
+ */
42
+ export declare function analyzeChange(change: string, baseline: Baseline): Finding;
43
+ /**
44
+ * Analyze all changes from a release
45
+ */
46
+ export declare function analyzeRelease(releaseBody: string, baseline: Baseline): Finding[];
47
+ /**
48
+ * Filter findings to only actionable ones (not no-action)
49
+ */
50
+ export declare function getActionableFindings(findings: Finding[]): Finding[];
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Relevance detection for upstream release changes
3
+ * Matches changes against sequant's baseline to identify relevant items
4
+ */
5
+ /**
6
+ * Default detection patterns for categorizing changes
7
+ */
8
+ export const DEFAULT_PATTERNS = {
9
+ newTool: /\b(added?|new|introduc(e|ing|ed))\b.*\btool\b/i,
10
+ deprecation: /\b(deprecat(e|ed|ing|ion)|remov(e|ed|ing)|no longer support)/i,
11
+ breaking: /\b(breaking|incompatible|must update|require(s|d) migration)/i,
12
+ hook: /\b(hook|PreToolUse|PostToolUse|pre-tool|post-tool)\b/i,
13
+ permission: /\b(permission|allow(ed)?|deny|denied|ask|consent|approve|reject)\b/i,
14
+ mcp: /\b(MCP|model context protocol|mcp server)\b/i,
15
+ };
16
+ /**
17
+ * Extract individual change items from release body
18
+ * Handles various markdown formats
19
+ */
20
+ export function extractChanges(releaseBody) {
21
+ const changes = [];
22
+ const lines = releaseBody.split("\n");
23
+ for (const line of lines) {
24
+ const trimmed = line.trim();
25
+ // Skip empty lines and headers
26
+ if (!trimmed || trimmed.startsWith("#")) {
27
+ continue;
28
+ }
29
+ // Match bullet points (-, *, +)
30
+ const bulletMatch = trimmed.match(/^[-*+]\s+(.+)$/);
31
+ if (bulletMatch) {
32
+ changes.push(bulletMatch[1].trim());
33
+ continue;
34
+ }
35
+ // Match numbered items
36
+ const numberedMatch = trimmed.match(/^\d+\.\s+(.+)$/);
37
+ if (numberedMatch) {
38
+ changes.push(numberedMatch[1].trim());
39
+ }
40
+ }
41
+ return changes;
42
+ }
43
+ /**
44
+ * Check if a change matches any keywords from baseline
45
+ */
46
+ export function matchKeywords(change, keywords) {
47
+ const matched = [];
48
+ for (const keyword of keywords) {
49
+ // Create word boundary regex for keyword
50
+ const pattern = new RegExp(`\\b${escapeRegex(keyword)}\\b`, "i");
51
+ if (pattern.test(change)) {
52
+ matched.push(keyword);
53
+ }
54
+ }
55
+ return matched;
56
+ }
57
+ /**
58
+ * Check which detection patterns match a change
59
+ */
60
+ export function matchPatterns(change, patterns = DEFAULT_PATTERNS) {
61
+ const matched = [];
62
+ for (const [name, pattern] of Object.entries(patterns)) {
63
+ if (pattern.test(change)) {
64
+ matched.push(name);
65
+ }
66
+ }
67
+ return matched;
68
+ }
69
+ /**
70
+ * Determine the category of a change based on matched patterns
71
+ */
72
+ export function categorizeChange(matchedPatterns) {
73
+ // Priority order: breaking > deprecation > new-tool > hook > opportunity > no-action
74
+ if (matchedPatterns.includes("breaking")) {
75
+ return "breaking";
76
+ }
77
+ if (matchedPatterns.includes("deprecation")) {
78
+ return "deprecation";
79
+ }
80
+ if (matchedPatterns.includes("newTool")) {
81
+ return "new-tool";
82
+ }
83
+ if (matchedPatterns.includes("hook")) {
84
+ return "hook-change";
85
+ }
86
+ // If any keywords matched but no specific pattern, it's an opportunity
87
+ if (matchedPatterns.length > 0) {
88
+ return "opportunity";
89
+ }
90
+ return "no-action";
91
+ }
92
+ /**
93
+ * Determine impact level based on category and matched keywords
94
+ */
95
+ export function determineImpact(category, matchedKeywords) {
96
+ // Breaking changes are always high impact
97
+ if (category === "breaking") {
98
+ return "high";
99
+ }
100
+ // Deprecations are medium to high depending on what's affected
101
+ if (category === "deprecation") {
102
+ const criticalKeywords = [
103
+ "hook",
104
+ "PreToolUse",
105
+ "PostToolUse",
106
+ "permission",
107
+ ];
108
+ if (matchedKeywords.some((k) => criticalKeywords.includes(k))) {
109
+ return "high";
110
+ }
111
+ return "medium";
112
+ }
113
+ // Hook changes can be significant
114
+ if (category === "hook-change") {
115
+ return "medium";
116
+ }
117
+ // New tools and opportunities are lower priority
118
+ if (category === "new-tool" || category === "opportunity") {
119
+ return "low";
120
+ }
121
+ return "none";
122
+ }
123
+ /**
124
+ * Get affected sequant files from dependency map
125
+ */
126
+ export function getImpactFiles(matchedKeywords, dependencyMap) {
127
+ const files = new Set();
128
+ for (const keyword of matchedKeywords) {
129
+ const mappedFiles = dependencyMap[keyword];
130
+ if (mappedFiles) {
131
+ for (const file of mappedFiles) {
132
+ files.add(file);
133
+ }
134
+ }
135
+ }
136
+ return Array.from(files);
137
+ }
138
+ /**
139
+ * Generate a title for a finding
140
+ */
141
+ export function generateTitle(category, change) {
142
+ // Truncate long changes
143
+ const maxLength = 80;
144
+ const title = change.length > maxLength ? change.slice(0, maxLength) + "..." : change;
145
+ // Add prefix based on category
146
+ switch (category) {
147
+ case "breaking":
148
+ return `BREAKING: ${title}`;
149
+ case "deprecation":
150
+ return `Deprecated: ${title}`;
151
+ case "new-tool":
152
+ return `New tool: ${title}`;
153
+ case "hook-change":
154
+ return `Hook change: ${title}`;
155
+ case "opportunity":
156
+ return title;
157
+ default:
158
+ return title;
159
+ }
160
+ }
161
+ /**
162
+ * Analyze a single change against the baseline
163
+ */
164
+ export function analyzeChange(change, baseline) {
165
+ // Match keywords and patterns
166
+ const matchedKeywords = matchKeywords(change, baseline.keywords);
167
+ const matchedPatterns = matchPatterns(change);
168
+ // Combine for categorization (keywords count as pattern matches for opportunity detection)
169
+ const allMatches = [
170
+ ...matchedPatterns,
171
+ ...(matchedKeywords.length > 0 ? ["keywords"] : []),
172
+ ];
173
+ // Categorize
174
+ const category = categorizeChange(allMatches);
175
+ // Determine impact
176
+ const impact = determineImpact(category, matchedKeywords);
177
+ // Get affected files
178
+ const sequantFiles = getImpactFiles(matchedKeywords, baseline.dependencyMap);
179
+ // Generate title
180
+ const title = generateTitle(category, change);
181
+ return {
182
+ category,
183
+ title,
184
+ description: change,
185
+ impact,
186
+ matchedKeywords,
187
+ matchedPatterns,
188
+ sequantFiles,
189
+ };
190
+ }
191
+ /**
192
+ * Analyze all changes from a release
193
+ */
194
+ export function analyzeRelease(releaseBody, baseline) {
195
+ const changes = extractChanges(releaseBody);
196
+ return changes.map((change) => analyzeChange(change, baseline));
197
+ }
198
+ /**
199
+ * Filter findings to only actionable ones (not no-action)
200
+ */
201
+ export function getActionableFindings(findings) {
202
+ return findings.filter((f) => f.category !== "no-action");
203
+ }
204
+ /**
205
+ * Escape special regex characters in a string
206
+ */
207
+ function escapeRegex(str) {
208
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
209
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Report generation for upstream assessments
3
+ * Creates markdown reports for GitHub issues and local files
4
+ */
5
+ import type { AssessmentSummary, Finding, UpstreamAssessment, BatchedAssessment } from "./types.js";
6
+ /**
7
+ * Calculate summary counts from findings
8
+ */
9
+ export declare function calculateSummary(findings: Finding[]): AssessmentSummary;
10
+ /**
11
+ * Generate the assessment issue body
12
+ */
13
+ export declare function generateAssessmentReport(assessment: UpstreamAssessment): string;
14
+ /**
15
+ * Generate an individual issue body for an actionable finding
16
+ */
17
+ export declare function generateFindingIssue(finding: Finding, version: string, assessmentIssueNumber?: number): {
18
+ title: string;
19
+ body: string;
20
+ labels: string[];
21
+ };
22
+ /**
23
+ * Generate a batched summary issue for multiple versions
24
+ */
25
+ export declare function generateBatchedSummaryReport(batched: BatchedAssessment): string;
26
+ /**
27
+ * Generate local report markdown file
28
+ */
29
+ export declare function generateLocalReport(assessment: UpstreamAssessment): string;