@renseiai/agentfactory 0.8.13 → 0.8.14

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 (49) hide show
  1. package/dist/src/orchestrator/completion-contracts.d.ts +89 -0
  2. package/dist/src/orchestrator/completion-contracts.d.ts.map +1 -0
  3. package/dist/src/orchestrator/completion-contracts.js +228 -0
  4. package/dist/src/orchestrator/completion-contracts.test.d.ts +2 -0
  5. package/dist/src/orchestrator/completion-contracts.test.d.ts.map +1 -0
  6. package/dist/src/orchestrator/completion-contracts.test.js +195 -0
  7. package/dist/src/orchestrator/index.d.ts +4 -0
  8. package/dist/src/orchestrator/index.d.ts.map +1 -1
  9. package/dist/src/orchestrator/index.js +3 -0
  10. package/dist/src/orchestrator/orchestrator.d.ts +32 -0
  11. package/dist/src/orchestrator/orchestrator.d.ts.map +1 -1
  12. package/dist/src/orchestrator/orchestrator.js +157 -26
  13. package/dist/src/orchestrator/session-backstop.d.ts +67 -0
  14. package/dist/src/orchestrator/session-backstop.d.ts.map +1 -0
  15. package/dist/src/orchestrator/session-backstop.js +394 -0
  16. package/dist/src/orchestrator/session-backstop.test.d.ts +2 -0
  17. package/dist/src/orchestrator/session-backstop.test.d.ts.map +1 -0
  18. package/dist/src/orchestrator/session-backstop.test.js +245 -0
  19. package/dist/src/orchestrator/worktree-checks.test.d.ts +2 -0
  20. package/dist/src/orchestrator/worktree-checks.test.d.ts.map +1 -0
  21. package/dist/src/orchestrator/worktree-checks.test.js +159 -0
  22. package/dist/src/providers/a2a-provider.d.ts +4 -0
  23. package/dist/src/providers/a2a-provider.d.ts.map +1 -1
  24. package/dist/src/providers/a2a-provider.js +4 -0
  25. package/dist/src/providers/amp-provider.d.ts +4 -0
  26. package/dist/src/providers/amp-provider.d.ts.map +1 -1
  27. package/dist/src/providers/amp-provider.js +4 -0
  28. package/dist/src/providers/claude-provider.d.ts +4 -0
  29. package/dist/src/providers/claude-provider.d.ts.map +1 -1
  30. package/dist/src/providers/claude-provider.js +6 -0
  31. package/dist/src/providers/codex-provider.d.ts +4 -0
  32. package/dist/src/providers/codex-provider.d.ts.map +1 -1
  33. package/dist/src/providers/codex-provider.js +4 -0
  34. package/dist/src/providers/index.d.ts +1 -1
  35. package/dist/src/providers/index.d.ts.map +1 -1
  36. package/dist/src/providers/spring-ai-provider.d.ts +4 -0
  37. package/dist/src/providers/spring-ai-provider.d.ts.map +1 -1
  38. package/dist/src/providers/spring-ai-provider.js +4 -0
  39. package/dist/src/providers/types.d.ts +22 -0
  40. package/dist/src/providers/types.d.ts.map +1 -1
  41. package/dist/src/templates/types.d.ts +3 -0
  42. package/dist/src/templates/types.d.ts.map +1 -1
  43. package/dist/src/templates/types.js +2 -0
  44. package/dist/src/tools/index.d.ts +1 -0
  45. package/dist/src/tools/index.d.ts.map +1 -1
  46. package/dist/src/tools/registry.d.ts +6 -1
  47. package/dist/src/tools/registry.d.ts.map +1 -1
  48. package/dist/src/tools/registry.js +5 -1
  49. package/package.json +2 -2
@@ -0,0 +1,394 @@
1
+ /**
2
+ * Session Backstop
3
+ *
4
+ * Deterministic post-session recovery that runs after every agent session.
5
+ * Validates the session's outputs against the work type's completion contract,
6
+ * then takes backstop actions for any recoverable gaps.
7
+ *
8
+ * This is provider-agnostic — it operates on the worktree and GitHub API,
9
+ * not on the agent session. Every provider gets the same backstop.
10
+ *
11
+ * Architecture:
12
+ * 1. Collect session outputs (git state, PR detection, work result markers)
13
+ * 2. Validate against the completion contract
14
+ * 3. Run backstop actions for recoverable fields (push, create PR)
15
+ * 4. Return structured result for the orchestrator to act on
16
+ */
17
+ import { execSync } from 'node:child_process';
18
+ import { getCompletionContract, validateCompletion, formatMissingFields, } from './completion-contracts.js';
19
+ /**
20
+ * Collect structured outputs from the completed session.
21
+ * Inspects git state, agent process data, and tracked flags.
22
+ */
23
+ export function collectSessionOutputs(ctx) {
24
+ const { agent } = ctx;
25
+ const outputs = {
26
+ prUrl: agent.pullRequestUrl ?? undefined,
27
+ workResult: agent.workResult ?? 'unknown',
28
+ commentPosted: ctx.commentPosted,
29
+ issueUpdated: ctx.issueUpdated,
30
+ subIssuesCreated: ctx.subIssuesCreated,
31
+ };
32
+ // Inspect git state for code-producing work types
33
+ if (agent.worktreePath) {
34
+ try {
35
+ outputs.commitsPresent = hasCommitsAheadOfMain(agent.worktreePath);
36
+ outputs.branchPushed = isBranchPushed(agent.worktreePath);
37
+ }
38
+ catch {
39
+ // Git inspection failed — leave as undefined (unknown)
40
+ }
41
+ }
42
+ // If PR URL exists, check merged status
43
+ if (outputs.prUrl) {
44
+ try {
45
+ outputs.prMerged = isPrMerged(outputs.prUrl, agent.worktreePath);
46
+ }
47
+ catch {
48
+ // GitHub check failed — leave as undefined
49
+ }
50
+ }
51
+ return outputs;
52
+ }
53
+ /**
54
+ * Run the post-session backstop for an agent.
55
+ *
56
+ * 1. Collects session outputs
57
+ * 2. Validates against completion contract
58
+ * 3. Runs backstop actions for recoverable gaps
59
+ * 4. Returns structured result
60
+ *
61
+ * This function is safe to call for any work type — it returns a no-op
62
+ * result for work types without contracts.
63
+ */
64
+ export function runBackstop(ctx, options) {
65
+ const workType = ctx.agent.workType ?? 'development';
66
+ const contract = getCompletionContract(workType);
67
+ // No contract for this work type — nothing to validate
68
+ if (!contract) {
69
+ return {
70
+ contract: null,
71
+ outputs: collectSessionOutputs(ctx),
72
+ validation: null,
73
+ backstop: { actions: [], fullyRecovered: true, remainingGaps: [] },
74
+ diagnosticMessage: null,
75
+ };
76
+ }
77
+ // Collect and validate
78
+ const outputs = collectSessionOutputs(ctx);
79
+ const validation = validateCompletion(contract, outputs);
80
+ // Already satisfied — no backstop needed
81
+ if (validation.satisfied) {
82
+ return {
83
+ contract,
84
+ outputs,
85
+ validation,
86
+ backstop: { actions: [], fullyRecovered: true, remainingGaps: [] },
87
+ diagnosticMessage: null,
88
+ };
89
+ }
90
+ // Run backstop actions for recoverable fields
91
+ const actions = [];
92
+ for (const fieldType of validation.backstopRecoverable) {
93
+ switch (fieldType) {
94
+ case 'branch_pushed': {
95
+ if (options?.dryRun) {
96
+ actions.push({ field: 'branch_pushed', action: 'would push branch', success: false, detail: 'dry-run' });
97
+ break;
98
+ }
99
+ const pushResult = backstopPushBranch(ctx.agent.worktreePath);
100
+ actions.push(pushResult);
101
+ if (pushResult.success) {
102
+ outputs.branchPushed = true;
103
+ }
104
+ break;
105
+ }
106
+ case 'pr_url': {
107
+ // Can only create PR if branch is pushed (either already or by backstop above)
108
+ if (!outputs.branchPushed) {
109
+ actions.push({
110
+ field: 'pr_url',
111
+ action: 'skipped PR creation — branch not pushed',
112
+ success: false,
113
+ detail: 'Branch must be pushed before PR can be created',
114
+ });
115
+ break;
116
+ }
117
+ if (options?.dryRun) {
118
+ actions.push({ field: 'pr_url', action: 'would create PR', success: false, detail: 'dry-run' });
119
+ break;
120
+ }
121
+ const prResult = backstopCreatePR(ctx.agent, options);
122
+ actions.push(prResult);
123
+ if (prResult.success && prResult.detail) {
124
+ outputs.prUrl = prResult.detail;
125
+ ctx.agent.pullRequestUrl = prResult.detail;
126
+ }
127
+ break;
128
+ }
129
+ }
130
+ }
131
+ // Re-validate after backstop
132
+ const postBackstopValidation = validateCompletion(contract, outputs);
133
+ const remainingGaps = postBackstopValidation.missingFields;
134
+ const fullyRecovered = postBackstopValidation.satisfied;
135
+ const backstopResult = {
136
+ actions,
137
+ fullyRecovered,
138
+ remainingGaps,
139
+ };
140
+ // Build diagnostic message for unrecoverable gaps
141
+ const diagnosticMessage = fullyRecovered
142
+ ? null
143
+ : formatMissingFields(contract, postBackstopValidation);
144
+ return {
145
+ contract,
146
+ outputs,
147
+ validation,
148
+ backstop: backstopResult,
149
+ diagnosticMessage,
150
+ };
151
+ }
152
+ // ---------------------------------------------------------------------------
153
+ // Git inspection helpers
154
+ // ---------------------------------------------------------------------------
155
+ function hasCommitsAheadOfMain(worktreePath) {
156
+ try {
157
+ const currentBranch = execSync('git branch --show-current', {
158
+ cwd: worktreePath,
159
+ encoding: 'utf-8',
160
+ timeout: 10000,
161
+ }).trim();
162
+ if (currentBranch === 'main' || currentBranch === 'master' || !currentBranch) {
163
+ return false;
164
+ }
165
+ const aheadOutput = execSync('git rev-list --count main..HEAD', {
166
+ cwd: worktreePath,
167
+ encoding: 'utf-8',
168
+ timeout: 10000,
169
+ }).trim();
170
+ return parseInt(aheadOutput, 10) > 0;
171
+ }
172
+ catch {
173
+ return false;
174
+ }
175
+ }
176
+ function isBranchPushed(worktreePath) {
177
+ try {
178
+ const currentBranch = execSync('git branch --show-current', {
179
+ cwd: worktreePath,
180
+ encoding: 'utf-8',
181
+ timeout: 10000,
182
+ }).trim();
183
+ if (!currentBranch || currentBranch === 'main' || currentBranch === 'master') {
184
+ return true; // main is always "pushed"
185
+ }
186
+ const remoteRef = execSync(`git ls-remote --heads origin ${currentBranch}`, {
187
+ cwd: worktreePath,
188
+ encoding: 'utf-8',
189
+ timeout: 15000,
190
+ }).trim();
191
+ if (remoteRef.length === 0) {
192
+ return false; // remote branch doesn't exist
193
+ }
194
+ // Check if local is ahead of remote (unpushed commits)
195
+ try {
196
+ execSync('git rev-parse --abbrev-ref @{u}', {
197
+ cwd: worktreePath,
198
+ encoding: 'utf-8',
199
+ timeout: 10000,
200
+ });
201
+ const unpushed = execSync('git rev-list --count @{u}..HEAD', {
202
+ cwd: worktreePath,
203
+ encoding: 'utf-8',
204
+ timeout: 10000,
205
+ }).trim();
206
+ return parseInt(unpushed, 10) === 0;
207
+ }
208
+ catch {
209
+ // No tracking branch but remote exists — close enough
210
+ return true;
211
+ }
212
+ }
213
+ catch {
214
+ return false;
215
+ }
216
+ }
217
+ function isPrMerged(prUrl, worktreePath) {
218
+ try {
219
+ const prMatch = prUrl.match(/\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
220
+ if (!prMatch)
221
+ return false;
222
+ const [, owner, repo, prNum] = prMatch;
223
+ const json = execSync(`gh pr view ${prNum} --repo ${owner}/${repo} --json state --jq '.state'`, {
224
+ cwd: worktreePath ?? process.cwd(),
225
+ encoding: 'utf-8',
226
+ timeout: 15000,
227
+ }).trim();
228
+ return json === 'MERGED';
229
+ }
230
+ catch {
231
+ return false;
232
+ }
233
+ }
234
+ // ---------------------------------------------------------------------------
235
+ // Backstop actions
236
+ // ---------------------------------------------------------------------------
237
+ function backstopPushBranch(worktreePath) {
238
+ try {
239
+ const currentBranch = execSync('git branch --show-current', {
240
+ cwd: worktreePath,
241
+ encoding: 'utf-8',
242
+ timeout: 10000,
243
+ }).trim();
244
+ if (!currentBranch || currentBranch === 'main' || currentBranch === 'master') {
245
+ return {
246
+ field: 'branch_pushed',
247
+ action: 'skipped — on main/master branch',
248
+ success: false,
249
+ detail: 'Cannot push from main branch',
250
+ };
251
+ }
252
+ execSync(`git push -u origin ${currentBranch}`, {
253
+ cwd: worktreePath,
254
+ encoding: 'utf-8',
255
+ timeout: 60000,
256
+ });
257
+ return {
258
+ field: 'branch_pushed',
259
+ action: 'auto-pushed branch to remote',
260
+ success: true,
261
+ detail: currentBranch,
262
+ };
263
+ }
264
+ catch (error) {
265
+ return {
266
+ field: 'branch_pushed',
267
+ action: 'failed to push branch',
268
+ success: false,
269
+ detail: error instanceof Error ? error.message : String(error),
270
+ };
271
+ }
272
+ }
273
+ function backstopCreatePR(agent, options) {
274
+ const worktreePath = agent.worktreePath;
275
+ if (!worktreePath) {
276
+ return {
277
+ field: 'pr_url',
278
+ action: 'skipped — no worktree path',
279
+ success: false,
280
+ };
281
+ }
282
+ try {
283
+ const currentBranch = execSync('git branch --show-current', {
284
+ cwd: worktreePath,
285
+ encoding: 'utf-8',
286
+ timeout: 10000,
287
+ }).trim();
288
+ // Check if PR already exists for this branch (might have been missed during output parsing)
289
+ try {
290
+ const existingPr = execSync(`gh pr list --head "${currentBranch}" --json url --limit 1`, {
291
+ cwd: worktreePath,
292
+ encoding: 'utf-8',
293
+ timeout: 15000,
294
+ }).trim();
295
+ const prs = JSON.parse(existingPr);
296
+ if (prs.length > 0 && prs[0].url) {
297
+ return {
298
+ field: 'pr_url',
299
+ action: 'found existing PR (missed during session)',
300
+ success: true,
301
+ detail: prs[0].url,
302
+ };
303
+ }
304
+ }
305
+ catch {
306
+ // PR check failed — proceed with creation
307
+ }
308
+ // Build PR title and body
309
+ const identifier = agent.identifier;
310
+ const title = options?.prTitleTemplate
311
+ ? options.prTitleTemplate.replace('{identifier}', identifier)
312
+ : `feat: ${identifier} (auto-recovered by backstop)`;
313
+ const body = options?.prBodyTemplate
314
+ ? options.prBodyTemplate.replace('{identifier}', identifier)
315
+ : [
316
+ `## Summary`,
317
+ ``,
318
+ `Auto-created by the session backstop for ${identifier}.`,
319
+ `The agent completed work but did not create a PR.`,
320
+ ``,
321
+ `> This PR was created automatically by the orchestrator backstop to prevent work loss.`,
322
+ `> Please review carefully before merging.`,
323
+ ].join('\n');
324
+ const prOutput = execSync(`gh pr create --title "${title.replace(/"/g, '\\"')}" --body "${body.replace(/"/g, '\\"')}"`, {
325
+ cwd: worktreePath,
326
+ encoding: 'utf-8',
327
+ timeout: 30000,
328
+ }).trim();
329
+ // gh pr create outputs the PR URL
330
+ const prUrlMatch = prOutput.match(/https:\/\/github\.com\/[^\s]+\/pull\/\d+/);
331
+ const prUrl = prUrlMatch ? prUrlMatch[0] : prOutput;
332
+ return {
333
+ field: 'pr_url',
334
+ action: 'auto-created PR via backstop',
335
+ success: true,
336
+ detail: prUrl,
337
+ };
338
+ }
339
+ catch (error) {
340
+ return {
341
+ field: 'pr_url',
342
+ action: 'failed to create PR',
343
+ success: false,
344
+ detail: error instanceof Error ? error.message : String(error),
345
+ };
346
+ }
347
+ }
348
+ // ---------------------------------------------------------------------------
349
+ // Diagnostic formatting
350
+ // ---------------------------------------------------------------------------
351
+ /**
352
+ * Format a backstop result into a diagnostic comment for the issue tracker.
353
+ */
354
+ export function formatBackstopComment(result) {
355
+ // Nothing to report if contract was satisfied or no contract exists
356
+ if (!result.contract || result.backstop.fullyRecovered) {
357
+ // If backstop took actions to recover, report those
358
+ if (result.backstop.actions.length > 0) {
359
+ const lines = [
360
+ `**Session backstop recovered missing outputs for ${result.contract?.workType ?? 'unknown'} work.**`,
361
+ '',
362
+ 'Actions taken:',
363
+ ...result.backstop.actions.map(a => `- ${a.field}: ${a.action}${a.success ? ' ✓' : ' ✗'}${a.detail ? ` (${a.detail})` : ''}`),
364
+ ];
365
+ return lines.join('\n');
366
+ }
367
+ return null;
368
+ }
369
+ // Contract not satisfied — build diagnostic
370
+ const lines = [
371
+ `⚠️ **Session completion check failed for ${result.contract.workType} work.**`,
372
+ '',
373
+ ];
374
+ if (result.backstop.actions.length > 0) {
375
+ lines.push('**Backstop actions attempted:**');
376
+ for (const action of result.backstop.actions) {
377
+ lines.push(`- ${action.field}: ${action.action}${action.success ? ' ✓' : ' ✗'}`);
378
+ if (action.detail && !action.success) {
379
+ lines.push(` > ${action.detail}`);
380
+ }
381
+ }
382
+ lines.push('');
383
+ }
384
+ if (result.backstop.remainingGaps.length > 0) {
385
+ lines.push('**Still missing (requires manual action or re-trigger):**');
386
+ for (const gap of result.backstop.remainingGaps) {
387
+ const field = result.contract.required.find(f => f.type === gap);
388
+ lines.push(`- ${field?.label ?? gap}`);
389
+ }
390
+ lines.push('');
391
+ lines.push('**Issue status was NOT updated automatically** to prevent incomplete work from advancing.');
392
+ }
393
+ return lines.join('\n');
394
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=session-backstop.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-backstop.test.d.ts","sourceRoot":"","sources":["../../../src/orchestrator/session-backstop.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,245 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { collectSessionOutputs, runBackstop, formatBackstopComment } from './session-backstop.js';
3
+ // Mock execSync to avoid actual git/gh commands
4
+ vi.mock('node:child_process', () => ({
5
+ execSync: vi.fn(),
6
+ }));
7
+ import { execSync } from 'node:child_process';
8
+ const mockExecSync = vi.mocked(execSync);
9
+ function createMockAgent(overrides) {
10
+ return {
11
+ issueId: 'issue-123',
12
+ identifier: 'SUP-123',
13
+ status: 'completed',
14
+ startedAt: new Date(),
15
+ completedAt: new Date(),
16
+ pid: 1234,
17
+ lastActivityAt: new Date(),
18
+ ...overrides,
19
+ };
20
+ }
21
+ function createSessionContext(overrides) {
22
+ return {
23
+ agent: createMockAgent(),
24
+ commentPosted: false,
25
+ issueUpdated: false,
26
+ subIssuesCreated: false,
27
+ ...overrides,
28
+ };
29
+ }
30
+ beforeEach(() => {
31
+ mockExecSync.mockReset();
32
+ });
33
+ describe('collectSessionOutputs', () => {
34
+ it('collects basic outputs from agent process', () => {
35
+ const ctx = createSessionContext({
36
+ agent: createMockAgent({
37
+ pullRequestUrl: 'https://github.com/org/repo/pull/42',
38
+ workResult: 'passed',
39
+ }),
40
+ commentPosted: true,
41
+ issueUpdated: true,
42
+ });
43
+ const outputs = collectSessionOutputs(ctx);
44
+ expect(outputs.prUrl).toBe('https://github.com/org/repo/pull/42');
45
+ expect(outputs.workResult).toBe('passed');
46
+ expect(outputs.commentPosted).toBe(true);
47
+ expect(outputs.issueUpdated).toBe(true);
48
+ });
49
+ it('inspects git state for worktree-based agents', () => {
50
+ // Mock git commands for commit and branch checks
51
+ mockExecSync
52
+ .mockReturnValueOnce('feat/sup-123\n') // git branch --show-current
53
+ .mockReturnValueOnce('3\n') // git rev-list --count main..HEAD
54
+ .mockReturnValueOnce('feat/sup-123\n') // git branch --show-current (isBranchPushed)
55
+ .mockReturnValueOnce('abc123 refs/heads/feat/sup-123\n') // git ls-remote
56
+ .mockReturnValueOnce('origin/feat/sup-123\n') // git rev-parse @{u}
57
+ .mockReturnValueOnce('0\n'); // git rev-list --count @{u}..HEAD
58
+ const ctx = createSessionContext({
59
+ agent: createMockAgent({ worktreePath: '/tmp/worktree' }),
60
+ });
61
+ const outputs = collectSessionOutputs(ctx);
62
+ expect(outputs.commitsPresent).toBe(true);
63
+ expect(outputs.branchPushed).toBe(true);
64
+ });
65
+ it('defaults to unknown work result when not set', () => {
66
+ const ctx = createSessionContext();
67
+ const outputs = collectSessionOutputs(ctx);
68
+ expect(outputs.workResult).toBe('unknown');
69
+ });
70
+ });
71
+ describe('runBackstop', () => {
72
+ it('returns no-op for work types without contracts', () => {
73
+ // Use a work type cast that has no contract
74
+ const ctx = createSessionContext({
75
+ agent: createMockAgent({ workType: 'unknown-type' }),
76
+ });
77
+ const result = runBackstop(ctx);
78
+ expect(result.contract).toBeNull();
79
+ expect(result.backstop.fullyRecovered).toBe(true);
80
+ expect(result.backstop.actions).toHaveLength(0);
81
+ });
82
+ it('returns satisfied for development with all outputs present', () => {
83
+ // Mock git: commits ahead of main, branch pushed
84
+ mockExecSync
85
+ .mockReturnValueOnce('feat/sup-123\n') // branch
86
+ .mockReturnValueOnce('2\n') // rev-list count
87
+ .mockReturnValueOnce('feat/sup-123\n') // branch (isBranchPushed)
88
+ .mockReturnValueOnce('abc refs/heads/feat/sup-123\n') // ls-remote
89
+ .mockReturnValueOnce('origin/feat/sup-123\n') // @{u}
90
+ .mockReturnValueOnce('0\n'); // unpushed count
91
+ const ctx = createSessionContext({
92
+ agent: createMockAgent({
93
+ workType: 'development',
94
+ worktreePath: '/tmp/worktree',
95
+ pullRequestUrl: 'https://github.com/org/repo/pull/42',
96
+ }),
97
+ });
98
+ const result = runBackstop(ctx);
99
+ expect(result.validation.satisfied).toBe(true);
100
+ expect(result.backstop.actions).toHaveLength(0);
101
+ });
102
+ it('auto-pushes branch when commits exist but branch not pushed', () => {
103
+ // collectSessionOutputs calls:
104
+ // hasCommitsAheadOfMain: git branch, git rev-list
105
+ // isBranchPushed: git branch, git ls-remote (empty = not pushed)
106
+ // backstopPushBranch calls:
107
+ // git branch, git push
108
+ // backstopCreatePR calls:
109
+ // git branch, gh pr list, gh pr create
110
+ // (re-validation uses mutated outputs object, no git calls)
111
+ mockExecSync
112
+ // --- collectSessionOutputs ---
113
+ .mockReturnValueOnce('feat/sup-123\n') // hasCommits: git branch
114
+ .mockReturnValueOnce('2\n') // hasCommits: git rev-list
115
+ .mockReturnValueOnce('feat/sup-123\n') // isBranchPushed: git branch
116
+ .mockReturnValueOnce('') // isBranchPushed: ls-remote (empty = not pushed)
117
+ // --- backstopPushBranch ---
118
+ .mockReturnValueOnce('feat/sup-123\n') // git branch
119
+ .mockReturnValueOnce('') // git push -u origin
120
+ // --- backstopCreatePR ---
121
+ .mockReturnValueOnce('feat/sup-123\n') // git branch
122
+ .mockReturnValueOnce('[]') // gh pr list (empty)
123
+ .mockReturnValueOnce('https://github.com/org/repo/pull/99\n'); // gh pr create
124
+ const ctx = createSessionContext({
125
+ agent: createMockAgent({
126
+ workType: 'development',
127
+ worktreePath: '/tmp/worktree',
128
+ // No PR URL — agent didn't create one
129
+ }),
130
+ });
131
+ const result = runBackstop(ctx);
132
+ const pushAction = result.backstop.actions.find(a => a.field === 'branch_pushed');
133
+ expect(pushAction).toBeDefined();
134
+ expect(pushAction.success).toBe(true);
135
+ expect(pushAction.action).toContain('auto-pushed');
136
+ });
137
+ it('skips backstop actions in dry-run mode', () => {
138
+ // Mock git: commits present, branch not pushed
139
+ mockExecSync
140
+ .mockReturnValueOnce('feat/sup-123\n') // branch
141
+ .mockReturnValueOnce('2\n') // count
142
+ .mockReturnValueOnce('feat/sup-123\n') // branch (isBranchPushed)
143
+ .mockReturnValueOnce('\n'); // ls-remote empty
144
+ const ctx = createSessionContext({
145
+ agent: createMockAgent({
146
+ workType: 'development',
147
+ worktreePath: '/tmp/worktree',
148
+ pullRequestUrl: 'https://github.com/org/repo/pull/42',
149
+ }),
150
+ });
151
+ const result = runBackstop(ctx, { dryRun: true });
152
+ const pushAction = result.backstop.actions.find(a => a.field === 'branch_pushed');
153
+ expect(pushAction).toBeDefined();
154
+ expect(pushAction.success).toBe(false);
155
+ expect(pushAction.detail).toBe('dry-run');
156
+ });
157
+ it('reports satisfied for QA with pass result and comment', () => {
158
+ const ctx = createSessionContext({
159
+ agent: createMockAgent({ workType: 'qa', workResult: 'passed' }),
160
+ commentPosted: true,
161
+ });
162
+ const result = runBackstop(ctx);
163
+ expect(result.validation.satisfied).toBe(true);
164
+ });
165
+ it('reports unsatisfied for QA with unknown work result', () => {
166
+ const ctx = createSessionContext({
167
+ agent: createMockAgent({ workType: 'qa' }),
168
+ commentPosted: true,
169
+ });
170
+ const result = runBackstop(ctx);
171
+ expect(result.validation.satisfied).toBe(false);
172
+ expect(result.backstop.remainingGaps).toContain('work_result');
173
+ });
174
+ it('reports satisfied for refinement with comment posted', () => {
175
+ const ctx = createSessionContext({
176
+ agent: createMockAgent({ workType: 'refinement' }),
177
+ commentPosted: true,
178
+ });
179
+ const result = runBackstop(ctx);
180
+ expect(result.validation.satisfied).toBe(true);
181
+ });
182
+ it('reports unsatisfied for refinement without comment', () => {
183
+ const ctx = createSessionContext({
184
+ agent: createMockAgent({ workType: 'refinement' }),
185
+ commentPosted: false,
186
+ });
187
+ const result = runBackstop(ctx);
188
+ expect(result.validation.satisfied).toBe(false);
189
+ expect(result.backstop.remainingGaps).toContain('comment_posted');
190
+ });
191
+ it('reports satisfied for backlog-creation with sub-issues', () => {
192
+ const ctx = createSessionContext({
193
+ agent: createMockAgent({ workType: 'backlog-creation' }),
194
+ subIssuesCreated: true,
195
+ });
196
+ const result = runBackstop(ctx);
197
+ expect(result.validation.satisfied).toBe(true);
198
+ });
199
+ });
200
+ describe('formatBackstopComment', () => {
201
+ it('returns null when contract satisfied with no actions', () => {
202
+ const ctx = createSessionContext({
203
+ agent: createMockAgent({ workType: 'qa', workResult: 'passed' }),
204
+ commentPosted: true,
205
+ });
206
+ const result = runBackstop(ctx);
207
+ const comment = formatBackstopComment(result);
208
+ expect(comment).toBeNull();
209
+ });
210
+ it('returns recovery summary when backstop took actions', () => {
211
+ // Mock: commits present, branch not pushed, push succeeds
212
+ mockExecSync
213
+ .mockReturnValueOnce('feat/sup-123\n')
214
+ .mockReturnValueOnce('2\n')
215
+ .mockReturnValueOnce('feat/sup-123\n')
216
+ .mockReturnValueOnce('\n') // not pushed
217
+ .mockReturnValueOnce('feat/sup-123\n') // backstop push branch
218
+ .mockReturnValueOnce('') // push succeeds
219
+ // Now PR creation attempt
220
+ .mockReturnValueOnce('feat/sup-123\n') // branch for PR
221
+ .mockReturnValueOnce('[]\n') // no existing PR
222
+ .mockReturnValueOnce('https://github.com/org/repo/pull/99\n'); // gh pr create
223
+ const ctx = createSessionContext({
224
+ agent: createMockAgent({
225
+ workType: 'development',
226
+ worktreePath: '/tmp/worktree',
227
+ }),
228
+ });
229
+ const result = runBackstop(ctx);
230
+ const comment = formatBackstopComment(result);
231
+ expect(comment).toBeDefined();
232
+ expect(comment).toContain('backstop');
233
+ });
234
+ it('includes remaining gaps when not fully recovered', () => {
235
+ const ctx = createSessionContext({
236
+ agent: createMockAgent({ workType: 'qa' }),
237
+ commentPosted: false,
238
+ });
239
+ const result = runBackstop(ctx);
240
+ const comment = formatBackstopComment(result);
241
+ expect(comment).toBeDefined();
242
+ expect(comment).toContain('Still missing');
243
+ expect(comment).toContain('NOT updated automatically');
244
+ });
245
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=worktree-checks.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worktree-checks.test.d.ts","sourceRoot":"","sources":["../../../src/orchestrator/worktree-checks.test.ts"],"names":[],"mappings":""}