edsger 0.58.0 → 0.59.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.
@@ -56,7 +56,6 @@ export async function listAllReadyIssues(verbose) {
56
56
  const allIssues = (data || []).map((row) => {
57
57
  // The `product` join lands as a nested object; lift its name onto
58
58
  // the row to keep the wire shape stable.
59
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- shape from PostgREST join
60
59
  const r = row;
61
60
  return {
62
61
  ...r,
@@ -41,7 +41,6 @@ export async function claimNextIssue(productId, verbose) {
41
41
  }
42
42
  // RPC returns rows with `issue_*` prefixed column names — unwrap to
43
43
  // the IssueInfo shape callers already consume.
44
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- raw RPC row
45
44
  const r = row;
46
45
  const issue = {
47
46
  id: r.issue_id,
@@ -91,7 +91,7 @@ export async function markWorkflowPhaseCompleted(issueId, phaseName, verbose) {
91
91
  // Fall through to MCP
92
92
  }
93
93
  }
94
- if (issue == null) {
94
+ if (!issue) {
95
95
  const response = (await callMcpEndpoint('issues/get', {
96
96
  issue_id: issueId,
97
97
  }));
@@ -354,7 +354,7 @@ function startRealtime() {
354
354
  registerChannel(payload.new);
355
355
  // A channel created with messages already in it (unlikely but
356
356
  // possible if INSERTs race) deserves an immediate sweep.
357
- const id = payload.new.id;
357
+ const { id } = payload.new;
358
358
  if (id) {
359
359
  void claimAndProcess(id);
360
360
  }
@@ -116,7 +116,7 @@ export async function runChecklists(options) {
116
116
  ? createChecklistsMcpServer({ kind: 'team', teamId: teamId })
117
117
  : createChecklistsMcpServer({
118
118
  kind: 'product',
119
- productId: productId,
119
+ productId,
120
120
  });
121
121
  const promptParts = isTeamScope
122
122
  ? [
@@ -0,0 +1,15 @@
1
+ /**
2
+ * CLI command: edsger product-techniques <productId> --techniques-id <id>
3
+ *
4
+ * Clones the product's repo, asks Claude to write a catalogue of the
5
+ * techniques the repo uses, and stores the markdown body in
6
+ * product_techniques. The desktop UI creates a pending row first then
7
+ * invokes the CLI with --techniques-id; the CLI flips status running →
8
+ * success/failed and populates the content/summary fields.
9
+ */
10
+ export interface ProductTechniquesCliOptions {
11
+ techniquesId: string;
12
+ guidance?: string;
13
+ verbose?: boolean;
14
+ }
15
+ export declare function runProductTechniques(productId: string, options: ProductTechniquesCliOptions): Promise<void>;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * CLI command: edsger product-techniques <productId> --techniques-id <id>
3
+ *
4
+ * Clones the product's repo, asks Claude to write a catalogue of the
5
+ * techniques the repo uses, and stores the markdown body in
6
+ * product_techniques. The desktop UI creates a pending row first then
7
+ * invokes the CLI with --techniques-id; the CLI flips status running →
8
+ * success/failed and populates the content/summary fields.
9
+ */
10
+ import { runProductTechniquesPhase } from '../../phases/product-techniques/index.js';
11
+ import { logError, logInfo, logSuccess } from '../../utils/logger.js';
12
+ export async function runProductTechniques(productId, options) {
13
+ const { techniquesId, guidance, verbose } = options;
14
+ if (!productId) {
15
+ throw new Error('Product ID is required for product-techniques');
16
+ }
17
+ if (!techniquesId) {
18
+ throw new Error('--techniques-id is required (the pending product_techniques row id)');
19
+ }
20
+ logInfo(`Starting techniques generation for product ${productId}`);
21
+ const result = await runProductTechniquesPhase({
22
+ productId,
23
+ techniquesId,
24
+ guidance,
25
+ verbose,
26
+ });
27
+ if (result.status === 'success') {
28
+ logSuccess(result.message);
29
+ if (result.summary) {
30
+ logInfo(`\nSummary: ${result.summary}`);
31
+ }
32
+ }
33
+ else {
34
+ logError(result.message);
35
+ process.exit(1);
36
+ }
37
+ }
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { checkApprovalBeforePhaseExecution } from '../../../api/issues/approval-checker.js';
5
5
  import { updateIssueStatusForPhase } from '../../../api/issues/index.js';
6
- import { logIssuePhaseEvent, } from '../../../services/audit-logs.js';
6
+ import { logIssuePhaseEvent } from '../../../services/audit-logs.js';
7
7
  import { getChecklistsForPhase, processChecklistItemResultsFromResponse, processChecklistResultsFromResponse, validateChecklistsForPhase, validateRequiredChecklistResults, } from '../../../services/checklist.js';
8
8
  import { runHooksForPhase } from '../../../services/phase-hooks/index.js';
9
9
  import { logDebug, logInfo, logWarning } from '../../../utils/logger.js';
package/dist/index.js CHANGED
@@ -19,17 +19,18 @@ import { runFindBugs } from './commands/find-bugs/index.js';
19
19
  import { runFindFeatures } from './commands/find-features/index.js';
20
20
  import { parseCategoriesOption, runFindSmells, } from './commands/find-smells/index.js';
21
21
  import { runGrowthAnalysis } from './commands/growth-analysis/index.js';
22
- import { runScreenFlow } from './commands/screen-flow/index.js';
23
22
  import { runInit } from './commands/init/index.js';
24
23
  import { runIntelligence } from './commands/intelligence/index.js';
25
24
  import { runIssueAnalysisCommand } from './commands/issue-analysis/index.js';
26
25
  import { runPRResolve } from './commands/pr-resolve/index.js';
27
26
  import { runPRReview } from './commands/pr-review/index.js';
27
+ import { runProductTechniques } from './commands/product-techniques/index.js';
28
28
  import { runProductTestCases } from './commands/product-test-cases/index.js';
29
29
  import { runQualityBenchmarkCli } from './commands/quality-benchmark/index.js';
30
30
  import { runRefactor } from './commands/refactor/refactor.js';
31
31
  import { runReleaseSyncCommand } from './commands/release-sync/index.js';
32
32
  import { runRunSheetCommand } from './commands/run-sheet/index.js';
33
+ import { runScreenFlow } from './commands/screen-flow/index.js';
33
34
  import { runSmokeTestCommand } from './commands/smoke-test/index.js';
34
35
  import { runSyncGithubIssues } from './commands/sync-github-issues/index.js';
35
36
  import { runSyncSentryIssues } from './commands/sync-sentry-issues/index.js';
@@ -694,6 +695,28 @@ program
694
695
  }
695
696
  });
696
697
  // ============================================================
698
+ // Subcommand: edsger product-techniques <productId>
699
+ // ============================================================
700
+ program
701
+ .command('product-techniques <productId>')
702
+ .description("Generate a catalogue of the techniques a product's repo uses (languages, frameworks, patterns, state idioms, build & deploy, notable bits). Writes the result to the pending product_techniques row identified by --techniques-id.")
703
+ .requiredOption('--techniques-id <id>', 'Pending product_techniques row id to populate')
704
+ .option('-g, --guidance <text>', 'Human direction for the AI (focus areas, exclusions)')
705
+ .option('-v, --verbose', 'Verbose output')
706
+ .action(async (productId, opts) => {
707
+ try {
708
+ await runProductTechniques(productId, {
709
+ techniquesId: opts.techniquesId,
710
+ guidance: opts.guidance,
711
+ verbose: opts.verbose,
712
+ });
713
+ }
714
+ catch (error) {
715
+ logError(error instanceof Error ? error.message : String(error));
716
+ process.exit(1);
717
+ }
718
+ });
719
+ // ============================================================
697
720
  // Subcommand: edsger pr-resolve <productId>
698
721
  // ============================================================
699
722
  program
@@ -5,11 +5,11 @@
5
5
  * user's session logs (only if the latest log is >1 hour old), and creates
6
6
  * product_user_feedbacks for any issues or improvement suggestions found.
7
7
  */
8
+ import { callMcpEndpoint } from '../../api/mcp-client.js';
8
9
  import { getProduct } from '../../api/products.js';
9
10
  import { getPendingLogsByUser, markLogsAnalyzed, } from '../../services/product-logs.js';
10
11
  import { getSupabase, hasSupabaseSession } from '../../supabase/client.js';
11
12
  import { logError, logInfo, logSuccess } from '../../utils/logger.js';
12
- import { callMcpEndpoint } from '../../api/mcp-client.js';
13
13
  import { executeLogAnalysis } from './agent.js';
14
14
  import { createAnalyzeLogsSystemPrompt, createAnalyzeLogsUserPrompt, } from './prompts.js';
15
15
  /**
@@ -41,8 +41,10 @@ export async function fetchBugFixingContext(issueId, verbose) {
41
41
  // Fall through to MCP
42
42
  }
43
43
  }
44
- if (raw == null) {
45
- const testReportResult = (await callMcpEndpoint('test_reports/latest', { issue_id: issueId }));
44
+ if (!raw) {
45
+ const testReportResult = (await callMcpEndpoint('test_reports/latest', {
46
+ issue_id: issueId,
47
+ }));
46
48
  raw = testReportResult.test_report ?? null;
47
49
  }
48
50
  if (raw) {
@@ -267,7 +267,7 @@ async function fetchIntelligenceReports(productId, focusReportId) {
267
267
  if (error) {
268
268
  throw new Error(error.message);
269
269
  }
270
- reports = (data || []);
270
+ reports = data || [];
271
271
  usedSdk = true;
272
272
  }
273
273
  catch {
@@ -0,0 +1,52 @@
1
+ /**
2
+ * product-techniques phase: clone the product's repo, ask Claude (via the
3
+ * `submit_techniques` MCP tool) to write a catalogue of the techniques the
4
+ * repo uses, and persist the result to product_techniques via the Supabase
5
+ * SDK.
6
+ *
7
+ * Mirrors the screen-flow pattern. Production-grade behaviours layered on
8
+ * top of the basic generate-and-write loop:
9
+ *
10
+ * - Heartbeat: `last_heartbeat_at` is refreshed on every assistant message
11
+ * so the reader can detect stalled / crashed runs (see services/db/
12
+ * product-techniques.ts for the lazy reaper).
13
+ * - Cancellation-safe writes: markRunning / markSuccess / markFailed only
14
+ * touch rows whose status is in {pending, running}. If the user clicked
15
+ * Stop and the row is now 'cancelled', the final write no-ops.
16
+ * - Tool-based submission: validated server-side via Zod + content checks
17
+ * (mcp-server.ts). Falls back to fenced JSON parsing for resilience.
18
+ */
19
+ import type { SupabaseClient } from '@supabase/supabase-js';
20
+ export interface ProductTechniquesOptions {
21
+ productId: string;
22
+ techniquesId: string;
23
+ guidance?: string;
24
+ verbose?: boolean;
25
+ }
26
+ export interface ProductTechniquesResult {
27
+ status: 'success' | 'error' | 'cancelled';
28
+ message: string;
29
+ summary?: string;
30
+ }
31
+ export declare function runProductTechniquesPhase(options: ProductTechniquesOptions): Promise<ProductTechniquesResult>;
32
+ /**
33
+ * Claim the row by flipping `pending` → `running`. Returns true on success
34
+ * (we won the claim) and false when the row has already moved on (e.g. user
35
+ * cancelled before the CLI started). Bounded by the status filter so we
36
+ * can't accidentally resurrect a 'cancelled' row.
37
+ */
38
+ export declare function markRunning(supabase: SupabaseClient, techniquesId: string): Promise<boolean>;
39
+ /**
40
+ * Touch the heartbeat. Best-effort — if it fails (network blip, RLS), the
41
+ * agent loop keeps running; the reader treats this row as stale and marks
42
+ * it failed on next read, which is the correct behaviour.
43
+ */
44
+ export declare function heartbeat(supabase: SupabaseClient, techniquesId: string): Promise<void>;
45
+ /**
46
+ * Write failure status iff the row is still in an active state. Returns
47
+ * true if the row was actually updated (so the caller knows whether the
48
+ * agent's verdict made it to the DB). Returns false when the row has
49
+ * already been cancelled or otherwise resolved by someone else.
50
+ */
51
+ export declare function markFailed(supabase: SupabaseClient, techniquesId: string, errorMessage: string): Promise<boolean>;
52
+ export declare function markSuccess(supabase: SupabaseClient, techniquesId: string, summary: string, content: string): Promise<boolean>;
@@ -0,0 +1,268 @@
1
+ /**
2
+ * product-techniques phase: clone the product's repo, ask Claude (via the
3
+ * `submit_techniques` MCP tool) to write a catalogue of the techniques the
4
+ * repo uses, and persist the result to product_techniques via the Supabase
5
+ * SDK.
6
+ *
7
+ * Mirrors the screen-flow pattern. Production-grade behaviours layered on
8
+ * top of the basic generate-and-write loop:
9
+ *
10
+ * - Heartbeat: `last_heartbeat_at` is refreshed on every assistant message
11
+ * so the reader can detect stalled / crashed runs (see services/db/
12
+ * product-techniques.ts for the lazy reaper).
13
+ * - Cancellation-safe writes: markRunning / markSuccess / markFailed only
14
+ * touch rows whose status is in {pending, running}. If the user clicked
15
+ * Stop and the row is now 'cancelled', the final write no-ops.
16
+ * - Tool-based submission: validated server-side via Zod + content checks
17
+ * (mcp-server.ts). Falls back to fenced JSON parsing for resilience.
18
+ */
19
+ import { query } from '@anthropic-ai/claude-agent-sdk';
20
+ import { getGitHubConfigByProduct } from '../../api/github.js';
21
+ import { DEFAULT_MODEL } from '../../constants.js';
22
+ import { getSupabase } from '../../supabase/client.js';
23
+ import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
24
+ import { cleanupIssueRepo, cloneIssueRepo, ensureWorkspaceDir, } from '../../workspace/workspace-manager.js';
25
+ import { fetchProductBasics } from '../find-shared/mcp.js';
26
+ import { createPromptGenerator, extractTextFromContent, tryExtractResult, } from '../pr-shared/agent-utils.js';
27
+ import { createTechniquesCaptureState, createTechniquesMcpServer, validateContent, } from './mcp-server.js';
28
+ import { createProductTechniquesSystemPrompt, createProductTechniquesUserPrompt, } from './prompts.js';
29
+ import { isTechniquesExtraction, TECHNIQUES_CONTENT_MAX, TECHNIQUES_SUMMARY_MAX, } from './types.js';
30
+ const WORKSPACE_KEY = 'product-techniques';
31
+ const MAX_TURNS = 120;
32
+ // Heartbeat cadence: at most one DB write per HEARTBEAT_MIN_INTERVAL_MS.
33
+ // Triggered on every assistant message so a stalled agent (no messages
34
+ // flowing) lets the row go stale and the reader can mark it failed.
35
+ const HEARTBEAT_MIN_INTERVAL_MS = 15_000;
36
+ export async function runProductTechniquesPhase(options) {
37
+ const { productId, techniquesId, guidance, verbose } = options;
38
+ logInfo(`Starting product-techniques generation for product ${productId}`);
39
+ const supabase = getSupabase();
40
+ const claimed = await markRunning(supabase, techniquesId);
41
+ if (!claimed) {
42
+ return {
43
+ status: 'cancelled',
44
+ message: 'Techniques row is no longer in a runnable state (likely cancelled before the CLI started)',
45
+ };
46
+ }
47
+ const githubConfig = await getGitHubConfigByProduct(productId, verbose);
48
+ if (!githubConfig.configured ||
49
+ !githubConfig.token ||
50
+ !githubConfig.owner ||
51
+ !githubConfig.repo) {
52
+ const msg = githubConfig.message ||
53
+ 'GitHub repository not configured for this product. Connect a repo first.';
54
+ await markFailed(supabase, techniquesId, msg);
55
+ return { status: 'error', message: msg };
56
+ }
57
+ let repoPath;
58
+ let succeeded = false;
59
+ try {
60
+ const workspaceRoot = ensureWorkspaceDir();
61
+ const repoKey = `${WORKSPACE_KEY}-${productId}`;
62
+ ({ repoPath } = cloneIssueRepo(workspaceRoot, repoKey, githubConfig.owner, githubConfig.repo, githubConfig.token));
63
+ const product = await fetchProductBasics(productId);
64
+ const systemPrompt = createProductTechniquesSystemPrompt();
65
+ const userPrompt = createProductTechniquesUserPrompt({
66
+ productName: product.name,
67
+ productDescription: product.description,
68
+ guidance,
69
+ });
70
+ logInfo('Running Claude agent to write techniques catalogue...');
71
+ const captureState = createTechniquesCaptureState();
72
+ const mcpServer = createTechniquesMcpServer(captureState);
73
+ let lastAssistantResponse = '';
74
+ let lastHeartbeatAt = 0;
75
+ for await (const message of query({
76
+ prompt: createPromptGenerator(userPrompt),
77
+ options: {
78
+ systemPrompt: {
79
+ type: 'preset',
80
+ preset: 'claude_code',
81
+ append: systemPrompt,
82
+ },
83
+ model: DEFAULT_MODEL,
84
+ maxTurns: MAX_TURNS,
85
+ permissionMode: 'bypassPermissions',
86
+ cwd: repoPath,
87
+ mcpServers: {
88
+ 'product-techniques': mcpServer,
89
+ },
90
+ },
91
+ })) {
92
+ if (message.type === 'assistant') {
93
+ lastAssistantResponse += extractTextFromContent(message.message?.content ?? [], verbose);
94
+ // Throttled heartbeat. Awaited (cheap UPDATE) so we don't pile up
95
+ // unresolved promises on a long run.
96
+ const now = Date.now();
97
+ if (now - lastHeartbeatAt >= HEARTBEAT_MIN_INTERVAL_MS) {
98
+ lastHeartbeatAt = now;
99
+ await heartbeat(supabase, techniquesId);
100
+ }
101
+ continue;
102
+ }
103
+ if (message.type !== 'result') {
104
+ continue;
105
+ }
106
+ // Prefer the MCP-captured extraction. Fall back to fenced JSON only
107
+ // if the agent ignored the tool — robustness, not a contract.
108
+ const fromTool = captureState.captured;
109
+ const fromFallback = fromTool
110
+ ? null
111
+ : tryFallbackParse(message, lastAssistantResponse);
112
+ const extraction = fromTool ?? fromFallback;
113
+ if (!extraction) {
114
+ const msg = message.subtype === 'success'
115
+ ? 'Techniques generation failed: agent did not call submit_techniques and no parseable fallback was found'
116
+ : `Techniques generation failed: agent ${message.subtype}`;
117
+ const written = await markFailed(supabase, techniquesId, msg);
118
+ return {
119
+ status: written ? 'error' : 'cancelled',
120
+ message: written
121
+ ? msg
122
+ : 'Generation was cancelled while the agent was running',
123
+ };
124
+ }
125
+ if (fromFallback) {
126
+ logWarning('Agent skipped submit_techniques; used fenced fallback JSON instead');
127
+ }
128
+ const written = await markSuccess(supabase, techniquesId, extraction.summary, extraction.content);
129
+ if (!written) {
130
+ return {
131
+ status: 'cancelled',
132
+ message: 'Generation was cancelled before the result could be written',
133
+ };
134
+ }
135
+ succeeded = true;
136
+ logSuccess(`Techniques catalogue generated (${extraction.content.length} chars of markdown)`);
137
+ return {
138
+ status: 'success',
139
+ message: 'Techniques catalogue generated',
140
+ summary: extraction.summary,
141
+ };
142
+ }
143
+ // Loop ended without a 'result' message — treat as failure.
144
+ const msg = 'Techniques generation ended without a result message';
145
+ await markFailed(supabase, techniquesId, msg);
146
+ return { status: 'error', message: msg };
147
+ }
148
+ catch (error) {
149
+ const errorMessage = error instanceof Error ? error.message : String(error);
150
+ logError(`Techniques generation failed: ${errorMessage}`);
151
+ await markFailed(supabase, techniquesId, errorMessage);
152
+ return { status: 'error', message: errorMessage };
153
+ }
154
+ finally {
155
+ if (succeeded) {
156
+ cleanupIssueRepo(repoPath);
157
+ }
158
+ }
159
+ }
160
+ // Fallback parser: extract a fenced `techniques` JSON block from the final
161
+ // assistant text if the agent skipped the submit_techniques tool. Backstop
162
+ // only — we expect the tool path to be the norm.
163
+ function tryFallbackParse(resultMessage, assistantText) {
164
+ const responseText = resultMessage.subtype === 'success'
165
+ ? resultMessage.result || assistantText
166
+ : assistantText;
167
+ const parsed = tryExtractResult(responseText, 'techniques');
168
+ if (!isTechniquesExtraction(parsed)) {
169
+ return null;
170
+ }
171
+ if (parsed.summary.length > TECHNIQUES_SUMMARY_MAX) {
172
+ return null;
173
+ }
174
+ if (parsed.content.length > TECHNIQUES_CONTENT_MAX) {
175
+ return null;
176
+ }
177
+ const { error } = validateContent(parsed.content);
178
+ if (error) {
179
+ return null;
180
+ }
181
+ return parsed;
182
+ }
183
+ // ============================================================================
184
+ // Persistence — exported for unit tests
185
+ // ============================================================================
186
+ /**
187
+ * Claim the row by flipping `pending` → `running`. Returns true on success
188
+ * (we won the claim) and false when the row has already moved on (e.g. user
189
+ * cancelled before the CLI started). Bounded by the status filter so we
190
+ * can't accidentally resurrect a 'cancelled' row.
191
+ */
192
+ export async function markRunning(supabase, techniquesId) {
193
+ const { data, error } = await supabase
194
+ .from('product_techniques')
195
+ .update({
196
+ status: 'running',
197
+ error: null,
198
+ last_heartbeat_at: new Date().toISOString(),
199
+ })
200
+ .eq('id', techniquesId)
201
+ .in('status', ['pending', 'running'])
202
+ .select('id')
203
+ .maybeSingle();
204
+ if (error) {
205
+ logWarning(`Could not mark techniques as running: ${error.message}`);
206
+ return false;
207
+ }
208
+ return data !== null;
209
+ }
210
+ /**
211
+ * Touch the heartbeat. Best-effort — if it fails (network blip, RLS), the
212
+ * agent loop keeps running; the reader treats this row as stale and marks
213
+ * it failed on next read, which is the correct behaviour.
214
+ */
215
+ export async function heartbeat(supabase, techniquesId) {
216
+ const { error } = await supabase
217
+ .from('product_techniques')
218
+ .update({ last_heartbeat_at: new Date().toISOString() })
219
+ .eq('id', techniquesId)
220
+ .eq('status', 'running');
221
+ if (error) {
222
+ logWarning(`Heartbeat failed: ${error.message}`);
223
+ }
224
+ }
225
+ /**
226
+ * Write failure status iff the row is still in an active state. Returns
227
+ * true if the row was actually updated (so the caller knows whether the
228
+ * agent's verdict made it to the DB). Returns false when the row has
229
+ * already been cancelled or otherwise resolved by someone else.
230
+ */
231
+ export async function markFailed(supabase, techniquesId, errorMessage) {
232
+ const { data, error } = await supabase
233
+ .from('product_techniques')
234
+ .update({
235
+ status: 'failed',
236
+ error: errorMessage,
237
+ completed_at: new Date().toISOString(),
238
+ })
239
+ .eq('id', techniquesId)
240
+ .in('status', ['pending', 'running'])
241
+ .select('id')
242
+ .maybeSingle();
243
+ if (error) {
244
+ logWarning(`Could not mark techniques as failed: ${error.message}`);
245
+ return false;
246
+ }
247
+ return data !== null;
248
+ }
249
+ export async function markSuccess(supabase, techniquesId, summary, content) {
250
+ const { data, error } = await supabase
251
+ .from('product_techniques')
252
+ .update({
253
+ status: 'success',
254
+ summary,
255
+ content,
256
+ error: null,
257
+ completed_at: new Date().toISOString(),
258
+ })
259
+ .eq('id', techniquesId)
260
+ .in('status', ['pending', 'running'])
261
+ .select('id')
262
+ .maybeSingle();
263
+ if (error) {
264
+ logWarning(`Could not mark techniques as success: ${error.message}`);
265
+ return false;
266
+ }
267
+ return data !== null;
268
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * In-process MCP server exposing a single tool — `submit_techniques` — that
3
+ * the Claude Agent SDK session calls to return the final markdown catalogue.
4
+ *
5
+ * Using a tool call instead of parsing a fenced JSON block lets the SDK
6
+ * enforce the schema (via Zod) and lets the agent self-correct when
7
+ * validation fails: the error message goes back as the tool result and the
8
+ * agent can re-call the tool with corrected data.
9
+ *
10
+ * The capture pattern: callers pass in a `TechniquesCaptureState`. The tool
11
+ * handler stores the validated args on `state.captured`. The orchestrator
12
+ * reads it after the SDK loop ends.
13
+ */
14
+ import { z } from 'zod';
15
+ import { type TechniquesExtraction } from './types.js';
16
+ export interface TechniquesCaptureState {
17
+ captured: TechniquesExtraction | null;
18
+ }
19
+ export declare function createTechniquesCaptureState(): TechniquesCaptureState;
20
+ /**
21
+ * Validation that goes beyond the basic Zod schema: enforce that the
22
+ * markdown actually has the H2 sections the prompt asks for. The agent gets
23
+ * an actionable error and can re-submit.
24
+ *
25
+ * We don't require ALL six sections — agents on tiny repos often legitimately
26
+ * collapse some. We require at least the first one ("Languages & Runtime")
27
+ * plus "Notable Techniques", which the prompt calls out as the whole point.
28
+ */
29
+ export declare function validateContent(content: string): {
30
+ error: string | null;
31
+ };
32
+ /**
33
+ * Build the `submit_techniques` tool. Exported separately from the server
34
+ * so tests can exercise the handler directly without going through MCP
35
+ * transport.
36
+ */
37
+ export declare function createSubmitTechniquesTool(state: TechniquesCaptureState): import("@anthropic-ai/claude-agent-sdk").SdkMcpToolDefinition<{
38
+ summary: z.ZodString;
39
+ content: z.ZodString;
40
+ }>;
41
+ export declare function createTechniquesMcpServer(state: TechniquesCaptureState): import("@anthropic-ai/claude-agent-sdk").McpSdkServerConfigWithInstance;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * In-process MCP server exposing a single tool — `submit_techniques` — that
3
+ * the Claude Agent SDK session calls to return the final markdown catalogue.
4
+ *
5
+ * Using a tool call instead of parsing a fenced JSON block lets the SDK
6
+ * enforce the schema (via Zod) and lets the agent self-correct when
7
+ * validation fails: the error message goes back as the tool result and the
8
+ * agent can re-call the tool with corrected data.
9
+ *
10
+ * The capture pattern: callers pass in a `TechniquesCaptureState`. The tool
11
+ * handler stores the validated args on `state.captured`. The orchestrator
12
+ * reads it after the SDK loop ends.
13
+ */
14
+ import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
15
+ import { z } from 'zod';
16
+ import { TECHNIQUES_CONTENT_MAX, TECHNIQUES_SUMMARY_MAX, } from './types.js';
17
+ export function createTechniquesCaptureState() {
18
+ return { captured: null };
19
+ }
20
+ /**
21
+ * Validation that goes beyond the basic Zod schema: enforce that the
22
+ * markdown actually has the H2 sections the prompt asks for. The agent gets
23
+ * an actionable error and can re-submit.
24
+ *
25
+ * We don't require ALL six sections — agents on tiny repos often legitimately
26
+ * collapse some. We require at least the first one ("Languages & Runtime")
27
+ * plus "Notable Techniques", which the prompt calls out as the whole point.
28
+ */
29
+ export function validateContent(content) {
30
+ const required = [
31
+ /^##\s+Languages\s*&\s*Runtime\b/im,
32
+ /^##\s+Notable\s+Techniques\b/im,
33
+ ];
34
+ for (const re of required) {
35
+ if (!re.test(content)) {
36
+ return {
37
+ error: `content is missing the required section matching ${re.source}. Add the section and re-call submit_techniques.`,
38
+ };
39
+ }
40
+ }
41
+ return { error: null };
42
+ }
43
+ /**
44
+ * Build the `submit_techniques` tool. Exported separately from the server
45
+ * so tests can exercise the handler directly without going through MCP
46
+ * transport.
47
+ */
48
+ export function createSubmitTechniquesTool(state) {
49
+ return tool('submit_techniques', [
50
+ 'Submit the final techniques catalogue. Call this EXACTLY once, when',
51
+ 'you have finished cataloguing every technique. Pass the summary and',
52
+ 'the full markdown content as arguments. After this call succeeds, end',
53
+ 'your turn — do NOT also paste the same content as a fenced code block.',
54
+ 'If validation fails, the error message tells you what to fix; call the',
55
+ 'tool again with corrected data.',
56
+ ].join(' '), {
57
+ summary: z
58
+ .string()
59
+ .min(1)
60
+ .max(TECHNIQUES_SUMMARY_MAX)
61
+ .describe('1-2 sentence summary suitable for a tab header. Plain text, no markdown.'),
62
+ content: z
63
+ .string()
64
+ .min(1)
65
+ .max(TECHNIQUES_CONTENT_MAX)
66
+ .describe('Full markdown body with H2 sections: Languages & Runtime, Frameworks & Libraries, Architecture Patterns, State & Data Techniques, Build & Deploy Techniques, Notable Techniques.'),
67
+ }, async (args) => {
68
+ const extraction = {
69
+ summary: args.summary,
70
+ content: args.content,
71
+ };
72
+ const { error } = validateContent(extraction.content);
73
+ if (error) {
74
+ return {
75
+ content: [{ type: 'text', text: error }],
76
+ isError: true,
77
+ };
78
+ }
79
+ state.captured = extraction;
80
+ return {
81
+ content: [
82
+ {
83
+ type: 'text',
84
+ text: `Captured techniques catalogue (${extraction.content.length} chars). End your turn now.`,
85
+ },
86
+ ],
87
+ };
88
+ });
89
+ }
90
+ export function createTechniquesMcpServer(state) {
91
+ return createSdkMcpServer({
92
+ name: 'product-techniques',
93
+ version: '1.0.0',
94
+ tools: [createSubmitTechniquesTool(state)],
95
+ });
96
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Prompts for the product-level techniques phase.
3
+ *
4
+ * Agent's job: explore the cloned product repo and write a markdown catalogue
5
+ * of the techniques it actually uses — languages, frameworks, patterns, state
6
+ * idioms, build/deploy choices, plus the clever non-obvious bits. Focused on
7
+ * what a new engineer needs to recognize and recreate, not feature lists.
8
+ *
9
+ * The final result is submitted via the `submit_techniques` MCP tool, NOT a
10
+ * fenced JSON block. Tool calls let the SDK enforce the schema with Zod and
11
+ * let the agent self-correct on validation errors. See mcp-server.ts.
12
+ */
13
+ export interface ProductTechniquesPromptContext {
14
+ productName: string;
15
+ productDescription?: string;
16
+ guidance?: string;
17
+ }
18
+ export declare function createProductTechniquesSystemPrompt(): string;
19
+ export declare function createProductTechniquesUserPrompt(context: ProductTechniquesPromptContext): string;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Prompts for the product-level techniques phase.
3
+ *
4
+ * Agent's job: explore the cloned product repo and write a markdown catalogue
5
+ * of the techniques it actually uses — languages, frameworks, patterns, state
6
+ * idioms, build/deploy choices, plus the clever non-obvious bits. Focused on
7
+ * what a new engineer needs to recognize and recreate, not feature lists.
8
+ *
9
+ * The final result is submitted via the `submit_techniques` MCP tool, NOT a
10
+ * fenced JSON block. Tool calls let the SDK enforce the schema with Zod and
11
+ * let the agent self-correct on validation errors. See mcp-server.ts.
12
+ */
13
+ export function createProductTechniquesSystemPrompt() {
14
+ return `You are a senior staff engineer cataloguing the techniques a product's codebase uses.
15
+
16
+ The current working directory is a fresh clone of the product's repository. Use Glob/Grep/Read (and Bash for git/log when helpful) to explore it before writing.
17
+
18
+ Your audience: a new engineer joining the team. They need to recognize WHICH techniques this repo employs — what frameworks, what patterns, what idioms — so they can read code without surprise and contribute in the codebase's existing style. This is not a feature list and not an architecture proposal. Anchor every technique in the actual code; reference real file paths.
19
+
20
+ ## Output protocol
21
+
22
+ When you are ready to submit, call the \`submit_techniques\` tool EXACTLY ONCE with:
23
+ - \`summary\` — 1-2 sentence summary suitable for a tab header (plain text, no markdown).
24
+ - \`content\` — the full markdown body, structured as described below.
25
+
26
+ Do NOT also paste the same content as a fenced code block — the tool call is the only channel for the result. If validation fails, the tool response tells you what to fix; call the tool again with corrected data.
27
+
28
+ ## Required sections in \`content\`
29
+
30
+ The markdown body MUST contain these H2 sections, in this order:
31
+
32
+ 1. **## Languages & Runtime** — languages used (with versions if pinned), runtime targets (Node / Bun / Deno / browser / native / etc), package manager.
33
+ 2. **## Frameworks & Libraries** — the major frameworks and libraries this repo depends on, and what each one is doing here. Don't just list package names; explain the role. Group by area (UI, server, data, infra) if it helps.
34
+ 3. **## Architecture Patterns** — the architectural choices the code embodies: layering / module boundaries / dependency direction / how features are organised. Include one Mermaid \`flowchart TD\` showing the dominant pattern (≤ 10 nodes). Name the pattern when you can ("repository pattern", "feature-sliced design", "hexagonal", "BFF", etc.).
35
+ 4. **## State & Data Techniques** — state management, data fetching, caching, optimistic updates, validation, type-safety across boundaries. How is server state vs client state handled? How are forms managed? How is data flowing between server and client?
36
+ 5. **## Build & Deploy Techniques** — bundler / compiler choices, code-splitting, environment handling, monorepo tooling, CI/CD specifics, deployment target (edge / serverless / containers / static).
37
+ 6. **## Notable Techniques** — the genuinely interesting bits that aren't obvious from the stack: clever abstractions, performance optimizations, security hardening, custom hooks/utilities worth knowing, intentional deviations from defaults, surprising workarounds. Each one: what it is, where it lives (file path), why it matters. This section is the whole point — be specific and useful here.
38
+
39
+ The validator will reject content missing the "Languages & Runtime" or "Notable Techniques" headings.
40
+
41
+ ## Rules
42
+
43
+ - Use relative file paths from the repo root (e.g. \`src/services/auth.ts\`). Link to specific files where the technique is most visible.
44
+ - Mermaid blocks must be valid (no unclosed quotes, no unsupported node shapes). Prefer simple \`flowchart TD\` syntax.
45
+ - Don't invent or guess. If a section has nothing distinctive ("just a stock CRA app"), say so briefly — don't pad.
46
+ - Focus on TECHNIQUES, not features. Bad: "the app has a login screen". Good: "uses next-auth's credentials provider with a custom JWT callback at src/lib/auth.ts:42".
47
+ - The whole document should fit in one screen-readable scroll — aim for ~1500-3000 words of content. Less is fine for small repos.`;
48
+ }
49
+ export function createProductTechniquesUserPrompt(context) {
50
+ const lines = [];
51
+ lines.push(`# Product: ${context.productName}`);
52
+ if (context.productDescription) {
53
+ lines.push('');
54
+ lines.push('## Description');
55
+ lines.push(context.productDescription);
56
+ }
57
+ if (context.guidance && context.guidance.trim()) {
58
+ lines.push('');
59
+ lines.push('## Reviewer guidance (focus or exclusions)');
60
+ lines.push(context.guidance.trim());
61
+ }
62
+ lines.push('');
63
+ lines.push('## Task');
64
+ lines.push('Explore the cloned repository and produce the techniques catalogue, then submit it via the `submit_techniques` MCP tool as specified in your system prompt. Pay particular attention to the "Notable Techniques" section — that is the real value to the reader.');
65
+ return lines.join('\n');
66
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Shape returned by the agent via the `submit_techniques` MCP tool. Mirrors
3
+ * the Zod schema in `mcp-server.ts` — kept duplicated as a plain TS type so
4
+ * consumers (the phase orchestrator, persistence helpers, tests) don't have
5
+ * to inflate Zod into their dependency graph.
6
+ */
7
+ export interface TechniquesExtraction {
8
+ summary: string;
9
+ content: string;
10
+ }
11
+ export declare function isTechniquesExtraction(v: unknown): v is TechniquesExtraction;
12
+ export declare const TECHNIQUES_SUMMARY_MAX = 500;
13
+ export declare const TECHNIQUES_CONTENT_MAX = 200000;
@@ -0,0 +1,13 @@
1
+ export function isTechniquesExtraction(v) {
2
+ if (!v || typeof v !== 'object') {
3
+ return false;
4
+ }
5
+ const obj = v;
6
+ return typeof obj.summary === 'string' && typeof obj.content === 'string';
7
+ }
8
+ // Defensive caps mirroring the DB CHECK constraints from
9
+ // 20260521000000_create_product_techniques.sql. Keeping the limits here
10
+ // too lets the MCP tool reject oversized output with an actionable error
11
+ // message instead of getting a Postgres constraint violation.
12
+ export const TECHNIQUES_SUMMARY_MAX = 500;
13
+ export const TECHNIQUES_CONTENT_MAX = 200_000;
@@ -42,10 +42,10 @@ export declare function createSubmitScreenFlowTool(state: ScreenFlowCaptureState
42
42
  file: z.ZodOptional<z.ZodString>;
43
43
  kind: z.ZodEnum<{
44
44
  page: "page";
45
+ state: "state";
45
46
  modal: "modal";
46
47
  drawer: "drawer";
47
48
  tab: "tab";
48
- state: "state";
49
49
  }>;
50
50
  layout: z.ZodEnum<{
51
51
  split: "split";
@@ -30,7 +30,7 @@ export async function getBranches(options) {
30
30
  // Fall through to MCP
31
31
  }
32
32
  }
33
- if (branches == null) {
33
+ if (!branches) {
34
34
  const result = (await callMcpEndpoint('branches/list', {
35
35
  issue_id: issueId,
36
36
  }));
@@ -79,7 +79,7 @@ export async function createBranches(options, branches) {
79
79
  // Fall through to MCP
80
80
  }
81
81
  }
82
- if (createdBranches == null) {
82
+ if (!createdBranches) {
83
83
  const result = (await callMcpEndpoint('branches/create', {
84
84
  issue_id: issueId,
85
85
  branches,
@@ -119,7 +119,7 @@ export async function updateBranch(branchId, updates, verbose) {
119
119
  // Fall through to MCP
120
120
  }
121
121
  }
122
- if (updated == null) {
122
+ if (!updated) {
123
123
  const result = (await callMcpEndpoint('branches/update', {
124
124
  branch_id: branchId,
125
125
  ...updates,
@@ -7,7 +7,7 @@ import { DEFAULT_MODEL } from '../../constants.js';
7
7
  import { logDebug } from '../../utils/logger.js';
8
8
  import { loadSkillFile } from './plugin-loader.js';
9
9
  const defaultDeps = {
10
- loadSkillFile: loadSkillFile,
10
+ loadSkillFile,
11
11
  queryFn: query,
12
12
  };
13
13
  // ---- Prompt building (pure) ----
@@ -116,7 +116,7 @@ export async function getPhaseRatings(issueId, phase, verbose) {
116
116
  // Fall through to MCP
117
117
  }
118
118
  }
119
- if (ratings == null) {
119
+ if (!ratings) {
120
120
  const result = await callMcpEndpoint('phase_ratings/list', {
121
121
  issue_id: issueId,
122
122
  phase,
@@ -46,7 +46,7 @@ export async function getPendingLogsByUser(productId, verbose) {
46
46
  // Fall through to MCP
47
47
  }
48
48
  }
49
- if (groups == null) {
49
+ if (!groups) {
50
50
  const result = (await callMcpEndpoint('product_logs/pending_by_user', {
51
51
  product_id: productId,
52
52
  }));
@@ -30,7 +30,7 @@ export async function getPullRequests(options) {
30
30
  // Fall through to MCP
31
31
  }
32
32
  }
33
- if (pullRequests == null) {
33
+ if (!pullRequests) {
34
34
  const result = (await callMcpEndpoint('pull_requests/list', {
35
35
  issue_id: issueId,
36
36
  }));
@@ -66,7 +66,7 @@ export async function createPullRequests(options, pullRequests) {
66
66
  // Fall through to MCP
67
67
  }
68
68
  }
69
- if (created == null) {
69
+ if (!created) {
70
70
  const result = (await callMcpEndpoint('pull_requests/create', {
71
71
  issue_id: issueId,
72
72
  pull_requests: pullRequests,
@@ -106,7 +106,7 @@ export async function updatePullRequest(prId, updates, verbose) {
106
106
  // Fall through to MCP
107
107
  }
108
108
  }
109
- if (updated == null) {
109
+ if (!updated) {
110
110
  const result = (await callMcpEndpoint('pull_requests/update', {
111
111
  pull_request_id: prId,
112
112
  ...updates,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edsger",
3
- "version": "0.58.0",
3
+ "version": "0.59.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "edsger": "dist/index.js"
package/vitest.config.ts CHANGED
@@ -16,6 +16,7 @@ export default defineConfig({
16
16
  'src/phases/sync-sentry-issues/__tests__/**/*.test.ts',
17
17
  'src/phases/sync-shared/__tests__/**/*.test.ts',
18
18
  'src/phases/screen-flow/__tests__/**/*.test.ts',
19
+ 'src/phases/product-techniques/__tests__/**/*.test.ts',
19
20
  'src/types/__tests__/**/*.test.ts',
20
21
  'src/commands/find-smells/__tests__/**/*.test.ts',
21
22
  ],