edsger 0.44.0 → 0.45.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 (105) hide show
  1. package/dist/api/run-sheets.d.ts +22 -0
  2. package/dist/api/run-sheets.js +13 -0
  3. package/dist/commands/run-sheet/index.d.ts +6 -0
  4. package/dist/commands/run-sheet/index.js +48 -0
  5. package/dist/index.js +22 -0
  6. package/dist/phases/run-sheet/index.d.ts +39 -0
  7. package/dist/phases/run-sheet/index.js +297 -0
  8. package/dist/phases/run-sheet/render.d.ts +42 -0
  9. package/dist/phases/run-sheet/render.js +133 -0
  10. package/package.json +11 -4
  11. package/tsconfig.build.json +4 -0
  12. package/tsconfig.json +3 -8
  13. package/vitest.config.ts +12 -0
  14. package/dist/api/__tests__/app-store.test.d.ts +0 -7
  15. package/dist/api/__tests__/app-store.test.js +0 -60
  16. package/dist/api/__tests__/intelligence.test.d.ts +0 -11
  17. package/dist/api/__tests__/intelligence.test.js +0 -315
  18. package/dist/api/features/__tests__/feature-utils.test.d.ts +0 -4
  19. package/dist/api/features/__tests__/feature-utils.test.js +0 -370
  20. package/dist/api/features/__tests__/status-updater.test.d.ts +0 -4
  21. package/dist/api/features/__tests__/status-updater.test.js +0 -88
  22. package/dist/commands/build/__tests__/build.test.d.ts +0 -5
  23. package/dist/commands/build/__tests__/build.test.js +0 -206
  24. package/dist/commands/build/__tests__/detect-project.test.d.ts +0 -6
  25. package/dist/commands/build/__tests__/detect-project.test.js +0 -160
  26. package/dist/commands/build/__tests__/run-build.test.d.ts +0 -6
  27. package/dist/commands/build/__tests__/run-build.test.js +0 -433
  28. package/dist/commands/intelligence/__tests__/command.test.d.ts +0 -4
  29. package/dist/commands/intelligence/__tests__/command.test.js +0 -48
  30. package/dist/commands/workflow/core/__tests__/feature-filter.test.d.ts +0 -5
  31. package/dist/commands/workflow/core/__tests__/feature-filter.test.js +0 -316
  32. package/dist/commands/workflow/core/__tests__/pipeline-evaluator.test.d.ts +0 -4
  33. package/dist/commands/workflow/core/__tests__/pipeline-evaluator.test.js +0 -397
  34. package/dist/commands/workflow/core/__tests__/state-manager.test.d.ts +0 -4
  35. package/dist/commands/workflow/core/__tests__/state-manager.test.js +0 -384
  36. package/dist/config/__tests__/config.test.d.ts +0 -4
  37. package/dist/config/__tests__/config.test.js +0 -286
  38. package/dist/config/__tests__/feature-status.test.d.ts +0 -4
  39. package/dist/config/__tests__/feature-status.test.js +0 -111
  40. package/dist/errors/__tests__/index.test.d.ts +0 -4
  41. package/dist/errors/__tests__/index.test.js +0 -349
  42. package/dist/phases/app-store-generation/__tests__/agent.test.d.ts +0 -5
  43. package/dist/phases/app-store-generation/__tests__/agent.test.js +0 -142
  44. package/dist/phases/app-store-generation/__tests__/context.test.d.ts +0 -4
  45. package/dist/phases/app-store-generation/__tests__/context.test.js +0 -284
  46. package/dist/phases/app-store-generation/__tests__/prompts.test.d.ts +0 -4
  47. package/dist/phases/app-store-generation/__tests__/prompts.test.js +0 -122
  48. package/dist/phases/app-store-generation/__tests__/screenshot-composer.test.d.ts +0 -5
  49. package/dist/phases/app-store-generation/__tests__/screenshot-composer.test.js +0 -826
  50. package/dist/phases/code-review/__tests__/diff-utils.test.d.ts +0 -1
  51. package/dist/phases/code-review/__tests__/diff-utils.test.js +0 -101
  52. package/dist/phases/intelligence-analysis/__tests__/context.test.d.ts +0 -4
  53. package/dist/phases/intelligence-analysis/__tests__/context.test.js +0 -192
  54. package/dist/phases/intelligence-analysis/__tests__/matching.test.d.ts +0 -13
  55. package/dist/phases/intelligence-analysis/__tests__/matching.test.js +0 -154
  56. package/dist/phases/intelligence-analysis/__tests__/orchestration.test.d.ts +0 -5
  57. package/dist/phases/intelligence-analysis/__tests__/orchestration.test.js +0 -378
  58. package/dist/phases/intelligence-analysis/__tests__/prompts.test.d.ts +0 -4
  59. package/dist/phases/intelligence-analysis/__tests__/prompts.test.js +0 -33
  60. package/dist/phases/pr-execution/__tests__/file-assigner.test.d.ts +0 -1
  61. package/dist/phases/pr-execution/__tests__/file-assigner.test.js +0 -303
  62. package/dist/phases/pr-resolve/__tests__/checklist-learner.test.d.ts +0 -1
  63. package/dist/phases/pr-resolve/__tests__/checklist-learner.test.js +0 -157
  64. package/dist/phases/pr-resolve/__tests__/prompts.test.d.ts +0 -1
  65. package/dist/phases/pr-resolve/__tests__/prompts.test.js +0 -116
  66. package/dist/phases/pr-resolve/__tests__/resolve-mapping.test.d.ts +0 -1
  67. package/dist/phases/pr-resolve/__tests__/resolve-mapping.test.js +0 -138
  68. package/dist/phases/pr-resolve/__tests__/types.test.d.ts +0 -1
  69. package/dist/phases/pr-resolve/__tests__/types.test.js +0 -43
  70. package/dist/phases/pr-resolve/__tests__/workspace.test.d.ts +0 -1
  71. package/dist/phases/pr-resolve/__tests__/workspace.test.js +0 -111
  72. package/dist/phases/pr-review/__tests__/prompts.test.d.ts +0 -1
  73. package/dist/phases/pr-review/__tests__/prompts.test.js +0 -49
  74. package/dist/phases/pr-review/__tests__/review-comments.test.d.ts +0 -1
  75. package/dist/phases/pr-review/__tests__/review-comments.test.js +0 -110
  76. package/dist/phases/pr-shared/__tests__/agent-utils.test.d.ts +0 -1
  77. package/dist/phases/pr-shared/__tests__/agent-utils.test.js +0 -91
  78. package/dist/phases/pr-shared/__tests__/context.test.d.ts +0 -1
  79. package/dist/phases/pr-shared/__tests__/context.test.js +0 -94
  80. package/dist/phases/pr-splitting/__tests__/import-dep-validator.test.d.ts +0 -1
  81. package/dist/phases/pr-splitting/__tests__/import-dep-validator.test.js +0 -331
  82. package/dist/phases/release-sync/__tests__/github.test.d.ts +0 -9
  83. package/dist/phases/release-sync/__tests__/github.test.js +0 -123
  84. package/dist/phases/release-sync/__tests__/snapshot.test.d.ts +0 -8
  85. package/dist/phases/release-sync/__tests__/snapshot.test.js +0 -93
  86. package/dist/phases/smoke-test/__tests__/agent.test.d.ts +0 -4
  87. package/dist/phases/smoke-test/__tests__/agent.test.js +0 -85
  88. package/dist/services/coaching/__tests__/coaching-agent.test.d.ts +0 -1
  89. package/dist/services/coaching/__tests__/coaching-agent.test.js +0 -74
  90. package/dist/services/coaching/__tests__/coaching-loop.test.d.ts +0 -1
  91. package/dist/services/coaching/__tests__/coaching-loop.test.js +0 -59
  92. package/dist/services/coaching/__tests__/self-rating.test.d.ts +0 -1
  93. package/dist/services/coaching/__tests__/self-rating.test.js +0 -188
  94. package/dist/services/phase-hooks/__tests__/bindings-fetcher.test.d.ts +0 -1
  95. package/dist/services/phase-hooks/__tests__/bindings-fetcher.test.js +0 -122
  96. package/dist/services/phase-hooks/__tests__/hook-executor.test.d.ts +0 -1
  97. package/dist/services/phase-hooks/__tests__/hook-executor.test.js +0 -321
  98. package/dist/services/phase-hooks/__tests__/hook-runner.test.d.ts +0 -1
  99. package/dist/services/phase-hooks/__tests__/hook-runner.test.js +0 -261
  100. package/dist/services/phase-hooks/__tests__/plugin-loader.test.d.ts +0 -1
  101. package/dist/services/phase-hooks/__tests__/plugin-loader.test.js +0 -158
  102. package/dist/services/video/__tests__/video-pipeline.test.d.ts +0 -6
  103. package/dist/services/video/__tests__/video-pipeline.test.js +0 -249
  104. package/dist/workspace/__tests__/workspace-manager.test.d.ts +0 -7
  105. package/dist/workspace/__tests__/workspace-manager.test.js +0 -52
@@ -0,0 +1,22 @@
1
+ export interface RunSheet {
2
+ id: string;
3
+ release_id: string;
4
+ title: string | null;
5
+ content: string;
6
+ template_snapshot: string | null;
7
+ metadata: Record<string, unknown>;
8
+ generated_at: string | null;
9
+ created_by: string;
10
+ created_at: string;
11
+ updated_at: string;
12
+ }
13
+ export interface UpsertRunSheetParams {
14
+ release_id: string;
15
+ content: string;
16
+ title?: string | null;
17
+ template_snapshot?: string | null;
18
+ metadata?: Record<string, unknown>;
19
+ generated_at?: string;
20
+ }
21
+ export declare function getRunSheetByRelease(releaseId: string, verbose?: boolean): Promise<RunSheet | null>;
22
+ export declare function upsertRunSheet(params: UpsertRunSheetParams, verbose?: boolean): Promise<RunSheet>;
@@ -0,0 +1,13 @@
1
+ import { logDebug } from '../utils/logger.js';
2
+ import { callMcpEndpoint } from './mcp-client.js';
3
+ export async function getRunSheetByRelease(releaseId, verbose) {
4
+ logDebug(`Fetching run sheet for release ${releaseId}`, verbose);
5
+ const result = (await callMcpEndpoint('run_sheets/get', {
6
+ release_id: releaseId,
7
+ }));
8
+ return result.run_sheet;
9
+ }
10
+ export async function upsertRunSheet(params, verbose) {
11
+ logDebug(`Upserting run sheet for release ${params.release_id}`, verbose);
12
+ return (await callMcpEndpoint('run_sheets/upsert', params));
13
+ }
@@ -0,0 +1,6 @@
1
+ export interface RunSheetCliOptions {
2
+ releaseId: string;
3
+ force?: boolean;
4
+ verbose?: boolean;
5
+ }
6
+ export declare const runRunSheetCommand: (options: RunSheetCliOptions) => Promise<void>;
@@ -0,0 +1,48 @@
1
+ import { runRunSheet, } from '../../phases/run-sheet/index.js';
2
+ import { deregisterSession, registerSession, } from '../../system/session-manager.js';
3
+ import { logError, logInfo } from '../../utils/logger.js';
4
+ import { validateConfiguration } from '../../utils/validation.js';
5
+ export const runRunSheetCommand = async (options) => {
6
+ const { releaseId } = options;
7
+ if (!releaseId) {
8
+ throw new Error('Release ID is required for run-sheet');
9
+ }
10
+ validateConfiguration({ verbose: options.verbose });
11
+ await registerSession({ command: 'run-sheet' });
12
+ logInfo(`Starting run sheet generation for release: ${releaseId}`);
13
+ try {
14
+ const result = await runRunSheet({
15
+ releaseId,
16
+ force: options.force,
17
+ verbose: options.verbose,
18
+ });
19
+ if (result.status === 'error') {
20
+ logError(`Run sheet generation failed: ${result.summary}`);
21
+ process.exitCode = 1;
22
+ return;
23
+ }
24
+ if (result.status === 'cached') {
25
+ logInfo(`Run sheet unchanged for ${result.releaseTag} — skipped clone.`);
26
+ }
27
+ else {
28
+ logInfo(`Generated run sheet for ${result.releaseTag}`);
29
+ }
30
+ if (result.cloneError) {
31
+ logInfo(`Clone warning: ${result.cloneError}`);
32
+ }
33
+ if (result.missingPlaceholders && result.missingPlaceholders.length > 0) {
34
+ logInfo(`Unresolved placeholders: ${result.missingPlaceholders.join(', ')}`);
35
+ }
36
+ if (result.commitsTruncated) {
37
+ logInfo('Commit list truncated at GitHub 250-commit cap.');
38
+ }
39
+ logInfo('View it in the Release detail page of your product dashboard.');
40
+ }
41
+ catch (error) {
42
+ logError(`Run sheet generation failed: ${error instanceof Error ? error.message : String(error)}`);
43
+ process.exitCode = 1;
44
+ }
45
+ finally {
46
+ await deregisterSession();
47
+ }
48
+ };
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ import { runPRResolve } from './commands/pr-resolve/index.js';
20
20
  import { runPRReview } from './commands/pr-review/index.js';
21
21
  import { runRefactor } from './commands/refactor/refactor.js';
22
22
  import { runReleaseSyncCommand } from './commands/release-sync/index.js';
23
+ import { runRunSheetCommand } from './commands/run-sheet/index.js';
23
24
  import { runSmokeTestCommand } from './commands/smoke-test/index.js';
24
25
  import { runTaskWorker } from './commands/task-worker/index.js';
25
26
  import { runWorkflow } from './commands/workflow/index.js';
@@ -314,6 +315,27 @@ program
314
315
  }
315
316
  });
316
317
  // ============================================================
318
+ // Subcommand: edsger run-sheet <releaseId>
319
+ // ============================================================
320
+ program
321
+ .command('run-sheet <releaseId>')
322
+ .description('Render the product run sheet template against a release (clones the repo at the release tag to resolve {{file:...}} placeholders)')
323
+ .option('-f, --force', 'Regenerate even if the cached run sheet is fresh')
324
+ .option('-v, --verbose', 'Verbose output')
325
+ .action(async (releaseId, opts) => {
326
+ try {
327
+ await runRunSheetCommand({
328
+ releaseId,
329
+ force: opts.force,
330
+ verbose: opts.verbose,
331
+ });
332
+ }
333
+ catch (error) {
334
+ logError(error instanceof Error ? error.message : String(error));
335
+ process.exit(1);
336
+ }
337
+ });
338
+ // ============================================================
317
339
  // Subcommand: edsger pr-review <productId>
318
340
  // ============================================================
319
341
  program
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Run-sheet generation for a release.
3
+ *
4
+ * Given a release, fetches the product's `run_sheet_template`, clones the
5
+ * repo at the release tag (reusing the workspace layout used by
6
+ * smoke-test), renders placeholders, and upserts the result to the
7
+ * `run_sheets` table.
8
+ *
9
+ * Release tags are immutable, so if an existing run sheet has the same
10
+ * template_snapshot + tag and no clone error, we short-circuit without
11
+ * re-cloning. Pass `{ force: true }` to regenerate anyway.
12
+ */
13
+ export interface RunSheetOptions {
14
+ releaseId: string;
15
+ force?: boolean;
16
+ verbose?: boolean;
17
+ }
18
+ export interface RunSheetResult {
19
+ status: 'success' | 'error' | 'cached';
20
+ releaseId: string;
21
+ releaseTag?: string;
22
+ summary: string;
23
+ runSheetId?: string;
24
+ missingPlaceholders?: string[];
25
+ cloneError?: string | null;
26
+ commitsTruncated?: boolean;
27
+ }
28
+ /**
29
+ * Atomic-create a `.lock` file alongside the workspace dir. Returns
30
+ * true if we acquired the lock, false if another CLI instance holds a
31
+ * fresh one. Stale locks (> LOCK_STALE_MS old) are stolen via a single
32
+ * unlink + create pair; concurrent stealers race fairly via O_EXCL on
33
+ * the second create.
34
+ */
35
+ /** Exported for unit tests; not part of the public CLI surface. */
36
+ export declare function tryAcquireFileLock(lockPath: string): boolean;
37
+ /** Exported for unit tests; not part of the public CLI surface. */
38
+ export declare function releaseFileLock(lockPath: string): void;
39
+ export declare function runRunSheet(options: RunSheetOptions): Promise<RunSheetResult>;
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Run-sheet generation for a release.
3
+ *
4
+ * Given a release, fetches the product's `run_sheet_template`, clones the
5
+ * repo at the release tag (reusing the workspace layout used by
6
+ * smoke-test), renders placeholders, and upserts the result to the
7
+ * `run_sheets` table.
8
+ *
9
+ * Release tags are immutable, so if an existing run sheet has the same
10
+ * template_snapshot + tag and no clone error, we short-circuit without
11
+ * re-cloning. Pass `{ force: true }` to regenerate anyway.
12
+ */
13
+ import { closeSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync, } from 'fs';
14
+ import { join } from 'path';
15
+ import { getGitHubConfigByProduct } from '../../api/github.js';
16
+ import { getProduct } from '../../api/products.js';
17
+ import { getRelease } from '../../api/releases.js';
18
+ import { getRunSheetByRelease, upsertRunSheet, } from '../../api/run-sheets.js';
19
+ import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
20
+ import { cloneFeatureRepo, ensureWorkspaceDir, getFeatureRepoPath, syncRepoToRef, } from '../../workspace/workspace-manager.js';
21
+ import { fetchCompare } from '../release-sync/github.js';
22
+ import { isSafeGitRef, renderTemplate } from './render.js';
23
+ // GitHub's compare endpoint is paginated; we don't page, so we warn when
24
+ // we hit the first-page cap.
25
+ const GITHUB_COMPARE_MAX_COMMITS = 250;
26
+ async function fetchCommitsBetween(owner, repo, base, head, token) {
27
+ if (!base) {
28
+ return { text: '', truncated: false };
29
+ }
30
+ try {
31
+ const compare = await fetchCompare(owner, repo, base, head, token);
32
+ const commits = compare.commits ?? [];
33
+ const text = commits
34
+ .map((c) => `- ${c.sha.slice(0, 7)} ${(c.commit.message || '').split('\n')[0]}`)
35
+ .join('\n');
36
+ return {
37
+ text,
38
+ truncated: commits.length >= GITHUB_COMPARE_MAX_COMMITS,
39
+ };
40
+ }
41
+ catch (err) {
42
+ logWarning(`Could not fetch commits for run sheet: ${err instanceof Error ? err.message : String(err)}`);
43
+ return { text: '', truncated: false };
44
+ }
45
+ }
46
+ // Stale locks (e.g. left behind by a crashed or SIGKILLed CLI) are
47
+ // considered abandoned after this many ms.
48
+ const LOCK_STALE_MS = 15 * 60 * 1000;
49
+ function writeLockFile(fd) {
50
+ const payload = { pid: process.pid, ts: Date.now() };
51
+ writeFileSync(fd, JSON.stringify(payload));
52
+ closeSync(fd);
53
+ }
54
+ function tryCreateLock(lockPath) {
55
+ try {
56
+ const fd = openSync(lockPath, 'wx');
57
+ writeLockFile(fd);
58
+ return true;
59
+ }
60
+ catch (err) {
61
+ if (err.code === 'EEXIST') {
62
+ return false;
63
+ }
64
+ throw err;
65
+ }
66
+ }
67
+ function isLockStale(lockPath) {
68
+ try {
69
+ const raw = readFileSync(lockPath, 'utf-8').trim();
70
+ let ts;
71
+ try {
72
+ ts = JSON.parse(raw).ts;
73
+ }
74
+ catch {
75
+ ts = Number(raw);
76
+ }
77
+ return !Number.isFinite(ts) || Date.now() - ts > LOCK_STALE_MS;
78
+ }
79
+ catch {
80
+ // Lock vanished between exists-check and read — treat as not held.
81
+ return true;
82
+ }
83
+ }
84
+ /**
85
+ * Atomic-create a `.lock` file alongside the workspace dir. Returns
86
+ * true if we acquired the lock, false if another CLI instance holds a
87
+ * fresh one. Stale locks (> LOCK_STALE_MS old) are stolen via a single
88
+ * unlink + create pair; concurrent stealers race fairly via O_EXCL on
89
+ * the second create.
90
+ */
91
+ /** Exported for unit tests; not part of the public CLI surface. */
92
+ export function tryAcquireFileLock(lockPath) {
93
+ mkdirSync(join(lockPath, '..'), { recursive: true });
94
+ // Pass 1: clean acquisition.
95
+ if (tryCreateLock(lockPath)) {
96
+ return true;
97
+ }
98
+ // Pass 2: someone holds it — only steal if their lock is stale.
99
+ if (!isLockStale(lockPath)) {
100
+ return false;
101
+ }
102
+ // Try to steal. Best-effort unlink (another stealer may have beaten
103
+ // us); the O_EXCL create then arbitrates fairly.
104
+ try {
105
+ unlinkSync(lockPath);
106
+ }
107
+ catch {
108
+ // Already gone — fine, race the create below.
109
+ }
110
+ return tryCreateLock(lockPath);
111
+ }
112
+ /** Exported for unit tests; not part of the public CLI surface. */
113
+ export function releaseFileLock(lockPath) {
114
+ try {
115
+ unlinkSync(lockPath);
116
+ }
117
+ catch {
118
+ // Best-effort — lock will expire via LOCK_STALE_MS anyway.
119
+ }
120
+ }
121
+ function runSheetIsFresh(existing, template, tag) {
122
+ if (!existing || !existing.content) {
123
+ return false;
124
+ }
125
+ if (existing.template_snapshot !== template) {
126
+ return false;
127
+ }
128
+ const meta = existing.metadata ?? {};
129
+ if (meta.tag !== tag) {
130
+ return false;
131
+ }
132
+ if (meta.clone_error) {
133
+ return false;
134
+ }
135
+ return true;
136
+ }
137
+ export async function runRunSheet(options) {
138
+ const { releaseId, force, verbose } = options;
139
+ let release;
140
+ try {
141
+ release = await getRelease(releaseId, verbose);
142
+ }
143
+ catch (err) {
144
+ const message = err instanceof Error ? err.message : String(err);
145
+ return {
146
+ status: 'error',
147
+ releaseId,
148
+ summary: `Failed to load release: ${message}`,
149
+ };
150
+ }
151
+ if (!isSafeGitRef(release.tag)) {
152
+ return {
153
+ status: 'error',
154
+ releaseId,
155
+ releaseTag: release.tag,
156
+ summary: `Unsafe release tag: ${release.tag}`,
157
+ };
158
+ }
159
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mcp product shape is open
160
+ const product = (await getProduct(release.product_id, verbose));
161
+ const template = product?.run_sheet_template;
162
+ if (!template || !template.trim()) {
163
+ return {
164
+ status: 'error',
165
+ releaseId,
166
+ releaseTag: release.tag,
167
+ summary: 'Product has no run_sheet_template configured. Set one in product settings.',
168
+ };
169
+ }
170
+ // Short-circuit cache: identical template + tag, no prior clone error.
171
+ if (!force) {
172
+ const existing = await getRunSheetByRelease(releaseId, verbose).catch(() => null);
173
+ if (runSheetIsFresh(existing, template, release.tag)) {
174
+ logInfo(`Run sheet for ${release.tag} is already up-to-date (template unchanged).`);
175
+ return {
176
+ status: 'cached',
177
+ releaseId,
178
+ releaseTag: release.tag,
179
+ runSheetId: existing.id,
180
+ summary: 'Cached (no change since last render)',
181
+ missingPlaceholders: existing.metadata?.missing_placeholders ?? [],
182
+ cloneError: null,
183
+ commitsTruncated: Boolean(existing.metadata?.commits_truncated),
184
+ };
185
+ }
186
+ }
187
+ const gh = await getGitHubConfigByProduct(release.product_id, verbose);
188
+ const repoConfigured = gh.configured && gh.token && gh.owner && gh.repo;
189
+ // Clone at tag + fetch commits (both best-effort — we still render with
190
+ // whatever metadata we have).
191
+ let repoDir = null;
192
+ let cloneError = null;
193
+ let commits = '';
194
+ let commitsTruncated = false;
195
+ let lockPath = null;
196
+ if (repoConfigured) {
197
+ const owner = gh.owner;
198
+ const repo = gh.repo;
199
+ const token = gh.token;
200
+ // Serialise concurrent CLI invocations for the *same release* by
201
+ // taking a file lock next to the clone dir. Different releases get
202
+ // different lock files (per-release workspace).
203
+ const workspaceRoot = ensureWorkspaceDir();
204
+ const workspaceName = `run-sheet-${release.id}`;
205
+ const repoPathAhead = getFeatureRepoPath(workspaceRoot, workspaceName);
206
+ lockPath = `${repoPathAhead}.lock`;
207
+ if (!tryAcquireFileLock(lockPath)) {
208
+ return {
209
+ status: 'error',
210
+ releaseId: release.id,
211
+ releaseTag: release.tag,
212
+ summary: 'Another run-sheet generation is in progress for this release. Wait for it to finish or retry.',
213
+ };
214
+ }
215
+ try {
216
+ // Clone into a per-release directory so two releases of the same
217
+ // product don't stomp each other's checkout during concurrent
218
+ // generation. (smoke-test uses `release-${product_id}` — that races;
219
+ // we don't want to inherit that bug here.)
220
+ const { repoPath } = cloneFeatureRepo(workspaceRoot, workspaceName, owner, repo, token);
221
+ repoDir = repoPath;
222
+ try {
223
+ syncRepoToRef(repoPath, { tag: release.tag }, token);
224
+ }
225
+ catch (err) {
226
+ cloneError = `Could not checkout tag ${release.tag}: ${err instanceof Error ? err.message : String(err)}`;
227
+ logWarning(cloneError);
228
+ }
229
+ }
230
+ catch (err) {
231
+ cloneError = err instanceof Error ? err.message : String(err);
232
+ logWarning(`Clone failed: ${cloneError}`);
233
+ }
234
+ const c = await fetchCommitsBetween(owner, repo, release.previous_tag, release.tag, token);
235
+ commits = c.text;
236
+ commitsTruncated = c.truncated;
237
+ }
238
+ else {
239
+ cloneError = 'Product is not linked to a GitHub repository';
240
+ }
241
+ const { rendered, missing } = await renderTemplate(template, {
242
+ name: product?.name ?? 'Unknown product',
243
+ github_repository_full_name: product?.github_repository_full_name ?? null,
244
+ }, {
245
+ tag: release.tag,
246
+ name: release.name,
247
+ body: release.body,
248
+ url: release.url,
249
+ published_at: release.published_at,
250
+ previous_tag: release.previous_tag,
251
+ previous_published_at: release.previous_published_at,
252
+ diff_summary: release.diff_summary,
253
+ diff_stats: (release.diff_stats ?? {}),
254
+ }, repoDir, commits);
255
+ try {
256
+ const saved = await upsertRunSheet({
257
+ release_id: release.id,
258
+ content: rendered,
259
+ title: `Run Sheet — ${product?.name ?? ''} ${release.tag}`.trim(),
260
+ template_snapshot: template,
261
+ metadata: {
262
+ missing_placeholders: missing,
263
+ clone_error: cloneError,
264
+ repo: product?.github_repository_full_name ?? null,
265
+ tag: release.tag,
266
+ commits_truncated: commitsTruncated,
267
+ },
268
+ generated_at: new Date().toISOString(),
269
+ }, verbose);
270
+ logSuccess(`Generated run sheet for ${release.tag}`);
271
+ return {
272
+ status: 'success',
273
+ releaseId: release.id,
274
+ releaseTag: release.tag,
275
+ runSheetId: saved.id,
276
+ summary: `Rendered ${rendered.length} characters`,
277
+ missingPlaceholders: missing,
278
+ cloneError,
279
+ commitsTruncated,
280
+ };
281
+ }
282
+ catch (err) {
283
+ const message = err instanceof Error ? err.message : String(err);
284
+ logError(`Failed to upsert run sheet: ${message}`);
285
+ return {
286
+ status: 'error',
287
+ releaseId: release.id,
288
+ releaseTag: release.tag,
289
+ summary: `Failed to upsert run sheet: ${message}`,
290
+ };
291
+ }
292
+ finally {
293
+ if (lockPath) {
294
+ releaseFileLock(lockPath);
295
+ }
296
+ }
297
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Pure template-rendering logic for run sheets.
3
+ *
4
+ * Intentionally identical to `web/src/services/run-sheets/render.ts` — any
5
+ * change here should mirror the web side (and its unit tests) so the CLI
6
+ * and the web `/api/releases/[id]/generate-run-sheet` route produce the
7
+ * exact same output for the same template + release.
8
+ */
9
+ export declare const MAX_FILE_INCLUDE_BYTES: number;
10
+ export declare const MAX_TOTAL_FILE_BYTES: number;
11
+ export interface TemplateProduct {
12
+ name: string;
13
+ github_repository_full_name: string | null;
14
+ }
15
+ export interface TemplateRelease {
16
+ tag: string;
17
+ name: string | null;
18
+ body: string | null;
19
+ url: string | null;
20
+ published_at: string | null;
21
+ previous_tag: string | null;
22
+ previous_published_at: string | null;
23
+ diff_summary: string | null;
24
+ diff_stats: Record<string, unknown>;
25
+ }
26
+ export declare function isSafeGitRef(ref: string): boolean;
27
+ export interface FileReadResult {
28
+ content: string | null;
29
+ bytes: number;
30
+ reason?: string;
31
+ }
32
+ export declare function safeReadRepoFile(repoDir: string, relPath: string, remainingBudget: number): Promise<FileReadResult>;
33
+ export interface RenderResult {
34
+ rendered: string;
35
+ missing: string[];
36
+ }
37
+ /**
38
+ * Strip Markdown-style backslash escapes inside `{{ ... }}` spans so the
39
+ * Rich-text editor roundtrip doesn't break placeholder matching.
40
+ */
41
+ export declare function normalizeTemplate(template: string): string;
42
+ export declare function renderTemplate(template: string, product: TemplateProduct, release: TemplateRelease, repoDir: string | null, commits: string): Promise<RenderResult>;
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Pure template-rendering logic for run sheets.
3
+ *
4
+ * Intentionally identical to `web/src/services/run-sheets/render.ts` — any
5
+ * change here should mirror the web side (and its unit tests) so the CLI
6
+ * and the web `/api/releases/[id]/generate-run-sheet` route produce the
7
+ * exact same output for the same template + release.
8
+ */
9
+ import { statSync } from 'fs';
10
+ import { readFile } from 'fs/promises';
11
+ import { join, resolve } from 'path';
12
+ export const MAX_FILE_INCLUDE_BYTES = 256 * 1024;
13
+ export const MAX_TOTAL_FILE_BYTES = 1024 * 1024;
14
+ export function isSafeGitRef(ref) {
15
+ if (typeof ref !== 'string' || ref.length === 0 || ref.length > 200) {
16
+ return false;
17
+ }
18
+ if (/\s/.test(ref) || /^[-.]/.test(ref)) {
19
+ return false;
20
+ }
21
+ if (ref.includes('..') || ref.includes('@{')) {
22
+ return false;
23
+ }
24
+ return /^[A-Za-z0-9._\-+/@]+$/.test(ref);
25
+ }
26
+ export async function safeReadRepoFile(repoDir, relPath, remainingBudget) {
27
+ if (!relPath || relPath.startsWith('/') || relPath.includes('\0')) {
28
+ return { content: null, bytes: 0, reason: 'invalid path' };
29
+ }
30
+ const repoDirResolved = resolve(repoDir);
31
+ const abs = resolve(join(repoDirResolved, relPath));
32
+ if (abs !== repoDirResolved && !abs.startsWith(`${repoDirResolved}/`)) {
33
+ return { content: null, bytes: 0, reason: 'path escape' };
34
+ }
35
+ let size;
36
+ try {
37
+ const stat = statSync(abs);
38
+ if (!stat.isFile()) {
39
+ return { content: null, bytes: 0, reason: 'not a file' };
40
+ }
41
+ size = stat.size;
42
+ }
43
+ catch {
44
+ return { content: null, bytes: 0, reason: 'not found' };
45
+ }
46
+ if (size > MAX_FILE_INCLUDE_BYTES) {
47
+ return {
48
+ content: null,
49
+ bytes: 0,
50
+ reason: `too large (${size} > ${MAX_FILE_INCLUDE_BYTES} bytes)`,
51
+ };
52
+ }
53
+ if (size > remainingBudget) {
54
+ return {
55
+ content: null,
56
+ bytes: 0,
57
+ reason: 'total file-inclusion budget exhausted',
58
+ };
59
+ }
60
+ try {
61
+ const content = await readFile(abs, 'utf-8');
62
+ return { content, bytes: size };
63
+ }
64
+ catch {
65
+ return { content: null, bytes: 0, reason: 'read failed' };
66
+ }
67
+ }
68
+ /**
69
+ * Strip Markdown-style backslash escapes inside `{{ ... }}` spans so the
70
+ * Rich-text editor roundtrip doesn't break placeholder matching.
71
+ */
72
+ export function normalizeTemplate(template) {
73
+ return template.replace(/\{\{([^}]*)\}\}/g, (_match, inner) => `{{${inner.replace(/\\([_.\-\\/])/g, '$1')}}}`);
74
+ }
75
+ export async function renderTemplate(template, product, release, repoDir, commits) {
76
+ const missing = [];
77
+ const stats = release.diff_stats ?? {};
78
+ const normalized = normalizeTemplate(template);
79
+ const simpleVars = {
80
+ product_name: product.name,
81
+ release_tag: release.tag,
82
+ release_name: release.name ?? release.tag,
83
+ release_body: release.body ?? '',
84
+ release_url: release.url ?? '',
85
+ previous_tag: release.previous_tag ?? '',
86
+ published_at: release.published_at ?? '',
87
+ previous_published_at: release.previous_published_at ?? '',
88
+ diff_summary: release.diff_summary ?? '',
89
+ files_changed: String(stats.files_changed ?? ''),
90
+ additions: String(stats.additions ?? ''),
91
+ deletions: String(stats.deletions ?? ''),
92
+ commits_count: String(stats.commits_count ?? stats.total_commits ?? ''),
93
+ repository: product.github_repository_full_name ?? '',
94
+ generated_at: new Date().toISOString(),
95
+ commits,
96
+ };
97
+ const fileRegex = /\{\{\s*file:([^}]+?)\s*\}\}/g;
98
+ const fileMatches = [];
99
+ for (const m of normalized.matchAll(fileRegex)) {
100
+ fileMatches.push({ match: m[0], path: m[1].trim() });
101
+ }
102
+ let remainingBudget = MAX_TOTAL_FILE_BYTES;
103
+ const fileResults = new Map();
104
+ for (const { match, path } of fileMatches) {
105
+ if (fileResults.has(match)) {
106
+ continue;
107
+ }
108
+ if (!repoDir) {
109
+ missing.push(`file:${path} (no repo)`);
110
+ fileResults.set(match, `<!-- file ${path} unavailable: repo not cloned -->`);
111
+ continue;
112
+ }
113
+ // eslint-disable-next-line no-await-in-loop -- sequential so the byte budget is enforced
114
+ const res = await safeReadRepoFile(repoDir, path, remainingBudget);
115
+ if (res.content === null) {
116
+ missing.push(`file:${path} (${res.reason ?? 'unavailable'})`);
117
+ fileResults.set(match, `<!-- file ${path} unavailable: ${res.reason ?? 'unknown'} -->`);
118
+ }
119
+ else {
120
+ remainingBudget -= res.bytes;
121
+ fileResults.set(match, res.content);
122
+ }
123
+ }
124
+ let rendered = normalized.replace(fileRegex, (match) => fileResults.get(match) ?? match);
125
+ rendered = rendered.replace(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, (match, key) => {
126
+ if (key in simpleVars) {
127
+ return simpleVars[key];
128
+ }
129
+ missing.push(key);
130
+ return match;
131
+ });
132
+ return { rendered, missing };
133
+ }
package/package.json CHANGED
@@ -1,15 +1,18 @@
1
1
  {
2
2
  "name": "edsger",
3
- "version": "0.44.0",
3
+ "version": "0.45.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "edsger": "dist/index.js"
7
7
  },
8
8
  "scripts": {
9
- "build": "tsc && cp -r src/phases/app-store-generation/assets dist/phases/app-store-generation/ && rm -rf dist/skills && mkdir -p dist/skills && cp -r ../edsger-skills/skills/phase dist/skills/phase",
10
- "dev": "tsc --watch",
9
+ "build": "tsc -p tsconfig.build.json && cp -r src/phases/app-store-generation/assets dist/phases/app-store-generation/ && rm -rf dist/skills && mkdir -p dist/skills && cp -r ../edsger-skills/skills/phase dist/skills/phase",
10
+ "dev": "tsc -p tsconfig.build.json --watch",
11
11
  "lint": "eslint .",
12
12
  "lint:fix": "eslint . --fix",
13
+ "test": "npm run test:unit",
14
+ "test:unit": "vitest run",
15
+ "test:watch": "vitest",
13
16
  "prepublishOnly": "npm run build"
14
17
  },
15
18
  "keywords": [
@@ -49,7 +52,11 @@
49
52
  },
50
53
  "devDependencies": {
51
54
  "@types/node": "^20.0.0",
52
- "typescript": "^5.0.0"
55
+ "@types/turndown": "^5.0.6",
56
+ "marked": "^15.0.12",
57
+ "turndown": "^7.2.2",
58
+ "typescript": "^5.0.0",
59
+ "vitest": "^4.1.0"
53
60
  },
54
61
  "peerDependenciesMeta": {
55
62
  "@anthropic-ai/claude-agent-sdk": {
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/__tests__/**"]
4
+ }