@ai-sdlc/orchestrator 0.4.0 → 0.6.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 (72) hide show
  1. package/dist/action-enforcement.d.ts +26 -0
  2. package/dist/action-enforcement.js +70 -0
  3. package/dist/adapters.d.ts +18 -3
  4. package/dist/adapters.js +92 -2
  5. package/dist/admission-score.d.ts +58 -0
  6. package/dist/admission-score.js +164 -0
  7. package/dist/cli/commands/init.js +4 -8
  8. package/dist/cli/commands/run.js +2 -2
  9. package/dist/config.d.ts +3 -0
  10. package/dist/config.js +14 -5
  11. package/dist/cycle-utils.d.ts +51 -0
  12. package/dist/cycle-utils.js +77 -0
  13. package/dist/defaults.d.ts +5 -0
  14. package/dist/defaults.js +5 -0
  15. package/dist/execute.d.ts +5 -2
  16. package/dist/execute.js +212 -62
  17. package/dist/fix-ci.js +45 -13
  18. package/dist/fix-review.d.ts +66 -0
  19. package/dist/fix-review.js +441 -0
  20. package/dist/index.d.ts +14 -4
  21. package/dist/index.js +18 -3
  22. package/dist/orchestrator.d.ts +1 -1
  23. package/dist/orchestrator.js +31 -9
  24. package/dist/pipeline-cycle-detector.d.ts +70 -0
  25. package/dist/pipeline-cycle-detector.js +111 -0
  26. package/dist/plugin.d.ts +9 -3
  27. package/dist/priority.d.ts +28 -0
  28. package/dist/priority.js +230 -0
  29. package/dist/review.d.ts +31 -0
  30. package/dist/review.js +74 -0
  31. package/dist/runners/claude-code.js +367 -35
  32. package/dist/runners/codex.js +15 -4
  33. package/dist/runners/copilot.js +15 -4
  34. package/dist/runners/cursor.js +15 -4
  35. package/dist/runners/generic-llm.js +1 -1
  36. package/dist/runners/index.d.ts +3 -1
  37. package/dist/runners/index.js +2 -0
  38. package/dist/runners/review-agent.d.ts +47 -0
  39. package/dist/runners/review-agent.js +220 -0
  40. package/dist/runners/security-triage.d.ts +43 -0
  41. package/dist/runners/security-triage.js +158 -0
  42. package/dist/runners/types.d.ts +24 -1
  43. package/dist/security.d.ts +8 -3
  44. package/dist/security.js +13 -2
  45. package/dist/shared.d.ts +17 -0
  46. package/dist/shared.js +27 -0
  47. package/dist/state/index.d.ts +1 -1
  48. package/dist/state/schema.d.ts +4 -1
  49. package/dist/state/schema.js +89 -1
  50. package/dist/state/store.d.ts +31 -1
  51. package/dist/state/store.js +208 -13
  52. package/dist/state/types.d.ts +52 -0
  53. package/dist/triage.d.ts +36 -0
  54. package/dist/triage.js +133 -0
  55. package/dist/types.d.ts +1 -1
  56. package/dist/watch.d.ts +6 -2
  57. package/dist/watch.js +34 -6
  58. package/dist/workflow-patterns/artifact-writer.d.ts +16 -0
  59. package/dist/workflow-patterns/artifact-writer.js +34 -0
  60. package/dist/workflow-patterns/classifiers.d.ts +10 -0
  61. package/dist/workflow-patterns/classifiers.js +72 -0
  62. package/dist/workflow-patterns/detector.d.ts +27 -0
  63. package/dist/workflow-patterns/detector.js +186 -0
  64. package/dist/workflow-patterns/index.d.ts +8 -0
  65. package/dist/workflow-patterns/index.js +7 -0
  66. package/dist/workflow-patterns/proposal-generator.d.ts +15 -0
  67. package/dist/workflow-patterns/proposal-generator.js +183 -0
  68. package/dist/workflow-patterns/telemetry-ingest.d.ts +27 -0
  69. package/dist/workflow-patterns/telemetry-ingest.js +103 -0
  70. package/dist/workflow-patterns/types.d.ts +61 -0
  71. package/dist/workflow-patterns/types.js +11 -0
  72. package/package.json +4 -2
package/dist/fix-ci.js CHANGED
@@ -8,11 +8,12 @@ import { loadConfig } from './config.js';
8
8
  import { createLogger } from './logger.js';
9
9
  import { createStructuredConsoleLogger } from './structured-logger.js';
10
10
  import { ClaudeCodeRunner } from './runners/claude-code.js';
11
- import { execFileAsync, getGitHubConfig, extractIssueNumber, resolveRepoRoot, createDefaultAuditLog, resolveAutonomyLevel, resolveConstraints, recordMetric, validateAndAuditOutput, authorizeFilesChanged, } from './shared.js';
11
+ import { execFileAsync, getGitHubConfig, extractIssueId, resolveRepoRoot, createDefaultAuditLog, resolveAutonomyLevel, resolveConstraints, recordMetric, validateAndAuditOutput, authorizeFilesChanged, issueIdToNumber, } from './shared.js';
12
12
  import { renderTemplate } from './notifications.js';
13
13
  import { parseDuration } from './policy-evaluators.js';
14
14
  import { checkKillSwitch, issueAgentCredentials, revokeAgentCredentials, } from './security.js';
15
15
  import { DEFAULT_MAX_FIX_ATTEMPTS, DEFAULT_MAX_LOG_LINES, DEFAULT_GH_CLI_TIMEOUT_MS, DEFAULT_CONFIG_DIR_NAME, defaultSandboxConstraints, NOTIFICATION_TITLES, } from './defaults.js';
16
+ import { createCycleDetectorFromConfig, checkAndHandleCycle } from './cycle-utils.js';
16
17
  export const MAX_FIX_ATTEMPTS = DEFAULT_MAX_FIX_ATTEMPTS;
17
18
  export const MAX_LOG_LINES = DEFAULT_MAX_LOG_LINES;
18
19
  export const RETRY_MARKER = '<!-- ai-sdlc-fix-ci-attempt -->';
@@ -107,6 +108,8 @@ export async function executeFixCI(prNumber, runId, options = {}) {
107
108
  }
108
109
  // Tracker is available if injected directly or if we're not in test mode
109
110
  const trackerAvailable = !!options.tracker || options._prComments === undefined;
111
+ // Create cycle detector (marker generated after all guard conditions pass)
112
+ const cycleDetector = createCycleDetectorFromConfig(config.pipeline?.spec ?? {});
110
113
  // 2. Count retry attempts (via injected comments or IssueTracker)
111
114
  log.stage('check-retries');
112
115
  let comments;
@@ -126,6 +129,27 @@ export async function executeFixCI(prNumber, runId, options = {}) {
126
129
  await getTracker().addComment(String(prNumber), body);
127
130
  }
128
131
  };
132
+ // Check for pipeline-level cycles AFTER retry counting
133
+ // (so legitimate retries under the limit aren't blocked)
134
+ if (trackerAvailable) {
135
+ const cycleCheck = await checkAndHandleCycle({
136
+ issueOrPrId: String(prNumber),
137
+ stage: 'fix-ci',
138
+ tracker: getTracker(),
139
+ detector: cycleDetector,
140
+ });
141
+ if (cycleCheck.cycleDetected) {
142
+ log.info('Pipeline cycle detected. Halting fix-ci execution.');
143
+ auditLog.record({
144
+ actor: 'system',
145
+ action: 'evaluate',
146
+ resource: `pr#${prNumber}`,
147
+ decision: 'denied',
148
+ details: { reason: 'pipeline-cycle-detected' },
149
+ });
150
+ return;
151
+ }
152
+ }
129
153
  if (attempts >= maxFixAttempts) {
130
154
  log.info(`Fix-CI retry limit reached (${maxFixAttempts}). Commenting and stopping.`);
131
155
  auditLog.record({
@@ -148,6 +172,9 @@ export async function executeFixCI(prNumber, runId, options = {}) {
148
172
  await addComment(`## ${limitComment.title}\n\n${limitComment.body}`);
149
173
  return;
150
174
  }
175
+ // Generate cycle marker AFTER all guard conditions (retry limit + cycle detection)
176
+ // so it's only recorded when we actually proceed with execution
177
+ const cycleMarker = cycleDetector.recordInvocation('fix-ci');
151
178
  // 3. Fetch CI logs
152
179
  log.stage('fetch-logs');
153
180
  const ciLogs = await fetchCILogs(runId, options._ciLogs, options.ciAdapter);
@@ -157,24 +184,25 @@ export async function executeFixCI(prNumber, runId, options = {}) {
157
184
  cwd: workDir,
158
185
  });
159
186
  const currentBranch = branchStdout.trim();
160
- const issueNumber = extractIssueNumber(currentBranch);
161
- if (issueNumber === null) {
162
- throw new Error(`Branch "${currentBranch}" does not match ai-sdlc/issue-N pattern`);
187
+ const issueId = extractIssueId(currentBranch);
188
+ if (issueId === null) {
189
+ throw new Error(`Branch "${currentBranch}" does not match ai-sdlc/issue-<id> pattern`);
163
190
  }
191
+ const issueNumber = issueIdToNumber(issueId);
164
192
  // 5. Resolve autonomy level and constraints
165
193
  const currentLevel = resolveAutonomyLevel(autonomyPolicy);
166
194
  const resolved = resolveConstraints(agentRole.spec.constraints, currentLevel);
167
195
  // 6. Fetch issue data (via tracker when available)
168
- let issueTitle = `Issue #${issueNumber}`;
196
+ let issueTitle = `Issue ${issueId}`;
169
197
  let issueBody = '';
170
198
  if (trackerAvailable) {
171
- const issueData = await getTracker().getIssue(String(issueNumber));
199
+ const issueData = await getTracker().getIssue(issueId);
172
200
  issueTitle = issueData.title;
173
201
  issueBody = issueData.description ?? '';
174
202
  }
175
203
  // Store issue context in working memory
176
204
  if (options.memory) {
177
- options.memory.working.set('currentIssue', { prNumber, issueNumber, currentBranch });
205
+ options.memory.working.set('currentIssue', { prNumber, issueId, currentBranch });
178
206
  }
179
207
  // Query episodic memory for previous fix-CI attempts
180
208
  if (options.memory) {
@@ -195,7 +223,7 @@ export async function executeFixCI(prNumber, runId, options = {}) {
195
223
  try {
196
224
  if (options.security) {
197
225
  const timeoutMs = codeStage?.timeout ? parseDuration(codeStage.timeout) : undefined;
198
- sandboxId = await options.security.sandbox.isolate(`issue-${issueNumber}`, defaultSandboxConstraints(workDir, timeoutMs));
226
+ sandboxId = await options.security.sandbox.isolate(`issue-${issueId}`, defaultSandboxConstraints(workDir, timeoutMs));
199
227
  }
200
228
  // Issue JIT credentials before agent execution
201
229
  const jitCred = options.security
@@ -207,7 +235,8 @@ export async function executeFixCI(prNumber, runId, options = {}) {
207
235
  [ATTRIBUTE_KEYS.RESOURCE_NAME]: `pr#${prNumber}`,
208
236
  }, async () => {
209
237
  const r = await runner.run({
210
- issueNumber,
238
+ issueId,
239
+ issueNumber: issueNumber ?? undefined,
211
240
  issueTitle,
212
241
  issueBody,
213
242
  workDir,
@@ -257,7 +286,8 @@ export async function executeFixCI(prNumber, runId, options = {}) {
257
286
  const agentFailComment = agentFailTpl
258
287
  ? renderTemplate(agentFailTpl, { stageName: 'fix-ci', details: errorDetail })
259
288
  : { title: NOTIFICATION_TITLES.fixCIAgentFailed, body: errorDetail };
260
- await addComment(`## ${agentFailComment.title}\n\n${agentFailComment.body}\n\n${RETRY_MARKER}`);
289
+ // Use pre-created marker (one per execution, prevents double-counting)
290
+ await addComment(`## ${agentFailComment.title}\n\n${agentFailComment.body}\n\n${RETRY_MARKER}\n${cycleMarker}`);
261
291
  throw new Error(`Fix-CI agent failed on PR #${prNumber}: ${r.error}`);
262
292
  }
263
293
  log.stageEnd('agent');
@@ -306,7 +336,7 @@ export async function executeFixCI(prNumber, runId, options = {}) {
306
336
  auditLog,
307
337
  log,
308
338
  onViolation: async (violationList) => {
309
- await addComment(`## ${NOTIFICATION_TITLES.fixCIGuardrailViolations}\n\n${violationList}\n\n${RETRY_MARKER}`);
339
+ await addComment(`## ${NOTIFICATION_TITLES.fixCIGuardrailViolations}\n\n${violationList}\n\n${RETRY_MARKER}\n${cycleMarker}`);
310
340
  },
311
341
  });
312
342
  });
@@ -333,6 +363,7 @@ export async function executeFixCI(prNumber, runId, options = {}) {
333
363
  title: NOTIFICATION_TITLES.fixCIApplied,
334
364
  body: `Attempt ${attempts + 1} of ${maxFixAttempts} — pushed fixes to \`${currentBranch}\`.`,
335
365
  };
366
+ // Use pre-created marker (one per execution, prevents double-counting)
336
367
  await addComment([
337
368
  `## ${successComment.title}`,
338
369
  '',
@@ -342,6 +373,7 @@ export async function executeFixCI(prNumber, runId, options = {}) {
342
373
  result.filesChanged.map((f) => `- \`${f}\``).join('\n'),
343
374
  '',
344
375
  RETRY_MARKER,
376
+ cycleMarker,
345
377
  ].join('\n'));
346
378
  // 12. Record episodic memory (success)
347
379
  if (options.memory) {
@@ -349,7 +381,7 @@ export async function executeFixCI(prNumber, runId, options = {}) {
349
381
  key: 'fix-ci-execution',
350
382
  value: {
351
383
  prNumber,
352
- issueNumber,
384
+ issueId,
353
385
  filesChanged: result.filesChanged.length,
354
386
  outcome: 'success',
355
387
  },
@@ -365,7 +397,7 @@ export async function executeFixCI(prNumber, runId, options = {}) {
365
397
  key: 'fix-ci-execution',
366
398
  value: {
367
399
  prNumber,
368
- issueNumber,
400
+ issueId,
369
401
  outcome: 'failure',
370
402
  error: err instanceof Error ? err.message : String(err),
371
403
  },
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Fix-Review orchestrator — detects review findings on agent-created PRs,
3
+ * fetches review comments, and re-invokes the agent with review context.
4
+ * Capped at MAX_FIX_ATTEMPTS to prevent infinite loops.
5
+ */
6
+ import { type IssueTracker, type AuditLog, type MetricStore, type AgentMemory, type SecretStore } from '@ai-sdlc/reference';
7
+ import { type Logger } from './logger.js';
8
+ import type { AgentRunner } from './runners/types.js';
9
+ import { type SecurityContext } from './security.js';
10
+ export declare const MAX_REVIEW_FIX_ATTEMPTS = 2;
11
+ export declare const RETRY_MARKER = "<!-- ai-sdlc-fix-review-attempt -->";
12
+ export interface FixReviewOptions {
13
+ /** Override the config directory (defaults to `.ai-sdlc`). */
14
+ configDir?: string;
15
+ /** Override the working directory (defaults to repo root). */
16
+ workDir?: string;
17
+ /** Inject a custom runner (for testing). */
18
+ runner?: AgentRunner;
19
+ /** Inject a custom logger (for testing). */
20
+ logger?: Logger;
21
+ /** Inject PR comments for testing (bypasses IssueTracker call). */
22
+ _prComments?: string[];
23
+ /** Inject review findings for testing (bypasses GitHub API call). */
24
+ _reviewFindings?: string;
25
+ /** Inject a custom audit log (for testing). */
26
+ auditLog?: AuditLog;
27
+ /** Inject a custom issue tracker (for testing). */
28
+ tracker?: IssueTracker;
29
+ /** In-process metric store for testable telemetry. */
30
+ metricStore?: MetricStore;
31
+ /** Agent memory for episodic recall. */
32
+ memory?: AgentMemory;
33
+ /** Security context for kill switch and JIT credentials. */
34
+ security?: SecurityContext;
35
+ /** Use the reference structured logger instead of the plain console logger. */
36
+ useStructuredLogger?: boolean;
37
+ /** Secret store adapter for resolving credentials (defaults to process.env). */
38
+ secretStore?: SecretStore;
39
+ }
40
+ /**
41
+ * Count how many fix-review retry attempts have been made on a PR
42
+ * by scanning comments for the hidden retry marker.
43
+ */
44
+ export declare function countRetryAttempts(comments: string[]): number;
45
+ /**
46
+ * Validate that a PR number is a positive integer.
47
+ */
48
+ export declare function validatePrNumber(prNumber: number): void;
49
+ /**
50
+ * Sanitize a git branch name to prevent command injection.
51
+ * Allows only alphanumeric characters, slashes, dashes, underscores, and dots.
52
+ */
53
+ export declare function sanitizeBranchName(branch: string): string;
54
+ /**
55
+ * Fetch review findings from PR reviews that requested changes.
56
+ * Returns a formatted string with all findings from review agents.
57
+ */
58
+ export declare function fetchReviewFindings(prNumber: number, injectedFindings?: string, _secretStore?: SecretStore): Promise<string>;
59
+ /**
60
+ * Execute the fix-review pipeline for a PR with review findings.
61
+ *
62
+ * Returns gracefully (no throw) when the retry limit is reached.
63
+ * Throws on agent failure or guardrail violations.
64
+ */
65
+ export declare function executeFixReview(prNumber: number, options?: FixReviewOptions): Promise<void>;
66
+ //# sourceMappingURL=fix-review.d.ts.map
@@ -0,0 +1,441 @@
1
+ /**
2
+ * Fix-Review orchestrator — detects review findings on agent-created PRs,
3
+ * fetches review comments, and re-invokes the agent with review context.
4
+ * Capped at MAX_FIX_ATTEMPTS to prevent infinite loops.
5
+ */
6
+ import { createGitHubIssueTracker, evaluateDemotion, withSpan, getMeter, SPAN_NAMES, METRIC_NAMES, ATTRIBUTE_KEYS, } from '@ai-sdlc/reference';
7
+ import { loadConfig } from './config.js';
8
+ import { createLogger } from './logger.js';
9
+ import { createStructuredConsoleLogger } from './structured-logger.js';
10
+ import { ClaudeCodeRunner } from './runners/claude-code.js';
11
+ import { execFileAsync, getGitHubConfig, extractIssueId, resolveRepoRoot, createDefaultAuditLog, resolveAutonomyLevel, resolveConstraints, recordMetric, validateAndAuditOutput, authorizeFilesChanged, issueIdToNumber, } from './shared.js';
12
+ import { renderTemplate } from './notifications.js';
13
+ import { parseDuration } from './policy-evaluators.js';
14
+ import { checkKillSwitch, issueAgentCredentials, revokeAgentCredentials, } from './security.js';
15
+ import { DEFAULT_GH_CLI_TIMEOUT_MS, DEFAULT_CONFIG_DIR_NAME, defaultSandboxConstraints, NOTIFICATION_TITLES, } from './defaults.js';
16
+ import { createCycleDetectorFromConfig, checkAndHandleCycle } from './cycle-utils.js';
17
+ // Default max review-fix attempts (lower than CI-fix since reviews are more deterministic)
18
+ export const MAX_REVIEW_FIX_ATTEMPTS = 2;
19
+ export const RETRY_MARKER = '<!-- ai-sdlc-fix-review-attempt -->';
20
+ /**
21
+ * Count how many fix-review retry attempts have been made on a PR
22
+ * by scanning comments for the hidden retry marker.
23
+ */
24
+ export function countRetryAttempts(comments) {
25
+ let count = 0;
26
+ for (const body of comments) {
27
+ const matches = body.match(new RegExp(RETRY_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'));
28
+ if (matches) {
29
+ count += matches.length;
30
+ }
31
+ }
32
+ return count;
33
+ }
34
+ /**
35
+ * Validate that a PR number is a positive integer.
36
+ */
37
+ export function validatePrNumber(prNumber) {
38
+ if (!Number.isInteger(prNumber) || prNumber <= 0) {
39
+ throw new Error(`Invalid PR number: ${prNumber} (must be a positive integer)`);
40
+ }
41
+ }
42
+ /**
43
+ * Sanitize a git branch name to prevent command injection.
44
+ * Allows only alphanumeric characters, slashes, dashes, underscores, and dots.
45
+ */
46
+ export function sanitizeBranchName(branch) {
47
+ if (!/^[a-zA-Z0-9/_.-]+$/.test(branch)) {
48
+ throw new Error(`Invalid branch name: "${branch}" (only alphanumeric, /, -, _, . allowed)`);
49
+ }
50
+ return branch;
51
+ }
52
+ /**
53
+ * Fetch review findings from PR reviews that requested changes.
54
+ * Returns a formatted string with all findings from review agents.
55
+ */
56
+ export async function fetchReviewFindings(prNumber, injectedFindings, _secretStore) {
57
+ if (injectedFindings !== undefined) {
58
+ return injectedFindings;
59
+ }
60
+ // Validate PR number to prevent command injection
61
+ validatePrNumber(prNumber);
62
+ // Fetch reviews using gh CLI
63
+ const { stdout } = await execFileAsync('gh', ['pr', 'review', String(prNumber), '--json', 'state,body,author'], { timeout: DEFAULT_GH_CLI_TIMEOUT_MS });
64
+ let reviews;
65
+ try {
66
+ reviews = JSON.parse(stdout);
67
+ }
68
+ catch (err) {
69
+ throw new Error(`Failed to parse review data from gh CLI: ${err instanceof Error ? err.message : String(err)}`);
70
+ }
71
+ const changesRequestedReviews = reviews.filter((r) => r.state === 'CHANGES_REQUESTED');
72
+ if (changesRequestedReviews.length === 0) {
73
+ return 'No review findings (all reviews approved or pending)';
74
+ }
75
+ // Format findings from all reviews
76
+ const findings = changesRequestedReviews
77
+ .map((r) => `### Review by ${r.author.login}\n\n${r.body}\n\n---\n`)
78
+ .join('\n');
79
+ return findings;
80
+ }
81
+ /**
82
+ * Execute the fix-review pipeline for a PR with review findings.
83
+ *
84
+ * Returns gracefully (no throw) when the retry limit is reached.
85
+ * Throws on agent failure or guardrail violations.
86
+ */
87
+ export async function executeFixReview(prNumber, options = {}) {
88
+ const workDir = options.workDir ?? (await resolveRepoRoot());
89
+ const configDir = options.configDir ?? `${workDir}/${DEFAULT_CONFIG_DIR_NAME}`;
90
+ const log = options.logger ??
91
+ (options.useStructuredLogger ? createStructuredConsoleLogger() : createLogger());
92
+ const auditLog = options.auditLog ?? createDefaultAuditLog(workDir);
93
+ const metricStore = options.metricStore;
94
+ // 1. Load config
95
+ log.stage('load-config');
96
+ const config = loadConfig(configDir);
97
+ log.stageEnd('load-config');
98
+ // Kill switch check (before any work)
99
+ if (options.security) {
100
+ await checkKillSwitch(options.security);
101
+ }
102
+ if (!config.agentRole) {
103
+ throw new Error('No AgentRole resource found in .ai-sdlc/');
104
+ }
105
+ if (!config.autonomyPolicy) {
106
+ throw new Error('No AutonomyPolicy resource found in .ai-sdlc/');
107
+ }
108
+ const agentRole = config.agentRole;
109
+ const autonomyPolicy = config.autonomyPolicy;
110
+ // Derive max fix attempts from pipeline config (review stage onFailure.maxRetries)
111
+ const reviewStage = config.pipeline?.spec.stages.find((s) => s.name === 'review');
112
+ const maxFixAttempts = reviewStage?.onFailure?.maxRetries ?? MAX_REVIEW_FIX_ATTEMPTS;
113
+ // Notification templates
114
+ const notifTemplates = config.pipeline?.spec.notifications?.templates;
115
+ // Create default tracker when needed (lazy to avoid resolving secrets in test environments).
116
+ // In production (no _prComments injected), the tracker is always available.
117
+ let _tracker = options.tracker;
118
+ function getTracker() {
119
+ if (!_tracker) {
120
+ const { org, repo } = getGitHubConfig(options.secretStore);
121
+ const ghConfig = { org, repo, token: { secretRef: 'github-token' } };
122
+ _tracker = createGitHubIssueTracker(ghConfig);
123
+ }
124
+ return _tracker;
125
+ }
126
+ // Tracker is available if injected directly or if we're not in test mode
127
+ const trackerAvailable = !!options.tracker || options._prComments === undefined;
128
+ // Create cycle detector (marker generated after all guard conditions pass)
129
+ const cycleDetector = createCycleDetectorFromConfig(config.pipeline?.spec ?? {});
130
+ // 2. Count retry attempts (via injected comments or IssueTracker)
131
+ log.stage('check-retries');
132
+ let comments;
133
+ if (options._prComments !== undefined) {
134
+ comments = options._prComments;
135
+ }
136
+ else {
137
+ const issueComments = await getTracker().getComments(String(prNumber));
138
+ comments = issueComments.map((c) => c.body);
139
+ }
140
+ const attempts = countRetryAttempts(comments);
141
+ log.info(`Fix-review attempt ${attempts + 1} of ${maxFixAttempts}`);
142
+ log.stageEnd('check-retries');
143
+ // Helper to add a comment via tracker (uses default tracker in production)
144
+ const addComment = async (body) => {
145
+ if (trackerAvailable) {
146
+ await getTracker().addComment(String(prNumber), body);
147
+ }
148
+ };
149
+ if (attempts >= maxFixAttempts) {
150
+ log.info(`Fix-review retry limit reached (${maxFixAttempts}). Commenting and stopping.`);
151
+ auditLog.record({
152
+ actor: 'system',
153
+ action: 'evaluate',
154
+ resource: `pr#${prNumber}`,
155
+ decision: 'denied',
156
+ details: { reason: 'retry-limit-reached', attempts, max: maxFixAttempts },
157
+ });
158
+ const limitTpl = notifTemplates?.['fix-review-limit'];
159
+ const limitComment = limitTpl
160
+ ? renderTemplate(limitTpl, {
161
+ attempts: String(attempts),
162
+ max: String(maxFixAttempts),
163
+ })
164
+ : {
165
+ title: NOTIFICATION_TITLES.fixReviewRetryLimit,
166
+ body: `This PR has reached the maximum number of automated review-fix attempts (${maxFixAttempts}). Manual intervention is needed.`,
167
+ };
168
+ await addComment(`## ${limitComment.title}\n\n${limitComment.body}`);
169
+ return;
170
+ }
171
+ // Check for pipeline-level cycles AFTER retry counting
172
+ if (trackerAvailable) {
173
+ const cycleCheck = await checkAndHandleCycle({
174
+ issueOrPrId: String(prNumber),
175
+ stage: 'fix-review',
176
+ tracker: getTracker(),
177
+ detector: cycleDetector,
178
+ });
179
+ if (cycleCheck.cycleDetected) {
180
+ log.info('Pipeline cycle detected. Halting fix-review execution.');
181
+ auditLog.record({
182
+ actor: 'system',
183
+ action: 'evaluate',
184
+ resource: `pr#${prNumber}`,
185
+ decision: 'denied',
186
+ details: { reason: 'pipeline-cycle-detected' },
187
+ });
188
+ return;
189
+ }
190
+ }
191
+ // Generate cycle marker AFTER all guard conditions pass
192
+ const cycleMarker = cycleDetector.recordInvocation('fix-review');
193
+ // 3. Fetch review findings
194
+ log.stage('fetch-findings');
195
+ const reviewFindings = await fetchReviewFindings(prNumber, options._reviewFindings, options.secretStore);
196
+ log.stageEnd('fetch-findings');
197
+ // Check if review findings are actionable (not a generic "no findings" message)
198
+ const isActionable = reviewFindings &&
199
+ !reviewFindings.startsWith('No review findings') &&
200
+ reviewFindings.trim().length > 0;
201
+ if (!isActionable) {
202
+ log.info('No actionable review findings. Skipping fix-review execution.');
203
+ auditLog.record({
204
+ actor: 'system',
205
+ action: 'evaluate',
206
+ resource: `pr#${prNumber}`,
207
+ decision: 'allowed',
208
+ details: { reason: 'no-actionable-findings', reviewFindings },
209
+ });
210
+ return;
211
+ }
212
+ // 4. Determine branch and issue number
213
+ const { stdout: branchStdout } = await execFileAsync('git', ['branch', '--show-current'], {
214
+ cwd: workDir,
215
+ });
216
+ const currentBranch = sanitizeBranchName(branchStdout.trim());
217
+ const issueId = extractIssueId(currentBranch);
218
+ if (issueId === null) {
219
+ throw new Error(`Branch "${currentBranch}" does not match ai-sdlc/issue-<id> pattern`);
220
+ }
221
+ const issueNumber = issueIdToNumber(issueId);
222
+ // 5. Resolve autonomy level and constraints
223
+ const currentLevel = resolveAutonomyLevel(autonomyPolicy);
224
+ const resolved = resolveConstraints(agentRole.spec.constraints, currentLevel);
225
+ // 6. Fetch issue data (via tracker when available)
226
+ let issueTitle = `Issue ${issueId}`;
227
+ let issueBody = '';
228
+ if (trackerAvailable) {
229
+ const issueData = await getTracker().getIssue(issueId);
230
+ issueTitle = issueData.title;
231
+ issueBody = issueData.description ?? '';
232
+ }
233
+ // Store issue context in working memory
234
+ if (options.memory) {
235
+ options.memory.working.set('currentIssue', { prNumber, issueId, currentBranch });
236
+ }
237
+ // Query episodic memory for previous fix-review attempts
238
+ if (options.memory) {
239
+ const previousAttempts = options.memory.episodic.search('fix-review-execution');
240
+ if (previousAttempts.length > 0) {
241
+ log.info(`Found ${previousAttempts.length} previous fix-review episodes in memory`);
242
+ }
243
+ }
244
+ const meter = getMeter();
245
+ // Wrap agent+validation+push in try/catch for failure episodes
246
+ try {
247
+ // 7. Invoke agent with review findings (with sandbox + JIT credential lifecycle)
248
+ log.stage('agent');
249
+ const runner = options.runner ?? new ClaudeCodeRunner();
250
+ // Sandbox isolation around agent execution
251
+ let sandboxId;
252
+ let result;
253
+ try {
254
+ if (options.security) {
255
+ const timeoutMs = reviewStage?.timeout ? parseDuration(reviewStage.timeout) : undefined;
256
+ sandboxId = await options.security.sandbox.isolate(`issue-${issueId}`, defaultSandboxConstraints(workDir, timeoutMs));
257
+ }
258
+ // Issue JIT credentials before agent execution
259
+ const jitCred = options.security
260
+ ? await issueAgentCredentials(options.security, agentRole.metadata.name)
261
+ : undefined;
262
+ try {
263
+ result = await withSpan(SPAN_NAMES.AGENT_TASK, {
264
+ [ATTRIBUTE_KEYS.AGENT]: agentRole.metadata.name,
265
+ [ATTRIBUTE_KEYS.RESOURCE_NAME]: `pr#${prNumber}`,
266
+ }, async () => {
267
+ const r = await runner.run({
268
+ issueId,
269
+ issueNumber: issueNumber ?? undefined,
270
+ issueTitle,
271
+ issueBody,
272
+ workDir,
273
+ branch: currentBranch,
274
+ constraints: {
275
+ maxFilesPerChange: resolved.maxFiles,
276
+ requireTests: resolved.requireTests,
277
+ blockedPaths: resolved.blockedPaths,
278
+ },
279
+ reviewFindings,
280
+ });
281
+ if (!r.success) {
282
+ log.stageEnd('agent');
283
+ auditLog.record({
284
+ actor: 'system',
285
+ action: 'execute',
286
+ resource: `agent/${agentRole.metadata.name}`,
287
+ decision: 'denied',
288
+ details: { error: r.error },
289
+ });
290
+ meter.createCounter(METRIC_NAMES.TASK_FAILURE_TOTAL).add(1);
291
+ recordMetric(metricStore, METRIC_NAMES.TASK_FAILURE_TOTAL, 1);
292
+ // Evaluate demotion on agent failure
293
+ const agentMetrics = {
294
+ name: agentRole.metadata.name,
295
+ currentLevel: currentLevel.level,
296
+ totalTasksCompleted: 0,
297
+ metrics: {},
298
+ approvals: [],
299
+ };
300
+ const demotion = evaluateDemotion(autonomyPolicy, agentMetrics, 'failed-test');
301
+ log.info(`Demotion evaluation: ${demotion.demoted ? `demoted from ${demotion.fromLevel} to ${demotion.toLevel}` : 'no demotion'}`);
302
+ auditLog.record({
303
+ actor: 'system',
304
+ action: 'evaluate',
305
+ resource: `agent/${agentRole.metadata.name}`,
306
+ policy: 'demotion',
307
+ decision: demotion.demoted ? 'denied' : 'allowed',
308
+ details: {
309
+ trigger: demotion.trigger,
310
+ fromLevel: demotion.fromLevel,
311
+ toLevel: demotion.toLevel,
312
+ },
313
+ });
314
+ const agentFailTpl = notifTemplates?.['agent-failure'];
315
+ const errorDetail = r.error ?? 'Unknown error';
316
+ const agentFailComment = agentFailTpl
317
+ ? renderTemplate(agentFailTpl, { stageName: 'fix-review', details: errorDetail })
318
+ : { title: NOTIFICATION_TITLES.fixReviewAgentFailed, body: errorDetail };
319
+ // Use pre-created marker (one per execution, prevents double-counting)
320
+ await addComment(`## ${agentFailComment.title}\n\n${agentFailComment.body}\n\n${RETRY_MARKER}\n${cycleMarker}`);
321
+ throw new Error(`Fix-review agent failed on PR #${prNumber}: ${r.error}`);
322
+ }
323
+ log.stageEnd('agent');
324
+ auditLog.record({
325
+ actor: 'system',
326
+ action: 'execute',
327
+ resource: `agent/${agentRole.metadata.name}`,
328
+ decision: 'allowed',
329
+ details: { filesChanged: r.filesChanged.length },
330
+ });
331
+ meter.createCounter(METRIC_NAMES.TASK_SUCCESS_TOTAL).add(1);
332
+ recordMetric(metricStore, METRIC_NAMES.TASK_SUCCESS_TOTAL, 1);
333
+ return r;
334
+ });
335
+ }
336
+ finally {
337
+ // Revoke JIT credentials after agent execution (success or failure)
338
+ if (jitCred && options.security) {
339
+ await revokeAgentCredentials(options.security, jitCred.id);
340
+ }
341
+ }
342
+ }
343
+ finally {
344
+ // Destroy sandbox after agent execution
345
+ if (sandboxId && options.security) {
346
+ await options.security.sandbox.destroy(sandboxId);
347
+ }
348
+ }
349
+ // 8. ABAC authorization check (if write permissions are defined)
350
+ if (currentLevel.permissions.write.length > 0) {
351
+ authorizeFilesChanged(result.filesChanged, currentLevel.permissions, agentRole.spec.constraints, auditLog, agentRole.metadata.name);
352
+ }
353
+ // 9. Validate agent output against guardrails
354
+ await withSpan(SPAN_NAMES.PIPELINE_STAGE, {
355
+ [ATTRIBUTE_KEYS.STAGE]: 'validate-output',
356
+ }, async () => {
357
+ await validateAndAuditOutput({
358
+ filesChanged: result.filesChanged,
359
+ workDir,
360
+ constraints: {
361
+ maxFilesPerChange: resolved.maxFiles,
362
+ requireTests: resolved.requireTests,
363
+ blockedPaths: resolved.blockedPaths,
364
+ },
365
+ guardrails: { maxLinesPerPR: currentLevel.guardrails.maxLinesPerPR },
366
+ auditLog,
367
+ log,
368
+ onViolation: async (violationList) => {
369
+ await addComment(`## ${NOTIFICATION_TITLES.fixReviewGuardrailViolations}\n\n${violationList}\n\n${RETRY_MARKER}\n${cycleMarker}`);
370
+ },
371
+ });
372
+ });
373
+ // 10. Push to the same branch (review agents re-run automatically via pull_request.synchronize)
374
+ log.stage('push');
375
+ await execFileAsync('git', ['push', 'origin', currentBranch], { cwd: workDir });
376
+ log.stageEnd('push');
377
+ auditLog.record({
378
+ actor: 'system',
379
+ action: 'create',
380
+ resource: `push/${currentBranch}`,
381
+ decision: 'allowed',
382
+ details: { prNumber, attempt: attempts + 1 },
383
+ });
384
+ // 11. Comment on PR with success details
385
+ const successTpl = notifTemplates?.['fix-review-success'];
386
+ const successComment = successTpl
387
+ ? renderTemplate(successTpl, {
388
+ attempt: String(attempts + 1),
389
+ max: String(maxFixAttempts),
390
+ branch: currentBranch,
391
+ })
392
+ : {
393
+ title: NOTIFICATION_TITLES.fixReviewApplied,
394
+ body: `Attempt ${attempts + 1} of ${maxFixAttempts} — pushed review fixes to \`${currentBranch}\`.`,
395
+ };
396
+ await addComment([
397
+ `## ${successComment.title}`,
398
+ '',
399
+ successComment.body,
400
+ '',
401
+ '### Changes',
402
+ result.filesChanged.map((f) => `- \`${f}\``).join('\n'),
403
+ '',
404
+ RETRY_MARKER,
405
+ cycleMarker,
406
+ ].join('\n'));
407
+ // 12. Record episodic memory (success)
408
+ if (options.memory) {
409
+ options.memory.episodic.append({
410
+ key: 'fix-review-execution',
411
+ value: {
412
+ prNumber,
413
+ issueId,
414
+ filesChanged: result.filesChanged.length,
415
+ outcome: 'success',
416
+ },
417
+ metadata: { summary: `Fix-review for PR #${prNumber} (attempt ${attempts + 1})` },
418
+ });
419
+ options.memory.working.clear();
420
+ }
421
+ }
422
+ catch (err) {
423
+ // Record failure episode before rethrowing
424
+ if (options.memory) {
425
+ options.memory.episodic.append({
426
+ key: 'fix-review-execution',
427
+ value: {
428
+ prNumber,
429
+ issueId,
430
+ outcome: 'failure',
431
+ error: err instanceof Error ? err.message : String(err),
432
+ },
433
+ metadata: { summary: `Failed fix-review for PR #${prNumber}` },
434
+ });
435
+ options.memory.working.clear();
436
+ }
437
+ throw err;
438
+ }
439
+ log.summary();
440
+ }
441
+ //# sourceMappingURL=fix-review.js.map