@yemi33/minions 0.1.2197 → 0.1.2198

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.
@@ -0,0 +1,123 @@
1
+ // engine/pr-fix-target-store.js — SQL-backed remembered execution-target
2
+ // choices, keyed per repo scope.
3
+ //
4
+ // Plan: projectless-pr-actions, Phase 3, item P-ppa10009 — "Remember
5
+ // execution-target choice per repo (optional)". One row per canonical PR scope
6
+ // (github:owner/repo / ado:org/proj/repo) in the pr_fix_target_prefs table
7
+ // (migration 014). When a remembered choice exists, a repeat fix on that repo
8
+ // skips the execution-target prompt and routes straight to the executor
9
+ // (engine/pr-fix-target.js#planPrFix consults getRemembered). Absence of a row
10
+ // still prompts — the safe "never clone silently" default.
11
+ //
12
+ // Mirrors the shape of engine/steering-store.js:
13
+ // - routes every read/write through getDb() (no JSON sidecar)
14
+ // - emits emitStateEvent('pr-fix-target-prefs', {repoScope, target}) on every
15
+ // write/clear so the dashboard's MAX(events.id) cache check fires.
16
+ //
17
+ // Public API:
18
+ // remember(repoScope, target) -> { repoScope, target, updatedAt } (upsert)
19
+ // getRemembered(repoScope) -> target string | null
20
+ // getRecord(repoScope) -> { repoScope, target, updatedAt } | null
21
+ // clear(repoScope) -> boolean (true if a row was removed)
22
+ // list() -> [{ repoScope, target, updatedAt }, ...]
23
+
24
+ function _normScope(repoScope) {
25
+ return typeof repoScope === 'string' ? repoScope.trim().toLowerCase() : '';
26
+ }
27
+
28
+ function _isValidTarget(target) {
29
+ // Validate against the canonical execution-target list. Lazy require avoids a
30
+ // load-time cycle (pr-fix-target.js requires this store at module top).
31
+ try {
32
+ const { isValidExecutionTarget } = require('./pr-fix-target');
33
+ return isValidExecutionTarget(target);
34
+ } catch {
35
+ // If pr-fix-target can't load (shouldn't happen), fall back to a minimal
36
+ // shape check so we still refuse obvious garbage.
37
+ return typeof target === 'string' && target.trim().length > 0;
38
+ }
39
+ }
40
+
41
+ function _rowToRecord(row) {
42
+ if (!row) return null;
43
+ return { repoScope: row.repo_scope, target: row.target, updatedAt: row.updated_at };
44
+ }
45
+
46
+ function _emitEvent(repoScope, target) {
47
+ try {
48
+ const { emitStateEvent } = require('./db-events');
49
+ emitStateEvent('pr-fix-target-prefs', { repoScope, target: target || null });
50
+ } catch { /* best-effort */ }
51
+ }
52
+
53
+ /**
54
+ * Remember (upsert) the execution-target `target` for `repoScope`. Validates the
55
+ * target against the canonical execution-target list and throws on an invalid
56
+ * scope/target so a bad value can never silently route a future clone.
57
+ */
58
+ function remember(repoScope, target) {
59
+ const scope = _normScope(repoScope);
60
+ if (!scope) throw new Error('pr-fix-target-store.remember: repoScope required');
61
+ const t = typeof target === 'string' ? target.trim().toLowerCase() : '';
62
+ if (!_isValidTarget(t)) {
63
+ throw new Error(`pr-fix-target-store.remember: invalid execution target ${JSON.stringify(target)}`);
64
+ }
65
+
66
+ const { getDb } = require('./db');
67
+ const db = getDb();
68
+ const now = Date.now();
69
+ db.prepare(`
70
+ INSERT INTO pr_fix_target_prefs (repo_scope, target, updated_at)
71
+ VALUES (?, ?, ?)
72
+ ON CONFLICT(repo_scope) DO UPDATE SET target = excluded.target, updated_at = excluded.updated_at
73
+ `).run(scope, t, now);
74
+
75
+ _emitEvent(scope, t);
76
+ return { repoScope: scope, target: t, updatedAt: now };
77
+ }
78
+
79
+ /** The remembered execution-target id for `repoScope`, or null when none. */
80
+ function getRemembered(repoScope) {
81
+ const rec = getRecord(repoScope);
82
+ return rec ? rec.target : null;
83
+ }
84
+
85
+ /** The full remembered record for `repoScope`, or null when none. */
86
+ function getRecord(repoScope) {
87
+ const scope = _normScope(repoScope);
88
+ if (!scope) return null;
89
+ let db;
90
+ try { const { getDb } = require('./db'); db = getDb(); }
91
+ catch { return null; }
92
+ const row = db.prepare('SELECT * FROM pr_fix_target_prefs WHERE repo_scope = ?').get(scope);
93
+ return _rowToRecord(row);
94
+ }
95
+
96
+ /** Forget the remembered choice for `repoScope`. Returns true if a row existed. */
97
+ function clear(repoScope) {
98
+ const scope = _normScope(repoScope);
99
+ if (!scope) return false;
100
+ const { getDb } = require('./db');
101
+ const db = getDb();
102
+ const info = db.prepare('DELETE FROM pr_fix_target_prefs WHERE repo_scope = ?').run(scope);
103
+ const removed = (info && info.changes) > 0;
104
+ if (removed) _emitEvent(scope, null);
105
+ return removed;
106
+ }
107
+
108
+ /** All remembered choices, newest-updated first. */
109
+ function list() {
110
+ let db;
111
+ try { const { getDb } = require('./db'); db = getDb(); }
112
+ catch { return []; }
113
+ const rows = db.prepare('SELECT * FROM pr_fix_target_prefs ORDER BY updated_at DESC, repo_scope ASC').all();
114
+ return rows.map(_rowToRecord);
115
+ }
116
+
117
+ module.exports = {
118
+ remember,
119
+ getRemembered,
120
+ getRecord,
121
+ clear,
122
+ list,
123
+ };
@@ -0,0 +1,418 @@
1
+ /**
2
+ * engine/pr-fix-target.js — Execution-target choice surface for projectless PR
3
+ * fixes ("never clone silently").
4
+ *
5
+ * Plan: projectless-pr-actions, Phase 2, item P-ppa10005. This module is the
6
+ * DECISION / ROUTING surface + the state machine that PAUSES a one-off fix on an
7
+ * unconfigured repo until the user explicitly picks an execution target. It does
8
+ * NOT execute anything — the executors land in:
9
+ * - temp-clone → P-ppa10006 (ephemeral clone, fix, push, discard) [DEFAULT]
10
+ * — IMPLEMENTED in engine/pr-temp-clone.js
11
+ * - clone-keep → P-ppa10007 (auto-clone + persistent project registration)
12
+ * — IMPLEMENTED in engine/pr-clone-keep.js
13
+ * - remote-patch → P-ppa10008 (GitHub Contents API, trivial single-file, NO test run)
14
+ * — IMPLEMENTED in engine/pr-remote-patch.js
15
+ * - devbox → P-ppa10011 (ephemeral clone+fix+push ON A DEVBOX, zero local
16
+ * footprint) — IMPLEMENTED in engine/pr-devbox.js. Never default,
17
+ * never auto-selected; explicit consent only.
18
+ *
19
+ * Load-bearing invariant (read before extending): "never clone silently". A fix
20
+ * on a repo that is NOT already a configured project MUST surface the
21
+ * clone-vs-temp-vs-remote-patch choice and pause — no clone, no worktree, no
22
+ * spawn happens here. This module therefore imports ZERO clone/worktree/spawn
23
+ * primitive on purpose; a test source-inspection guard enforces that. If the
24
+ * repo IS already a configured project, the choice is skipped and the plan
25
+ * routes straight to the existing project fix path.
26
+ *
27
+ * State machine (`planPrFix` → `resolvePrFixTarget`):
28
+ * project-ready — repo matches a configured project; skip the prompt,
29
+ * use the existing project worktree/fix path.
30
+ * awaiting-target-choice — repo is unconfigured; the user must choose a target
31
+ * before any clone. Carries CC prompt + dashboard modal.
32
+ * routed — a valid target was chosen; carries the routing intent
33
+ * to the matching executor (implemented:false until the
34
+ * P-ppa10006/7/8 executors land).
35
+ */
36
+
37
+ const shared = require('./shared');
38
+ const prResolve = require('./pr-resolve');
39
+ const { PrActionError } = require('./pr-action');
40
+ const prFixTargetStore = require('./pr-fix-target-store');
41
+
42
+ // ── Execution targets ─────────────────────────────────────────────────────────
43
+
44
+ // The three explicit execution targets a user can pick when a fix is requested
45
+ // on an unconfigured repo. Order is the presentation order (CC + dashboard).
46
+ // `validates` flags whether the target runs the build/tests before pushing —
47
+ // remote-patch deliberately does NOT (it is labeled "no validation").
48
+ const EXECUTION_TARGETS = Object.freeze([
49
+ Object.freeze({
50
+ id: 'clone-keep',
51
+ label: 'Clone & keep',
52
+ description: 'Auto-clone the repo and register it as a persistent project so ongoing tracking/auto-fix works. Disk-resident.',
53
+ isDefault: false,
54
+ executorItem: 'P-ppa10007',
55
+ executorModule: 'pr-clone-keep',
56
+ implemented: true,
57
+ trivialOnly: false,
58
+ validates: true,
59
+ }),
60
+ Object.freeze({
61
+ id: 'temp-clone',
62
+ label: 'Temp clone',
63
+ description: 'Ephemeral clone on D:, fix + run tests, push to the PR branch, then discard. Nothing persists.',
64
+ isDefault: true,
65
+ executorItem: 'P-ppa10006',
66
+ executorModule: 'pr-temp-clone',
67
+ implemented: true,
68
+ trivialOnly: false,
69
+ validates: true,
70
+ }),
71
+ Object.freeze({
72
+ id: 'remote-patch',
73
+ label: 'Remote patch (no validation)',
74
+ description: 'Trivial single-file edits only via the GitHub Contents API — no clone and NO test run. Never use for non-trivial changes.',
75
+ isDefault: false,
76
+ executorItem: 'P-ppa10008',
77
+ executorModule: 'pr-remote-patch',
78
+ implemented: true,
79
+ trivialOnly: true,
80
+ validates: false,
81
+ }),
82
+ Object.freeze({
83
+ id: 'devbox',
84
+ label: 'DevBox (never touches your machine)',
85
+ description: 'Clone ephemerally ON A DEVBOX, run the fix + tests there, push from the DevBox, then discard. Zero local footprint — the answer for high PR volume. Never auto-selected.',
86
+ isDefault: false,
87
+ executorItem: 'P-ppa10011',
88
+ executorModule: 'pr-devbox',
89
+ implemented: true,
90
+ trivialOnly: false,
91
+ validates: true,
92
+ }),
93
+ ]);
94
+
95
+ const EXECUTION_TARGET_IDS = Object.freeze(EXECUTION_TARGETS.map((t) => t.id));
96
+
97
+ // Default execution target when the user does not specify one. Temp clone is the
98
+ // safest "do the work, leave no trace" option, so it is the plan's default.
99
+ const DEFAULT_EXECUTION_TARGET = (EXECUTION_TARGETS.find((t) => t.isDefault) || EXECUTION_TARGETS[1]).id;
100
+
101
+ function isValidExecutionTarget(target) {
102
+ return typeof target === 'string' && EXECUTION_TARGET_IDS.includes(target);
103
+ }
104
+
105
+ function _targetById(id) {
106
+ return EXECUTION_TARGETS.find((t) => t.id === id) || null;
107
+ }
108
+
109
+ /**
110
+ * Build the `{ status:'routed', … }` routing intent for a planned fix + a chosen
111
+ * target. Shared by `resolvePrFixTarget` (explicit choice) and `planPrFix` (when
112
+ * a remembered per-repo choice short-circuits the prompt). Still does NOT clone
113
+ * or spawn — selection only routes.
114
+ */
115
+ function _routedRecord(plan, target, extra = {}) {
116
+ const desc = _targetById(target);
117
+ return {
118
+ status: 'routed',
119
+ ref: plan.ref,
120
+ prId: plan.prId,
121
+ prUrl: plan.prUrl,
122
+ target,
123
+ executor: {
124
+ target,
125
+ item: desc.executorItem,
126
+ module: desc.executorModule || null,
127
+ validates: desc.validates,
128
+ trivialOnly: desc.trivialOnly,
129
+ // temp-clone's executor landed in P-ppa10006 (engine/pr-temp-clone.js),
130
+ // clone-keep's in P-ppa10007 (engine/pr-clone-keep.js), and remote-patch's
131
+ // in P-ppa10008 (engine/pr-remote-patch.js). `implemented` reflects whether
132
+ // a real executor backs the choice.
133
+ implemented: !!desc.implemented,
134
+ },
135
+ ...extra,
136
+ };
137
+ }
138
+
139
+ // ── PR ref resolution ─────────────────────────────────────────────────────────
140
+
141
+ /**
142
+ * Resolve `{ url }` (raw PR URL / canonical id) OR an already-normalized ref into
143
+ * a normalized PR ref. Throws `PrActionError` (400) on missing/unrecognized input
144
+ * so the dashboard handler reflects it as a 400 (matching the read-only path).
145
+ */
146
+ function _resolveRef(input) {
147
+ if (input && typeof input === 'object' && input.host && input.slug && input.number) {
148
+ return input; // already normalized
149
+ }
150
+ if (input && typeof input === 'object' && input.ref && input.ref.host) {
151
+ return input.ref; // a plan/handle carrying a ref
152
+ }
153
+ const rawUrl = input && typeof input === 'object'
154
+ ? (typeof input.url === 'string' ? input.url.trim() : '')
155
+ : (typeof input === 'string' ? input.trim() : '');
156
+ if (!rawUrl) throw new PrActionError('url required');
157
+ const ref = prResolve.normalizePrRef(rawUrl);
158
+ if (!ref) throw new PrActionError(`unrecognized PR reference: ${JSON.stringify(rawUrl.slice(0, 120))}`);
159
+ return ref;
160
+ }
161
+
162
+ // ── Configured-project detection ──────────────────────────────────────────────
163
+
164
+ /** Canonical `host:slug` scope for a normalized ref (lowercased), or ''. */
165
+ function _refScope(ref) {
166
+ if (!ref) return '';
167
+ if (ref.scope) return String(ref.scope).toLowerCase();
168
+ if (ref.host && ref.slug) return `${ref.host}:${ref.slug}`.toLowerCase();
169
+ return '';
170
+ }
171
+
172
+ /**
173
+ * Find the configured project whose repo matches the PR `ref`, or null when the
174
+ * repo is NOT already a configured project. Match is by canonical PR scope
175
+ * (`github:owner/repo` / `ado:org/proj/repo`); for ADO it also accepts the
176
+ * repositoryId (GUID) form via `shared.isAdoPrScopeCompatible`.
177
+ *
178
+ * Importantly, a project with no derivable scope NEVER matches — we must not
179
+ * treat "scope unknown" as "compatible", or an unconfigured repo could slip past
180
+ * the choice surface and clone silently.
181
+ */
182
+ function findConfiguredProjectForRef(ref, config) {
183
+ const refScope = _refScope(ref);
184
+ if (!refScope) return null;
185
+ const projects = shared.getProjects(config) || [];
186
+ for (const project of projects) {
187
+ const projectScope = shared.getProjectPrScope(project);
188
+ if (projectScope && projectScope.toLowerCase() === refScope) return project;
189
+ if (ref && ref.host === 'ado' && shared.isAdoPrScopeCompatible(refScope, project)) return project;
190
+ }
191
+ return null;
192
+ }
193
+
194
+ // ── Remembered execution-target choice (per repo) ─────────────────────────────
195
+ //
196
+ // P-ppa10009: optionally remember the user's execution-target pick keyed by repo
197
+ // scope so a repeat fix on the same repo doesn't re-prompt. Persisted SQL-first
198
+ // via engine/pr-fix-target-store.js. Absence of a remembered choice still
199
+ // prompts — the safe "never clone silently" default is preserved.
200
+
201
+ /** The remembered execution-target id for a ref's repo scope, or null. */
202
+ function getRememberedTargetForRef(ref) {
203
+ const scope = _refScope(ref);
204
+ if (!scope) return null;
205
+ let target;
206
+ try { target = prFixTargetStore.getRemembered(scope); }
207
+ catch { return null; }
208
+ // A stale/invalid remembered value must never route a clone — ignore it.
209
+ return isValidExecutionTarget(target) ? target : null;
210
+ }
211
+
212
+ /** Remember `target` for a ref's repo scope. Throws on invalid scope/target. */
213
+ function rememberTargetForRef(ref, target) {
214
+ const scope = _refScope(ref);
215
+ if (!scope) throw new PrActionError('cannot remember choice: PR ref has no resolvable repo scope');
216
+ if (!isValidExecutionTarget(target)) {
217
+ throw new PrActionError(
218
+ `unknown execution target: ${JSON.stringify(target)}. Valid targets: ${EXECUTION_TARGET_IDS.join(', ')}`,
219
+ );
220
+ }
221
+ return prFixTargetStore.remember(scope, target);
222
+ }
223
+
224
+ /** Forget the remembered choice for a ref's repo scope. Returns true if cleared. */
225
+ function clearRememberedTargetForRef(ref) {
226
+ const scope = _refScope(ref);
227
+ if (!scope) return false;
228
+ try { return prFixTargetStore.clear(scope); }
229
+ catch { return false; }
230
+ }
231
+
232
+ // ── Choice surfaces (CC prompt + dashboard modal) ─────────────────────────────
233
+
234
+ /** Plain target descriptors for a UI to render (id/label/description/default/...). */
235
+ function _targetOptions() {
236
+ return EXECUTION_TARGETS.map((t) => ({
237
+ id: t.id,
238
+ label: t.label,
239
+ description: t.description,
240
+ isDefault: t.id === DEFAULT_EXECUTION_TARGET,
241
+ trivialOnly: t.trivialOnly,
242
+ validates: t.validates,
243
+ }));
244
+ }
245
+
246
+ /**
247
+ * The Command Center text presenting the execution-target choice. CC shows this
248
+ * verbatim so the user picks a target before any clone happens.
249
+ */
250
+ function buildPrFixTargetPrompt(plan) {
251
+ const prId = (plan && (plan.prId || (plan.ref && plan.ref.id))) || 'this pull request';
252
+ const lines = [
253
+ `Fixing ${prId} needs a checkout, but its repo is not a configured project.`,
254
+ 'Choose an execution target before anything is cloned (nothing is cloned until you pick):',
255
+ '',
256
+ ];
257
+ for (const t of EXECUTION_TARGETS) {
258
+ const tag = t.id === DEFAULT_EXECUTION_TARGET ? ' [default]' : (t.trivialOnly ? ' [trivial only]' : '');
259
+ lines.push(`- ${t.label}${tag} — ${t.description}`);
260
+ }
261
+ lines.push('');
262
+ lines.push(`Reply with one of: ${EXECUTION_TARGET_IDS.join(', ')} (default: ${DEFAULT_EXECUTION_TARGET}).`);
263
+ return lines.join('\n');
264
+ }
265
+
266
+ /**
267
+ * A dashboard modal descriptor for the execution-target choice. Shape kept thin
268
+ * and serializable so the dashboard renders it without bespoke parsing.
269
+ */
270
+ function buildPrFixTargetModal(plan) {
271
+ return {
272
+ kind: 'pr-fix-target-choice',
273
+ title: 'Choose execution target',
274
+ prId: (plan && plan.prId) || (plan && plan.ref && plan.ref.id) || null,
275
+ prUrl: (plan && plan.prUrl) || null,
276
+ message: 'This PR’s repo is not a configured project. Pick how to run the fix — nothing is cloned until you choose.',
277
+ options: _targetOptions(),
278
+ default: DEFAULT_EXECUTION_TARGET,
279
+ };
280
+ }
281
+
282
+ // ── State machine ─────────────────────────────────────────────────────────────
283
+
284
+ /**
285
+ * Plan a one-off fix for a PR. The decision/routing entry point.
286
+ *
287
+ * `input` — `{ url }` (raw PR URL / canonical id) or an already-normalized ref.
288
+ * `opts.config` — full config (for configured-project detection).
289
+ * `opts.prUrl` — original user-supplied URL (preferred in the surfaces).
290
+ *
291
+ * Returns one of:
292
+ * - `{ status:'project-ready', ref, prId, project, projectName, target:'project' }`
293
+ * when the repo IS already a configured project — skip the prompt, use the
294
+ * existing project fix path. No clone needed (the project already has a
295
+ * worktree/checkout path).
296
+ * - `{ status:'routed', ref, prId, prUrl, target, executor, fromRemembered:true,
297
+ * rememberedTarget }` when the repo is unconfigured BUT the user has a
298
+ * remembered per-repo choice (P-ppa10009) — skip the prompt and route
299
+ * straight to the executor. Still NOTHING is cloned here; the caller invokes
300
+ * the executor. Pass `opts.ignoreRemembered:true` to force the prompt anyway
301
+ * (override path).
302
+ * - `{ status:'awaiting-target-choice', ref, prId, prUrl, options, default,
303
+ * prompt, modal }` when the repo is unconfigured and no remembered choice
304
+ * applies — PAUSE for the user. NOTHING is cloned here; that is the whole
305
+ * point of this item.
306
+ *
307
+ * Throws `PrActionError` (400) on bad input.
308
+ */
309
+ function planPrFix(input = {}, opts = {}) {
310
+ const ref = _resolveRef(input);
311
+ const prUrl = opts.prUrl || (input && typeof input === 'object' ? input.url : undefined) || null;
312
+ const prId = ref.id || (ref.host && ref.slug && ref.number ? `${ref.host}:${ref.slug}#${ref.number}` : null);
313
+
314
+ const project = findConfiguredProjectForRef(ref, opts.config);
315
+ if (project) {
316
+ return {
317
+ status: 'project-ready',
318
+ ref,
319
+ prId,
320
+ prUrl,
321
+ target: 'project',
322
+ project,
323
+ projectName: project.name || null,
324
+ };
325
+ }
326
+
327
+ const plan = { status: 'awaiting-target-choice', ref, prId, prUrl };
328
+
329
+ // P-ppa10009: a remembered per-repo choice skips the prompt on subsequent
330
+ // fixes for that repo. Absence of one still prompts (the safe default) — and an
331
+ // explicit override can force the prompt via opts.ignoreRemembered.
332
+ if (!opts.ignoreRemembered) {
333
+ const remembered = getRememberedTargetForRef(ref);
334
+ if (remembered) {
335
+ return _routedRecord(plan, remembered, { fromRemembered: true, rememberedTarget: remembered });
336
+ }
337
+ }
338
+
339
+ return {
340
+ ...plan,
341
+ options: _targetOptions(),
342
+ default: DEFAULT_EXECUTION_TARGET,
343
+ prompt: buildPrFixTargetPrompt(plan),
344
+ modal: buildPrFixTargetModal(plan),
345
+ };
346
+ }
347
+
348
+ /**
349
+ * Resolve an execution-target choice for a planned fix. The second half of the
350
+ * state machine: takes a plan (or raw `{ url }`) plus the user's `choice` and
351
+ * returns the routing intent to the matching executor.
352
+ *
353
+ * IMPORTANT: this still does NOT clone or spawn — selection only routes. The
354
+ * returned `routed` record carries `executor.implemented` (true once a real
355
+ * executor backs the choice: temp-clone → engine/pr-temp-clone.js; clone-keep →
356
+ * engine/pr-clone-keep.js; remote-patch → engine/pr-remote-patch.js). The caller is
357
+ * responsible for invoking the executor; this surface never clones, keeping
358
+ * "never clone silently" absolute.
359
+ *
360
+ * `planOrInput` — a plan from `planPrFix`, an already-normalized ref, or `{ url }`.
361
+ * `choice` — one of EXECUTION_TARGET_IDS.
362
+ * `opts.config` — full config (re-checks configured-project so a stale plan can't
363
+ * force a clone choice on a repo that is actually a project).
364
+ * `opts.remember` — when true, persist this choice for the repo scope so future
365
+ * fixes skip the prompt (P-ppa10009). No-op for a configured project (moot).
366
+ *
367
+ * Returns:
368
+ * - `{ status:'project-ready', ... }` — repo is a configured project; the
369
+ * target choice is moot, route to the existing project fix path.
370
+ * - `{ status:'routed', ref, prId, prUrl, target, executor, remembered? }` —
371
+ * valid choice. `remembered:true` when opts.remember persisted the choice.
372
+ *
373
+ * Throws `PrActionError` (400) on missing/invalid choice or bad input.
374
+ */
375
+ function resolvePrFixTarget(planOrInput = {}, choice, opts = {}) {
376
+ // Re-derive the plan from current config so a stale awaiting-choice plan can't
377
+ // route a clone for a repo that has since become a configured project. Ignore
378
+ // any remembered choice here — the caller is supplying an explicit one.
379
+ const refInput = planOrInput && planOrInput.ref ? planOrInput.ref : planOrInput;
380
+ const plan = planPrFix(refInput, {
381
+ config: opts.config,
382
+ prUrl: (planOrInput && planOrInput.prUrl) || opts.prUrl,
383
+ ignoreRemembered: true,
384
+ });
385
+
386
+ if (plan.status === 'project-ready') return plan; // choice is moot — use project path
387
+
388
+ const target = typeof choice === 'string' ? choice.trim().toLowerCase() : '';
389
+ if (!target) throw new PrActionError('execution target required');
390
+ if (!isValidExecutionTarget(target)) {
391
+ throw new PrActionError(
392
+ `unknown execution target: ${JSON.stringify(choice)}. Valid targets: ${EXECUTION_TARGET_IDS.join(', ')}`,
393
+ );
394
+ }
395
+
396
+ let remembered = false;
397
+ if (opts.remember) {
398
+ try { rememberTargetForRef(plan.ref, target); remembered = true; }
399
+ catch { /* persistence is best-effort — never block the fix on a store error */ }
400
+ }
401
+
402
+ return _routedRecord(plan, target, remembered ? { remembered: true } : {});
403
+ }
404
+
405
+ module.exports = {
406
+ EXECUTION_TARGETS,
407
+ EXECUTION_TARGET_IDS,
408
+ DEFAULT_EXECUTION_TARGET,
409
+ isValidExecutionTarget,
410
+ findConfiguredProjectForRef,
411
+ planPrFix,
412
+ resolvePrFixTarget,
413
+ buildPrFixTargetPrompt,
414
+ buildPrFixTargetModal,
415
+ getRememberedTargetForRef,
416
+ rememberTargetForRef,
417
+ clearRememberedTargetForRef,
418
+ };