edsger 0.42.1 → 0.44.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.
Files changed (98) hide show
  1. package/.claude/settings.local.json +23 -3
  2. package/.env.local +12 -0
  3. package/dist/api/release-test-cases.d.ts +7 -0
  4. package/dist/api/release-test-cases.js +21 -0
  5. package/dist/api/releases.d.ts +41 -0
  6. package/dist/api/releases.js +31 -0
  7. package/dist/api/web-deploy.d.ts +8 -1
  8. package/dist/api/web-deploy.js +2 -1
  9. package/dist/commands/release-sync/index.d.ts +5 -0
  10. package/dist/commands/release-sync/index.js +38 -0
  11. package/dist/commands/smoke-test/index.d.ts +5 -0
  12. package/dist/commands/smoke-test/index.js +40 -0
  13. package/dist/commands/workflow/phase-orchestrator.js +3 -1
  14. package/dist/index.js +40 -0
  15. package/dist/phases/app-store-generation/__tests__/screenshot-composer.test.js +1 -0
  16. package/dist/phases/app-store-generation/index.js +3 -1
  17. package/dist/phases/app-store-generation/screenshot-composer.js +34 -10
  18. package/dist/phases/branch-planning/index.js +3 -1
  19. package/dist/phases/bug-fixing/analyzer.js +3 -1
  20. package/dist/phases/code-implementation/index.js +3 -1
  21. package/dist/phases/code-refine/index.js +3 -1
  22. package/dist/phases/code-review/__tests__/diff-utils.test.js +11 -11
  23. package/dist/phases/code-review/index.js +3 -1
  24. package/dist/phases/code-testing/analyzer.js +3 -1
  25. package/dist/phases/feature-analysis/index.js +3 -1
  26. package/dist/phases/functional-testing/analyzer.js +3 -1
  27. package/dist/phases/growth-analysis/index.js +3 -1
  28. package/dist/phases/intelligence-analysis/__tests__/orchestration.test.js +12 -12
  29. package/dist/phases/intelligence-analysis/agent.js +2 -0
  30. package/dist/phases/intelligence-analysis/index.js +1 -0
  31. package/dist/phases/intelligence-analysis/prompts.js +11 -1
  32. package/dist/phases/output-contracts.js +1 -0
  33. package/dist/phases/pr-execution/__tests__/file-assigner.test.js +22 -13
  34. package/dist/phases/pr-execution/context.js +4 -2
  35. package/dist/phases/pr-execution/file-assigner.js +1 -0
  36. package/dist/phases/pr-resolve/__tests__/checklist-learner.test.js +11 -11
  37. package/dist/phases/pr-resolve/__tests__/prompts.test.js +12 -12
  38. package/dist/phases/pr-resolve/__tests__/resolve-mapping.test.js +6 -6
  39. package/dist/phases/pr-resolve/__tests__/types.test.js +11 -11
  40. package/dist/phases/pr-resolve/__tests__/workspace.test.js +13 -13
  41. package/dist/phases/pr-resolve/checklist-learner.js +34 -9
  42. package/dist/phases/pr-resolve/index.js +29 -13
  43. package/dist/phases/pr-resolve/prompts.js +2 -1
  44. package/dist/phases/pr-resolve/workspace.d.ts +12 -2
  45. package/dist/phases/pr-resolve/workspace.js +6 -4
  46. package/dist/phases/pr-review/__tests__/prompts.test.js +9 -9
  47. package/dist/phases/pr-review/__tests__/review-comments.test.js +6 -6
  48. package/dist/phases/pr-review/index.js +1 -0
  49. package/dist/phases/pr-shared/__tests__/agent-utils.test.js +17 -17
  50. package/dist/phases/pr-shared/__tests__/context.test.js +12 -12
  51. package/dist/phases/pr-splitting/import-dep-validator.js +14 -6
  52. package/dist/phases/pr-splitting/index.js +3 -1
  53. package/dist/phases/release-sync/__tests__/github.test.d.ts +9 -0
  54. package/dist/phases/release-sync/__tests__/github.test.js +123 -0
  55. package/dist/phases/release-sync/__tests__/snapshot.test.d.ts +8 -0
  56. package/dist/phases/release-sync/__tests__/snapshot.test.js +93 -0
  57. package/dist/phases/release-sync/github.d.ts +54 -0
  58. package/dist/phases/release-sync/github.js +101 -0
  59. package/dist/phases/release-sync/index.d.ts +24 -0
  60. package/dist/phases/release-sync/index.js +147 -0
  61. package/dist/phases/release-sync/snapshot.d.ts +27 -0
  62. package/dist/phases/release-sync/snapshot.js +159 -0
  63. package/dist/phases/smoke-test/__tests__/agent.test.d.ts +4 -0
  64. package/dist/phases/smoke-test/__tests__/agent.test.js +85 -0
  65. package/dist/phases/smoke-test/agent.d.ts +12 -0
  66. package/dist/phases/smoke-test/agent.js +94 -0
  67. package/dist/phases/smoke-test/index.d.ts +22 -0
  68. package/dist/phases/smoke-test/index.js +233 -0
  69. package/dist/phases/smoke-test/prompts.d.ts +15 -0
  70. package/dist/phases/smoke-test/prompts.js +35 -0
  71. package/dist/phases/technical-design/index.js +3 -1
  72. package/dist/phases/test-cases-analysis/index.js +3 -1
  73. package/dist/phases/user-stories-analysis/index.js +3 -1
  74. package/dist/services/phase-hooks/__tests__/hook-executor.test.js +7 -4
  75. package/dist/services/phase-hooks/__tests__/hook-runner.test.js +22 -21
  76. package/dist/services/phase-hooks/hook-executor.js +1 -0
  77. package/dist/services/phase-hooks/plugin-loader.js +3 -0
  78. package/dist/services/video/screenshot-generator.js +8 -2
  79. package/dist/skills/phase/smoke-test/SKILL.md +80 -0
  80. package/dist/utils/json-extract.d.ts +6 -0
  81. package/dist/utils/json-extract.js +44 -0
  82. package/dist/workspace/__tests__/workspace-manager.test.d.ts +7 -0
  83. package/dist/workspace/__tests__/workspace-manager.test.js +52 -0
  84. package/dist/workspace/workspace-manager.d.ts +31 -0
  85. package/dist/workspace/workspace-manager.js +96 -10
  86. package/package.json +1 -1
  87. package/dist/services/lifecycle-agent/__tests__/phase-criteria.test.d.ts +0 -4
  88. package/dist/services/lifecycle-agent/__tests__/phase-criteria.test.js +0 -133
  89. package/dist/services/lifecycle-agent/__tests__/transition-rules.test.d.ts +0 -4
  90. package/dist/services/lifecycle-agent/__tests__/transition-rules.test.js +0 -336
  91. package/dist/services/lifecycle-agent/index.d.ts +0 -24
  92. package/dist/services/lifecycle-agent/index.js +0 -25
  93. package/dist/services/lifecycle-agent/phase-criteria.d.ts +0 -57
  94. package/dist/services/lifecycle-agent/phase-criteria.js +0 -335
  95. package/dist/services/lifecycle-agent/transition-rules.d.ts +0 -60
  96. package/dist/services/lifecycle-agent/transition-rules.js +0 -184
  97. package/dist/services/lifecycle-agent/types.d.ts +0 -190
  98. package/dist/services/lifecycle-agent/types.js +0 -12
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Snapshot detection: after the latest GitHub release already has a
3
+ * smoke-test plan, inspect the cloned repo for a yet-to-be-released
4
+ * "snapshot" version (next package.json version, an [Unreleased] entry
5
+ * in CHANGELOG, an unreleased git tag, etc.) so we can prepare the
6
+ * next smoke test before anyone cuts the tag on GitHub.
7
+ */
8
+ import { query } from '@anthropic-ai/claude-agent-sdk';
9
+ import { DEFAULT_MODEL } from '../../constants.js';
10
+ import { findBalancedJsonObject } from '../../utils/json-extract.js';
11
+ import { logDebug, logInfo } from '../../utils/logger.js';
12
+ export function buildSnapshotDetectionPrompt(latestReleaseTag) {
13
+ return `You are inspecting a code repository to decide whether an unreleased "snapshot" version is being prepared.
14
+
15
+ The most recent shipped release on GitHub is tagged: **${latestReleaseTag}**
16
+
17
+ Your job:
18
+
19
+ 1. Read the repository's primary version source. In priority order:
20
+ - package.json "version" field (JS/TS projects)
21
+ - Cargo.toml [package] version (Rust)
22
+ - pyproject.toml [project] / [tool.poetry] version (Python)
23
+ - pom.xml <version> (Java / Maven)
24
+ - VERSION file (plain text)
25
+ 2. Read CHANGELOG.md / HISTORY.md / RELEASES.md for an [Unreleased] or similarly labelled section.
26
+ 3. Run \`git tag --list\` and \`git tag --sort=-creatordate | head -5\` to look for tags newer than ${latestReleaseTag} that have not yet been cut as GitHub releases.
27
+
28
+ Then decide:
29
+ - If the primary version source is strictly greater than ${latestReleaseTag} (accounting for "v" prefixes and semver prerelease suffixes like \`-SNAPSHOT\`, \`-rc.1\`, \`-next.0\`), OR there is a newer unreleased git tag, report that string as \`snapshot_tag\`.
30
+ - Otherwise report \`snapshot_tag: null\`.
31
+ - Do NOT invent a version. Use the exact string from the source file. Preserve the repo's existing tag convention (e.g. if releases are \`v1.2.3\` but package.json says \`1.2.3\`, return \`v1.2.3\`).
32
+
33
+ Respond with ONLY a JSON object — no prose, no markdown fences:
34
+
35
+ {
36
+ "snapshot_tag": "v2.0.0" | null,
37
+ "source": "package.json" | "Cargo.toml" | "pyproject.toml" | "pom.xml" | "VERSION" | "CHANGELOG" | "git_tag" | null,
38
+ "reasoning": "<one sentence>"
39
+ }`;
40
+ }
41
+ export function parseSnapshotDetection(raw) {
42
+ let body = raw.trim();
43
+ const fence = body.match(/```(?:json)?\s*([\s\S]*?)```/);
44
+ if (fence) {
45
+ body = fence[1].trim();
46
+ }
47
+ let parsed;
48
+ try {
49
+ parsed = JSON.parse(body);
50
+ }
51
+ catch {
52
+ const object = findBalancedJsonObject(body);
53
+ if (!object) {
54
+ throw new Error('No JSON object found in snapshot-detection output');
55
+ }
56
+ parsed = JSON.parse(object);
57
+ }
58
+ if (typeof parsed !== 'object' || parsed === null) {
59
+ throw new Error('Snapshot detection returned non-object response');
60
+ }
61
+ const obj = parsed;
62
+ const rawTag = obj.snapshot_tag;
63
+ const snapshotTag = typeof rawTag === 'string' && rawTag.trim().length > 0
64
+ ? rawTag.trim()
65
+ : null;
66
+ const source = typeof obj.source === 'string' ? obj.source : null;
67
+ const reasoning = typeof obj.reasoning === 'string' ? obj.reasoning : '(no reasoning given)';
68
+ return { snapshot_tag: snapshotTag, source, reasoning };
69
+ }
70
+ /**
71
+ * Very loose sanity check on a proposed snapshot tag. We reject tags
72
+ * that are obviously the same as the latest release or that contain
73
+ * characters that would break downstream git / GitHub API calls.
74
+ */
75
+ export function isPlausibleSnapshotTag(candidate, latestReleaseTag) {
76
+ if (candidate === latestReleaseTag) {
77
+ return false;
78
+ }
79
+ if (candidate.length === 0 || candidate.length > 100) {
80
+ return false;
81
+ }
82
+ if (/\s/.test(candidate)) {
83
+ return false;
84
+ }
85
+ if (/^[-.]/.test(candidate) ||
86
+ candidate.includes('..') ||
87
+ candidate.includes('@{')) {
88
+ return false;
89
+ }
90
+ return /^[A-Za-z0-9._\-+/@]+$/.test(candidate);
91
+ }
92
+ function userMessage(content) {
93
+ return { type: 'user', message: { role: 'user', content } };
94
+ }
95
+ // eslint-disable-next-line @typescript-eslint/require-await -- async generator required by SDK interface
96
+ async function* makePrompt(text) {
97
+ yield userMessage(text);
98
+ }
99
+ // eslint-disable-next-line complexity -- agent loop with message-type handling
100
+ export async function detectSnapshotVersion(options) {
101
+ const { cwd, latestReleaseTag, verbose } = options;
102
+ if (verbose) {
103
+ logInfo(`Detecting snapshot version ahead of ${latestReleaseTag}...`);
104
+ }
105
+ let lastAssistant = '';
106
+ let detection = null;
107
+ for await (const message of query({
108
+ prompt: makePrompt(buildSnapshotDetectionPrompt(latestReleaseTag)),
109
+ options: {
110
+ systemPrompt: {
111
+ type: 'preset',
112
+ preset: 'claude_code',
113
+ },
114
+ model: DEFAULT_MODEL,
115
+ maxTurns: 10,
116
+ permissionMode: 'bypassPermissions',
117
+ cwd,
118
+ },
119
+ })) {
120
+ if (message.type === 'assistant' && message.message?.content) {
121
+ for (const content of message.message.content) {
122
+ if (content.type === 'text') {
123
+ lastAssistant += `${content.text}\n`;
124
+ logDebug(content.text, verbose);
125
+ }
126
+ }
127
+ }
128
+ if (message.type === 'result') {
129
+ const text = ('result' in message ? message.result : '') || lastAssistant;
130
+ try {
131
+ detection = parseSnapshotDetection(text);
132
+ }
133
+ catch (err) {
134
+ if (verbose) {
135
+ logDebug(`Snapshot detection parse error: ${err instanceof Error ? err.message : String(err)}`, verbose);
136
+ }
137
+ }
138
+ }
139
+ }
140
+ if (!detection) {
141
+ return {
142
+ snapshot_tag: null,
143
+ source: null,
144
+ reasoning: 'Could not parse detection response',
145
+ };
146
+ }
147
+ // Validate the proposed tag before returning.
148
+ if (detection.snapshot_tag !== null &&
149
+ !isPlausibleSnapshotTag(detection.snapshot_tag, latestReleaseTag)) {
150
+ if (verbose) {
151
+ logInfo(`Rejecting implausible snapshot tag: ${JSON.stringify(detection.snapshot_tag)}`);
152
+ }
153
+ return {
154
+ ...detection,
155
+ snapshot_tag: null,
156
+ };
157
+ }
158
+ return detection;
159
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Unit tests for smoke-test agent response parsing.
3
+ */
4
+ export {};
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Unit tests for smoke-test agent response parsing.
3
+ */
4
+ import assert from 'node:assert';
5
+ import { describe, it } from 'node:test';
6
+ import { findBalancedJsonObject } from '../../../utils/json-extract.js';
7
+ import { extractJson } from '../agent.js';
8
+ void describe('extractJson', () => {
9
+ void it('parses a plain JSON object', () => {
10
+ const raw = `{
11
+ "summary": "one thing changed",
12
+ "test_cases": [
13
+ { "name": "login still works", "description": "step 1", "is_critical": true }
14
+ ]
15
+ }`;
16
+ const parsed = extractJson(raw);
17
+ assert.strictEqual(parsed.summary, 'one thing changed');
18
+ assert.strictEqual(parsed.test_cases.length, 1);
19
+ assert.strictEqual(parsed.test_cases[0].is_critical, true);
20
+ });
21
+ void it('strips ```json fences', () => {
22
+ const raw = '```json\n{"summary":"x","test_cases":[{"name":"a","description":"b"}]}\n```';
23
+ const parsed = extractJson(raw);
24
+ assert.strictEqual(parsed.summary, 'x');
25
+ assert.strictEqual(parsed.test_cases[0].name, 'a');
26
+ });
27
+ void it('strips unlabeled fences', () => {
28
+ const raw = '```\n{"summary":"x","test_cases":[]}\n```';
29
+ const parsed = extractJson(raw);
30
+ assert.deepStrictEqual(parsed.test_cases, []);
31
+ });
32
+ void it('tolerates leading and trailing prose', () => {
33
+ const raw = `Here you go:
34
+ {"summary":"x","test_cases":[{"name":"a","description":"b"}]}
35
+
36
+ Hope that helps.`;
37
+ const parsed = extractJson(raw);
38
+ assert.strictEqual(parsed.summary, 'x');
39
+ });
40
+ void it('throws when test_cases is missing', () => {
41
+ assert.throws(() => extractJson('{"summary":"x"}'), /test_cases/);
42
+ });
43
+ void it('throws on invalid JSON', () => {
44
+ assert.throws(() => extractJson('not json'));
45
+ });
46
+ void it('picks the first balanced object when prose contains decoy braces', () => {
47
+ const raw = 'Note: { pseudo-json example } but the real answer is:\n' +
48
+ '{"summary":"x","test_cases":[{"name":"a","description":"b"}]}\n' +
49
+ 'Let me know if you want changes.';
50
+ const parsed = extractJson(raw);
51
+ assert.strictEqual(parsed.summary, 'x');
52
+ assert.strictEqual(parsed.test_cases[0].name, 'a');
53
+ });
54
+ void it('preserves braces inside JSON string values', () => {
55
+ const raw = '{"summary":"changes to {pricing} and {checkout}","test_cases":[{"name":"n","description":"d"}]}';
56
+ const parsed = extractJson(raw);
57
+ assert.strictEqual(parsed.summary, 'changes to {pricing} and {checkout}');
58
+ });
59
+ void it('throws when test_cases is not an array', () => {
60
+ assert.throws(() => extractJson('{"summary":"x","test_cases":"nope"}'), /test_cases/);
61
+ });
62
+ });
63
+ void describe('findBalancedJsonObject', () => {
64
+ void it('returns null when there is no opening brace', () => {
65
+ assert.strictEqual(findBalancedJsonObject('no braces here'), null);
66
+ });
67
+ void it('returns the first balanced top-level object', () => {
68
+ assert.strictEqual(findBalancedJsonObject('prefix {"a": 1} and {"b": 2} suffix'), '{"a": 1}');
69
+ });
70
+ void it('handles nested braces', () => {
71
+ const text = 'xxx {"a": {"b": {"c": 1}}} yyy';
72
+ assert.strictEqual(findBalancedJsonObject(text), '{"a": {"b": {"c": 1}}}');
73
+ });
74
+ void it('ignores braces inside strings', () => {
75
+ const text = '{"msg": "this has { and } inside", "ok": true}';
76
+ assert.strictEqual(findBalancedJsonObject(text), text);
77
+ });
78
+ void it('handles escaped quotes inside strings', () => {
79
+ const text = '{"msg": "she said \\"hi\\" {not object}"}';
80
+ assert.strictEqual(findBalancedJsonObject(text), text);
81
+ });
82
+ void it('returns null on unbalanced input', () => {
83
+ assert.strictEqual(findBalancedJsonObject('{"a": 1'), null);
84
+ });
85
+ });
@@ -0,0 +1,12 @@
1
+ import { type EdsgerConfig } from '../../types/index.js';
2
+ export interface GeneratedSmokeTestPlan {
3
+ summary: string;
4
+ test_cases: {
5
+ name: string;
6
+ description: string;
7
+ is_critical?: boolean;
8
+ }[];
9
+ }
10
+ /** @internal Exported for unit tests only. */
11
+ export declare function extractJson(raw: string): GeneratedSmokeTestPlan;
12
+ export declare function executeSmokeTestQuery(systemPrompt: string, userPrompt: string, config: EdsgerConfig, verbose?: boolean, cwd?: string): Promise<GeneratedSmokeTestPlan>;
@@ -0,0 +1,94 @@
1
+ import { query } from '@anthropic-ai/claude-agent-sdk';
2
+ import { DEFAULT_MODEL } from '../../constants.js';
3
+ import { findBalancedJsonObject } from '../../utils/json-extract.js';
4
+ import { logDebug, logError, logInfo } from '../../utils/logger.js';
5
+ function userMessage(content) {
6
+ return { type: 'user', message: { role: 'user', content } };
7
+ }
8
+ // eslint-disable-next-line @typescript-eslint/require-await -- async generator required by SDK interface
9
+ async function* makePrompt(text) {
10
+ yield userMessage(text);
11
+ }
12
+ /** @internal Exported for unit tests only. */
13
+ export function extractJson(raw) {
14
+ let body = raw.trim();
15
+ const fence = body.match(/```(?:json)?\s*([\s\S]*?)```/);
16
+ if (fence) {
17
+ body = fence[1].trim();
18
+ }
19
+ // Try parsing the whole body first — if the model returned clean JSON
20
+ // it round-trips without needing to slice.
21
+ let parsed;
22
+ try {
23
+ parsed = JSON.parse(body);
24
+ }
25
+ catch {
26
+ const object = findBalancedJsonObject(body);
27
+ if (!object) {
28
+ throw new Error('No JSON object found in model output');
29
+ }
30
+ parsed = JSON.parse(object);
31
+ }
32
+ if (typeof parsed !== 'object' ||
33
+ parsed === null ||
34
+ !Array.isArray(parsed.test_cases)) {
35
+ throw new Error('Model response is missing test_cases array');
36
+ }
37
+ return parsed;
38
+ }
39
+ // eslint-disable-next-line complexity -- agent loop with message type handling
40
+ export async function executeSmokeTestQuery(systemPrompt, userPrompt, config, verbose, cwd) {
41
+ let lastAssistant = '';
42
+ let plan = null;
43
+ let parseError = null;
44
+ let turnCount = 0;
45
+ if (verbose) {
46
+ logInfo('Connecting to Claude Code for smoke-test generation...');
47
+ }
48
+ for await (const message of query({
49
+ prompt: makePrompt(userPrompt),
50
+ options: {
51
+ systemPrompt: {
52
+ type: 'preset',
53
+ preset: 'claude_code',
54
+ append: systemPrompt,
55
+ },
56
+ model: DEFAULT_MODEL,
57
+ maxTurns: 20,
58
+ permissionMode: 'bypassPermissions',
59
+ ...(cwd ? { cwd } : {}),
60
+ },
61
+ })) {
62
+ if (message.type === 'assistant' && message.message?.content) {
63
+ turnCount++;
64
+ for (const content of message.message.content) {
65
+ if (content.type === 'text') {
66
+ lastAssistant += `${content.text}\n`;
67
+ logDebug(content.text, verbose);
68
+ }
69
+ else if (content.type === 'tool_use') {
70
+ const desc = content.input?.description || content.input?.command || 'Running...';
71
+ if (verbose) {
72
+ logInfo(`[Turn ${turnCount}] ${content.name}: ${typeof desc === 'string' ? desc.slice(0, 120) : 'Running...'}`);
73
+ }
74
+ }
75
+ }
76
+ }
77
+ if (message.type === 'result') {
78
+ const text = ('result' in message ? message.result : '') || lastAssistant;
79
+ try {
80
+ plan = extractJson(text);
81
+ }
82
+ catch (err) {
83
+ parseError = err instanceof Error ? err.message : String(err);
84
+ }
85
+ if (message.subtype !== 'success') {
86
+ logError(`Smoke-test query incomplete: ${message.subtype}`);
87
+ }
88
+ }
89
+ }
90
+ if (!plan) {
91
+ throw new Error(`Failed to parse smoke-test plan from model output: ${parseError ?? 'no result message received'}`);
92
+ }
93
+ return plan;
94
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Smoke-test generation for a single release row. Given a release that has
3
+ * already been synced into the `releases` table (see `phases/release-sync`),
4
+ * this phase clones the repo, diffs against the baseline, and asks Claude
5
+ * to emit a JSON test plan, then persists the test cases and marks the
6
+ * release `ready`.
7
+ */
8
+ import { type EdsgerConfig } from '../../types/index.js';
9
+ export interface SmokeTestOptions {
10
+ releaseId: string;
11
+ verbose?: boolean;
12
+ }
13
+ export interface SmokeTestResult {
14
+ status: 'success' | 'error';
15
+ releaseId: string;
16
+ releaseTag?: string;
17
+ previousReleaseTag?: string | null;
18
+ casesCount?: number;
19
+ summary: string;
20
+ isSnapshot?: boolean;
21
+ }
22
+ export declare function runSmokeTest(options: SmokeTestOptions, config: EdsgerConfig): Promise<SmokeTestResult>;
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Smoke-test generation for a single release row. Given a release that has
3
+ * already been synced into the `releases` table (see `phases/release-sync`),
4
+ * this phase clones the repo, diffs against the baseline, and asks Claude
5
+ * to emit a JSON test plan, then persists the test cases and marks the
6
+ * release `ready`.
7
+ */
8
+ import { getGitHubConfigByProduct } from '../../api/github.js';
9
+ import { getProduct } from '../../api/products.js';
10
+ import { clearReleaseTestCases, createReleaseTestCases, } from '../../api/release-test-cases.js';
11
+ import { getRelease, updateRelease } from '../../api/releases.js';
12
+ import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
13
+ import { cloneFeatureRepo, ensureWorkspaceDir, syncRepoToRef, } from '../../workspace/workspace-manager.js';
14
+ import { buildDiffDigest, fetchCompare, getDefaultBranchHead, summariseStats, } from '../release-sync/github.js';
15
+ import { executeSmokeTestQuery } from './agent.js';
16
+ import { buildSmokeTestUserPrompt, createSmokeTestSystemPrompt, } from './prompts.js';
17
+ function isSnapshotRelease(release) {
18
+ return !release.published_at && !release.url;
19
+ }
20
+ // eslint-disable-next-line complexity -- orchestration spans GitHub + DB + AI
21
+ export async function runSmokeTest(options, config) {
22
+ const { releaseId, verbose } = options;
23
+ if (verbose) {
24
+ logInfo(`Starting smoke-test generation for release: ${releaseId}`);
25
+ }
26
+ let release;
27
+ try {
28
+ release = await getRelease(releaseId, verbose);
29
+ }
30
+ catch (err) {
31
+ const message = err instanceof Error ? err.message : String(err);
32
+ return {
33
+ status: 'error',
34
+ releaseId,
35
+ summary: `Failed to load release: ${message}`,
36
+ };
37
+ }
38
+ const gh = await getGitHubConfigByProduct(release.product_id, verbose);
39
+ if (!gh.configured || !gh.token || !gh.owner || !gh.repo) {
40
+ return {
41
+ status: 'error',
42
+ releaseId,
43
+ summary: gh.message ||
44
+ 'Product is not connected to a GitHub repository. Connect it in Product Settings.',
45
+ };
46
+ }
47
+ const ghResolved = { owner: gh.owner, repo: gh.repo, token: gh.token };
48
+ const isSnapshot = isSnapshotRelease(release);
49
+ // Mark release as generating up front so the UI reflects progress.
50
+ let markedGenerating = false;
51
+ try {
52
+ await updateRelease({ release_id: release.id, status: 'generating', generation_error: null }, verbose);
53
+ markedGenerating = true;
54
+ }
55
+ catch (err) {
56
+ logWarning(`Could not mark release as generating: ${err instanceof Error ? err.message : String(err)}`);
57
+ }
58
+ try {
59
+ return await runSmokeTestInner({
60
+ release,
61
+ gh: ghResolved,
62
+ isSnapshot,
63
+ config,
64
+ verbose,
65
+ });
66
+ }
67
+ catch (err) {
68
+ // Safety net: an uncaught throw past the `generating` mark would leave the
69
+ // release row stuck. Flip it to `failed` so the UI isn't permanently stale.
70
+ const message = err instanceof Error ? err.message : String(err);
71
+ if (markedGenerating) {
72
+ try {
73
+ await updateRelease({
74
+ release_id: release.id,
75
+ status: 'failed',
76
+ generation_error: `Unexpected error: ${message}`,
77
+ }, verbose);
78
+ }
79
+ catch {
80
+ // best effort
81
+ }
82
+ }
83
+ logError(`Smoke-test generation failed: ${message}`);
84
+ return {
85
+ status: 'error',
86
+ releaseId: release.id,
87
+ releaseTag: release.tag,
88
+ summary: message,
89
+ };
90
+ }
91
+ }
92
+ // eslint-disable-next-line complexity -- orchestration spans GitHub + DB + AI
93
+ async function runSmokeTestInner(ctx) {
94
+ const { release, gh: ghResolved, isSnapshot, config, verbose } = ctx;
95
+ // Clone + checkout the ref Claude should read.
96
+ let cwd;
97
+ try {
98
+ const workspaceRoot = ensureWorkspaceDir();
99
+ const { repoPath } = cloneFeatureRepo(workspaceRoot, `release-${release.product_id}`, ghResolved.owner, ghResolved.repo, ghResolved.token);
100
+ cwd = repoPath;
101
+ }
102
+ catch (err) {
103
+ logWarning(`Could not clone repo (continuing without code access): ${err instanceof Error ? err.message : String(err)}`);
104
+ }
105
+ // Decide the diff head. For shipped releases this is the release tag; for
106
+ // snapshots it's the default branch HEAD SHA.
107
+ let diffHead = release.tag;
108
+ if (cwd && isSnapshot) {
109
+ try {
110
+ const head = await getDefaultBranchHead(ghResolved.owner, ghResolved.repo, ghResolved.token);
111
+ syncRepoToRef(cwd, { branch: head.branch }, ghResolved.token);
112
+ diffHead = head.sha;
113
+ }
114
+ catch (err) {
115
+ logWarning(`Could not sync to default branch head: ${err instanceof Error ? err.message : String(err)}`);
116
+ }
117
+ }
118
+ else if (cwd && !isSnapshot) {
119
+ try {
120
+ syncRepoToRef(cwd, { tag: release.tag }, ghResolved.token);
121
+ }
122
+ catch (err) {
123
+ logWarning(`Could not checkout release tag ${release.tag}, continuing on default ref: ${err instanceof Error ? err.message : String(err)}`);
124
+ }
125
+ }
126
+ // Reset non-approved cases so the run is idempotent.
127
+ try {
128
+ await clearReleaseTestCases(release.id, verbose);
129
+ }
130
+ catch (err) {
131
+ logWarning(`Could not clear existing draft cases: ${err instanceof Error ? err.message : String(err)}`);
132
+ }
133
+ // Fetch diff (skip if there is no baseline).
134
+ let diffDigest = '(No baseline to diff against — generate smoke tests covering the entire release notes.)';
135
+ let diffStats = {
136
+ files_changed: 0,
137
+ additions: 0,
138
+ deletions: 0,
139
+ total_commits: 0,
140
+ };
141
+ if (release.previous_tag) {
142
+ try {
143
+ const compare = await fetchCompare(ghResolved.owner, ghResolved.repo, release.previous_tag, diffHead, ghResolved.token);
144
+ diffDigest = buildDiffDigest(compare);
145
+ diffStats = summariseStats(compare);
146
+ }
147
+ catch (err) {
148
+ const message = err instanceof Error ? err.message : String(err);
149
+ await updateRelease({
150
+ release_id: release.id,
151
+ status: 'failed',
152
+ generation_error: `Failed to fetch diff: ${message}`,
153
+ }, verbose);
154
+ return {
155
+ status: 'error',
156
+ releaseId: release.id,
157
+ releaseTag: release.tag,
158
+ summary: `Failed to fetch diff: ${message}`,
159
+ };
160
+ }
161
+ }
162
+ // Pull product metadata for the prompt.
163
+ const product = await getProduct(release.product_id, verbose).catch(() => null);
164
+ const productName = product?.name ?? 'Unknown product';
165
+ const productDescription = product?.description ?? null;
166
+ // Run Claude to produce the test plan.
167
+ let plan;
168
+ try {
169
+ const systemPrompt = await createSmokeTestSystemPrompt(Boolean(cwd), cwd);
170
+ plan = await executeSmokeTestQuery(systemPrompt, buildSmokeTestUserPrompt({
171
+ productName,
172
+ productDescription,
173
+ latestTag: release.tag,
174
+ previousTag: release.previous_tag,
175
+ releaseNotes: release.body ?? null,
176
+ diffDigest,
177
+ }), config, verbose, cwd);
178
+ }
179
+ catch (err) {
180
+ const message = err instanceof Error ? err.message : String(err);
181
+ await updateRelease({ release_id: release.id, status: 'failed', generation_error: message }, verbose);
182
+ logError(`Smoke-test generation failed: ${message}`);
183
+ return {
184
+ status: 'error',
185
+ releaseId: release.id,
186
+ releaseTag: release.tag,
187
+ summary: message,
188
+ };
189
+ }
190
+ let casesCount = 0;
191
+ try {
192
+ casesCount = await createReleaseTestCases(release.id, plan.test_cases.map((c) => ({
193
+ name: c.name,
194
+ description: c.description,
195
+ is_critical: c.is_critical ?? false,
196
+ })), verbose);
197
+ }
198
+ catch (err) {
199
+ const message = err instanceof Error ? err.message : String(err);
200
+ await updateRelease({
201
+ release_id: release.id,
202
+ status: 'failed',
203
+ generation_error: `Failed to insert cases: ${message}`,
204
+ }, verbose);
205
+ return {
206
+ status: 'error',
207
+ releaseId: release.id,
208
+ releaseTag: release.tag,
209
+ summary: `Failed to insert cases: ${message}`,
210
+ };
211
+ }
212
+ await updateRelease({
213
+ release_id: release.id,
214
+ status: 'ready',
215
+ diff_summary: plan.summary,
216
+ diff_stats: diffStats,
217
+ generation_error: null,
218
+ }, verbose);
219
+ logSuccess(`Generated ${casesCount} smoke-test cases for ${release.tag}${isSnapshot
220
+ ? ` (snapshot ahead of ${release.previous_tag ?? 'first release'})`
221
+ : release.previous_tag
222
+ ? ` (vs ${release.previous_tag})`
223
+ : ' (first release)'}`);
224
+ return {
225
+ status: 'success',
226
+ releaseId: release.id,
227
+ releaseTag: release.tag,
228
+ previousReleaseTag: release.previous_tag,
229
+ casesCount,
230
+ summary: plan.summary,
231
+ isSnapshot,
232
+ };
233
+ }
@@ -0,0 +1,15 @@
1
+ export interface SmokeTestPromptInput {
2
+ productName: string;
3
+ productDescription: string | null;
4
+ latestTag: string;
5
+ previousTag: string | null;
6
+ releaseNotes: string | null;
7
+ diffDigest: string;
8
+ }
9
+ /**
10
+ * Load the smoke-test system prompt from the shared SKILL.md library.
11
+ * Keeping the prompt in a skill file means it can also be consumed by
12
+ * Claude Code, other agents, or edited by users in the desktop app.
13
+ */
14
+ export declare function createSmokeTestSystemPrompt(hasCodebase?: boolean, projectDir?: string): Promise<string>;
15
+ export declare function buildSmokeTestUserPrompt(input: SmokeTestPromptInput): string;
@@ -0,0 +1,35 @@
1
+ import { processConditionals, resolveSkill, } from '../../services/skill-resolver.js';
2
+ /**
3
+ * Load the smoke-test system prompt from the shared SKILL.md library.
4
+ * Keeping the prompt in a skill file means it can also be consumed by
5
+ * Claude Code, other agents, or edited by users in the desktop app.
6
+ */
7
+ export async function createSmokeTestSystemPrompt(hasCodebase = false, projectDir) {
8
+ const skill = await resolveSkill('phase/smoke-test', { projectDir });
9
+ if (!skill) {
10
+ throw new Error('Failed to load skill: phase/smoke-test');
11
+ }
12
+ return processConditionals(skill.prompt, { hasCodebase });
13
+ }
14
+ export function buildSmokeTestUserPrompt(input) {
15
+ const productBlock = input.productDescription
16
+ ? `Product: ${input.productName}\nDescription: ${input.productDescription}`
17
+ : `Product: ${input.productName}`;
18
+ const baseBlock = input.previousTag
19
+ ? `Previous release: ${input.previousTag}`
20
+ : 'Previous release: (none — this is the first release)';
21
+ const notesBlock = input.releaseNotes
22
+ ? `Release notes for ${input.latestTag}:\n${input.releaseNotes.slice(0, 4000)}`
23
+ : `Release notes for ${input.latestTag}: (none)`;
24
+ return `${productBlock}
25
+
26
+ Release to ship: ${input.latestTag}
27
+ ${baseBlock}
28
+
29
+ ${notesBlock}
30
+
31
+ Code changes between the two releases:
32
+ ${input.diffDigest}
33
+
34
+ Produce the smoke-test plan as described in the system prompt.`;
35
+ }
@@ -24,7 +24,9 @@ async function* prompt(analysisPrompt) {
24
24
  setTimeout(res, 10000);
25
25
  });
26
26
  }
27
- export const generateTechnicalDesign = async (options, config, checklistContext) => {
27
+ export const generateTechnicalDesign = async (options, config, checklistContext
28
+ // eslint-disable-next-line complexity
29
+ ) => {
28
30
  const { featureId, verbose } = options;
29
31
  if (verbose) {
30
32
  logInfo(`Starting technical design generation for feature ID: ${featureId}`);