@dev-loops/core 0.1.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 (54) hide show
  1. package/bin/capture-deep-persona-signals.mjs +143 -0
  2. package/bin/ensure-phase-files.mjs +7 -0
  3. package/bin/log-bash-exit-1.mjs +7 -0
  4. package/bin/parse-review-threads.mjs +7 -0
  5. package/package.json +78 -0
  6. package/src/analysis/change-classifier.mjs +146 -0
  7. package/src/analysis/diff-analyzer.mjs +285 -0
  8. package/src/bash-exit-one.mjs +130 -0
  9. package/src/cli/helpers.mjs +22 -0
  10. package/src/cli/primitives.mjs +70 -0
  11. package/src/cli/retry-wrapper.mjs +169 -0
  12. package/src/cli/subcommand-runner.mjs +246 -0
  13. package/src/config/config.mjs +965 -0
  14. package/src/debt/cluster.mjs +240 -0
  15. package/src/debt/debt-finding.mjs +68 -0
  16. package/src/debt/debt-signal.mjs +46 -0
  17. package/src/debt/deep-persona-signals.mjs +266 -0
  18. package/src/debt/remediation-to-issue.mjs +121 -0
  19. package/src/debt/score.mjs +127 -0
  20. package/src/debt/shape.mjs +214 -0
  21. package/src/github/copilot-helpers.mjs +343 -0
  22. package/src/github/repo-slug.mjs +105 -0
  23. package/src/github/review-threads.mjs +343 -0
  24. package/src/harness/adapter.mjs +57 -0
  25. package/src/harness/index.mjs +3 -0
  26. package/src/harness/noop-adapter.mjs +22 -0
  27. package/src/harness/pi-adapter.mjs +47 -0
  28. package/src/loop/async-start-contract.mjs +170 -0
  29. package/src/loop/conductor-routing.mjs +817 -0
  30. package/src/loop/copilot-ci-status.mjs +255 -0
  31. package/src/loop/copilot-loop-iterations.mjs +161 -0
  32. package/src/loop/copilot-loop-state.mjs +510 -0
  33. package/src/loop/handoff-envelope.mjs +800 -0
  34. package/src/loop/issue-refinement-artifact.mjs +268 -0
  35. package/src/loop/lifecycle-state.mjs +342 -0
  36. package/src/loop/phase-files.mjs +187 -0
  37. package/src/loop/policy-constants.mjs +17 -0
  38. package/src/loop/pr-gate-coordination.mjs +1278 -0
  39. package/src/loop/public-dev-loop-routing-contract.mjs +277 -0
  40. package/src/loop/public-dev-loop-routing.mjs +1746 -0
  41. package/src/loop/queue-board-ordering.mjs +38 -0
  42. package/src/loop/queue-board-sync.mjs +223 -0
  43. package/src/loop/queue-driver.mjs +164 -0
  44. package/src/loop/queue-parallel.mjs +190 -0
  45. package/src/loop/queue-state.mjs +230 -0
  46. package/src/loop/retrospective-checkpoint.mjs +178 -0
  47. package/src/loop/reviewer-loop-state.mjs +456 -0
  48. package/src/loop/run-inspection.mjs +604 -0
  49. package/src/loop/steering.mjs +793 -0
  50. package/src/loop/timeout-policy.mjs +73 -0
  51. package/src/loop/tracker-first-loop-state.mjs +87 -0
  52. package/src/loop/tracker-pr-state.mjs +301 -0
  53. package/src/loop/worktree-guard.mjs +141 -0
  54. package/src/refinement/ac-dod-matrix.mjs +95 -0
@@ -0,0 +1,817 @@
1
+ /**
2
+ * Conductor routing contract: deterministic routing and handoff decisions
3
+ * above family-local state machines.
4
+ *
5
+ * This module provides:
6
+ * - ROUTING_OUTCOME: closed routing outcome taxonomy constants
7
+ * - LOOP_FAMILY: loop family identifier constants
8
+ * - SOURCE_MODE: confidence/source mode constants
9
+ * - ENTRYPOINT: handoff entrypoint identifier constants
10
+ * - STOP_REASON: stop reason code constants (for outer-loop backward compat)
11
+ * - evaluateConductorRouting: shared evaluator/policy entrypoint
12
+ *
13
+ * Contract guarantees:
14
+ * - One deterministic routing outcome per normalized input set
15
+ * - Ambiguous, conflicting, or insufficient inputs return `needs_reconcile`
16
+ * rather than a guessed handoff
17
+ * - The evaluator is purely functional; no I/O or side effects
18
+ * - Callers use evaluateConductorRouting as the single routing authority
19
+ *
20
+ * Integration boundary (see docs/conductor-routing-contract.md):
21
+ * - This module starts after active-run identity and ownership are already resolved
22
+ * - It consumes already-detected family-local lifecycle states as inputs
23
+ * - It derives the routing outcome directly from states; it does not take a
24
+ * pre-computed outer-loop action as an input
25
+ * - It emits routing decisions and handoff envelopes; it does not perform handoff
26
+ * - Ownership/idempotency rules remain in conductor-ownership.mjs (#32)
27
+ * - Family-local state machine semantics remain in copilot-loop-state.mjs etc. (#26)
28
+ */
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Exported constants
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /**
35
+ * Closed routing outcome taxonomy constants.
36
+ *
37
+ * Covers all possible routing decisions for an already-targeted active run.
38
+ */
39
+ export const ROUTING_OUTCOME = Object.freeze({
40
+ /** Outer-loop wait; re-enter after a bounded wait interval. No handoff needed yet. */
41
+ CONTINUE_CURRENT_WAIT: "continue_current_wait",
42
+ /** Copilot inner loop should handle the next step. */
43
+ HANDOFF_TO_COPILOT_LOOP: "handoff_to_copilot_loop",
44
+ /** Reviewer inner loop should handle the next step. */
45
+ HANDOFF_TO_REVIEWER_LOOP: "handoff_to_reviewer_loop",
46
+ /** A live owner already has control; no new handoff is needed at this cycle. */
47
+ STAY_WITH_CURRENT_LIVE_OWNER: "stay_with_current_live_owner",
48
+ /** Blocked state requiring human intervention before any loop can proceed. */
49
+ STOP_NEEDS_HUMAN: "stop_needs_human",
50
+ /** PR is merged, closed, or fully done; no further loop action is needed. */
51
+ DONE_TERMINAL: "done_terminal",
52
+ /** Ambiguous, conflicting, stale, or insufficient signals; reconcile before routing. */
53
+ NEEDS_RECONCILE: "needs_reconcile",
54
+ });
55
+
56
+ /**
57
+ * Loop family identifier constants.
58
+ */
59
+ export const LOOP_FAMILY = Object.freeze({
60
+ /** Copilot review/fix inner loop. */
61
+ COPILOT_LOOP: "copilot_loop",
62
+ /** Reviewer-side inner loop. */
63
+ REVIEWER_LOOP: "reviewer_loop",
64
+ /** Outer conductor loop (wait/checkpoint). */
65
+ OUTER_LOOP: "outer_loop",
66
+ /** No loop family (terminal, blocked, or reconcile states). */
67
+ NONE: null,
68
+ });
69
+
70
+ /**
71
+ * Source/confidence mode constants for routing inputs.
72
+ */
73
+ export const SOURCE_MODE = Object.freeze({
74
+ /** State derived from authoritative remote signals. */
75
+ AUTHORITATIVE: "authoritative",
76
+ /** State derived from local records only. */
77
+ LOCAL: "local",
78
+ /** State from a pre-captured snapshot (snapshot-mode testing or replay). */
79
+ SNAPSHOT: "snapshot",
80
+ });
81
+
82
+ /**
83
+ * Handoff entrypoint identifier constants.
84
+ *
85
+ * These identify the specific handler/script that the conductor should invoke
86
+ * for each loop family, without requiring prose to restate the branch logic.
87
+ */
88
+ export const ENTRYPOINT = Object.freeze({
89
+ /** copilot-pr-handoff.mjs — main copilot loop re-entry handler. */
90
+ COPILOT_PR_HANDOFF: "copilot_pr_handoff",
91
+ /** reviewer loop handler — reviewer-side inner loop re-entry. */
92
+ REVIEWER_LOOP_HANDLER: "reviewer_loop_handler",
93
+ /** outer-loop.mjs — outer wait/checkpoint re-run. */
94
+ OUTER_LOOP_WAIT: "outer_loop_wait",
95
+ /** No automated entrypoint; human intervention required. */
96
+ NONE: null,
97
+ });
98
+
99
+ /**
100
+ * Stop reason code constants for outer-loop backward-compatibility.
101
+ *
102
+ * Populated in `stopReason` on results whose `outerAction` is "stop".
103
+ */
104
+ export const STOP_REASON = Object.freeze({
105
+ PR_NOT_READY: "pr_not_ready",
106
+ COPILOT_BLOCKED: "copilot_blocked",
107
+ REVIEWER_BLOCKED: "reviewer_blocked",
108
+ REVIEW_UNAVAILABLE: "review_unavailable",
109
+ OWNERSHIP_CONFLICT: "ownership_conflict",
110
+ UNKNOWN_STATE: "unknown_state",
111
+ });
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Internal: state classification sets
115
+ // ---------------------------------------------------------------------------
116
+
117
+ // Copilot strong active states: win over reviewer wait states
118
+ const COPILOT_STRONG_ACTIVE = new Set([
119
+ "unresolved_feedback_present",
120
+ "already_fixed_needs_reply_resolve",
121
+ ]);
122
+
123
+ // Copilot weak active states: yield to reviewer wait states
124
+ const COPILOT_WEAK_ACTIVE = new Set([
125
+ "pr_ready_no_feedback",
126
+ "ready_to_rerequest_review",
127
+ ]);
128
+
129
+ // Copilot wait states owned by the orchestrator
130
+ const COPILOT_WAIT = new Set([
131
+ "waiting_for_copilot_review",
132
+ "waiting_for_ci",
133
+ ]);
134
+
135
+ // Reviewer active states requiring handoff or isolation check
136
+ const REVIEWER_ACTIVE = new Set([
137
+ "review_requested",
138
+ "determine_review_plan",
139
+ "reviews_running",
140
+ "merge_results",
141
+ "draft_review_ready",
142
+ "draft_review_posted",
143
+ "waiting_for_user_submit",
144
+ "review_invalidated",
145
+ ]);
146
+
147
+ // Reviewer wait states owned by the orchestrator
148
+ const REVIEWER_WAIT = new Set([
149
+ "submitted_review",
150
+ "waiting_for_author_followup",
151
+ "waiting_for_re_request",
152
+ ]);
153
+
154
+ // Ownership state that indicates a live owner is already active
155
+ const OWNERSHIP_LIVE_OWNER = "live_owner";
156
+
157
+ // Ownership state that indicates duplicate local owners (must reconcile)
158
+ const OWNERSHIP_DUPLICATE_LOCAL_OWNERS = "duplicate_local_owners";
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Input normalization helpers
162
+ // ---------------------------------------------------------------------------
163
+
164
+ function normalizeTarget(target) {
165
+ if (!target || typeof target !== "object") {
166
+ return null;
167
+ }
168
+ const { repo, pr } = target;
169
+ if (typeof repo !== "string" || repo.trim().length === 0) {
170
+ return null;
171
+ }
172
+ if (typeof pr !== "number" || !Number.isInteger(pr) || pr <= 0) {
173
+ return null;
174
+ }
175
+ return { repo: repo.trim().toLowerCase(), pr };
176
+ }
177
+
178
+ function describeMalformedTarget(target) {
179
+ if (!target || typeof target !== "object") {
180
+ return null;
181
+ }
182
+
183
+ const repo = typeof target.repo === "string" && target.repo.trim().length > 0
184
+ ? target.repo.trim().toLowerCase()
185
+ : null;
186
+ const pr = typeof target.pr === "number" && Number.isInteger(target.pr) && target.pr > 0
187
+ ? target.pr
188
+ : null;
189
+
190
+ return { repo, pr };
191
+ }
192
+
193
+ function resolveConfidence(sourceMode) {
194
+ if (sourceMode === SOURCE_MODE.AUTHORITATIVE) {
195
+ return SOURCE_MODE.AUTHORITATIVE;
196
+ }
197
+ if (sourceMode === SOURCE_MODE.SNAPSHOT) {
198
+ return SOURCE_MODE.SNAPSHOT;
199
+ }
200
+ return SOURCE_MODE.LOCAL;
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // Handoff envelope builder
205
+ // ---------------------------------------------------------------------------
206
+
207
+ /**
208
+ * Build a machine-readable handoff envelope.
209
+ *
210
+ * @param {object} params
211
+ * @returns {object}
212
+ */
213
+ function buildEnvelope({
214
+ targetIdentity,
215
+ loopFamily,
216
+ entrypoint,
217
+ reason,
218
+ requiredArgs = {},
219
+ requiresLocalIsolation = false,
220
+ confidence = SOURCE_MODE.LOCAL,
221
+ }) {
222
+ return {
223
+ targetIdentity,
224
+ loopFamily,
225
+ entrypoint,
226
+ reason,
227
+ requiredArgs,
228
+ requiresLocalIsolation,
229
+ confidence,
230
+ };
231
+ }
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // Internal: routing helpers
235
+ // ---------------------------------------------------------------------------
236
+
237
+ /**
238
+ * Build a stay_with_current_live_owner result when an active live owner
239
+ * is already handling this scope; no new handoff is needed.
240
+ */
241
+ function stayWithLiveOwner({
242
+ normalizedTarget,
243
+ copilotState,
244
+ reviewerState,
245
+ baseArgs,
246
+ requiresLocalIsolation,
247
+ confidence,
248
+ }) {
249
+ return {
250
+ routingOutcome: ROUTING_OUTCOME.STAY_WITH_CURRENT_LIVE_OWNER,
251
+ outerAction: "continue_wait",
252
+ stopReason: null,
253
+ handoffEnvelope: buildEnvelope({
254
+ targetIdentity: normalizedTarget,
255
+ loopFamily: LOOP_FAMILY.OUTER_LOOP,
256
+ entrypoint: ENTRYPOINT.OUTER_LOOP_WAIT,
257
+ reason: `A live owner is already active for this scope; no new handoff issued: copilot_state=${copilotState}, reviewer_state=${reviewerState}`,
258
+ requiredArgs: baseArgs,
259
+ requiresLocalIsolation,
260
+ confidence,
261
+ }),
262
+ };
263
+ }
264
+
265
+ function continueCurrentWait({
266
+ normalizedTarget,
267
+ copilotState,
268
+ reviewerState,
269
+ baseArgs,
270
+ requiresLocalIsolation,
271
+ confidence,
272
+ }) {
273
+ return {
274
+ routingOutcome: ROUTING_OUTCOME.CONTINUE_CURRENT_WAIT,
275
+ outerAction: "continue_wait",
276
+ stopReason: null,
277
+ handoffEnvelope: buildEnvelope({
278
+ targetIdentity: normalizedTarget,
279
+ loopFamily: LOOP_FAMILY.OUTER_LOOP,
280
+ entrypoint: ENTRYPOINT.OUTER_LOOP_WAIT,
281
+ reason: `Outer-loop wait state: copilot_state=${copilotState}, reviewer_state=${reviewerState}`,
282
+ requiredArgs: baseArgs,
283
+ requiresLocalIsolation,
284
+ confidence,
285
+ }),
286
+ };
287
+ }
288
+
289
+ /**
290
+ * Core routing policy: derive a routing outcome from normalized states.
291
+ *
292
+ * This function contains the real branch logic. Both evaluateConductorRouting
293
+ * (full contract with target validation) and the thin decideOuterAction adapter
294
+ * (target-agnostic) delegate here.
295
+ *
296
+ * Priority order (first match wins):
297
+ * 1. Ownership conflict (duplicate_local_owners) → needs_reconcile
298
+ * 2. Terminal (done) → done_terminal
299
+ * 3. Missing PR (no_pr) → stop_needs_human / pr_not_ready
300
+ * 4. Hard copilot stop (review_request_unavailable, blocked) → stop_needs_human
301
+ * 5. Hard reviewer stop (blocked) → stop_needs_human
302
+ * 6. pr_draft — live-owner check, then handoff (marking requiresLocalIsolation when needed)
303
+ * 7. Copilot explicit review-settle wait (waiting_for_copilot_review) → continue_current_wait
304
+ * 8. Reviewer active states — live-owner check, handoff (marking requiresLocalIsolation when needed)
305
+ * 9. Copilot strong active states — live-owner check, handoff (marking requiresLocalIsolation when needed)
306
+ * 10. Outer-loop wait states (copilot or reviewer)
307
+ * 11. Copilot weak active states (yield to reviewer wait above)
308
+ * 12. Fallback → needs_reconcile / unknown_state
309
+ *
310
+ * @param {object} params
311
+ * @param {{ repo: string, pr: number }} params.normalizedTarget
312
+ * @param {string} params.copilotState
313
+ * @param {string} params.reviewerState
314
+ * @param {string|undefined} params.ownershipState
315
+ * @param {boolean} params.requiresLocalIsolation
316
+ * @param {string} params.confidence
317
+ * @returns {{ routingOutcome: string, outerAction: string, stopReason: string|null, handoffEnvelope: object }}
318
+ */
319
+ function routeFromStates({
320
+ normalizedTarget,
321
+ copilotState,
322
+ reviewerState,
323
+ ownershipState,
324
+ requiresLocalIsolation,
325
+ confidence,
326
+ }) {
327
+ const baseArgs = { repo: normalizedTarget.repo, pr: normalizedTarget.pr };
328
+
329
+ // 1. Ownership conflict — must reconcile before routing
330
+ if (ownershipState === OWNERSHIP_DUPLICATE_LOCAL_OWNERS) {
331
+ return {
332
+ routingOutcome: ROUTING_OUTCOME.NEEDS_RECONCILE,
333
+ outerAction: "stop",
334
+ stopReason: STOP_REASON.OWNERSHIP_CONFLICT,
335
+ handoffEnvelope: buildEnvelope({
336
+ targetIdentity: normalizedTarget,
337
+ loopFamily: LOOP_FAMILY.NONE,
338
+ entrypoint: ENTRYPOINT.NONE,
339
+ reason: "Ownership state indicates duplicate local owners; reconcile ownership before routing",
340
+ requiredArgs: baseArgs,
341
+ requiresLocalIsolation,
342
+ confidence,
343
+ }),
344
+ };
345
+ }
346
+
347
+ // 2. Terminal
348
+ if (copilotState === "done") {
349
+ return {
350
+ routingOutcome: ROUTING_OUTCOME.DONE_TERMINAL,
351
+ outerAction: "done",
352
+ stopReason: null,
353
+ handoffEnvelope: buildEnvelope({
354
+ targetIdentity: normalizedTarget,
355
+ loopFamily: LOOP_FAMILY.NONE,
356
+ entrypoint: ENTRYPOINT.NONE,
357
+ reason: "PR is merged or closed; conductor loop is complete",
358
+ requiredArgs: baseArgs,
359
+ requiresLocalIsolation,
360
+ confidence,
361
+ }),
362
+ };
363
+ }
364
+
365
+ // 3. No PR
366
+ if (copilotState === "no_pr") {
367
+ return {
368
+ routingOutcome: ROUTING_OUTCOME.STOP_NEEDS_HUMAN,
369
+ outerAction: "stop",
370
+ stopReason: STOP_REASON.PR_NOT_READY,
371
+ handoffEnvelope: buildEnvelope({
372
+ targetIdentity: normalizedTarget,
373
+ loopFamily: LOOP_FAMILY.NONE,
374
+ entrypoint: ENTRYPOINT.NONE,
375
+ reason: "No open PR exists for this scope; cannot route",
376
+ requiredArgs: baseArgs,
377
+ requiresLocalIsolation,
378
+ confidence,
379
+ }),
380
+ };
381
+ }
382
+
383
+ // 4. Hard copilot stops
384
+ if (copilotState === "review_request_unavailable") {
385
+ return {
386
+ routingOutcome: ROUTING_OUTCOME.STOP_NEEDS_HUMAN,
387
+ outerAction: "stop",
388
+ stopReason: STOP_REASON.REVIEW_UNAVAILABLE,
389
+ handoffEnvelope: buildEnvelope({
390
+ targetIdentity: normalizedTarget,
391
+ loopFamily: LOOP_FAMILY.NONE,
392
+ entrypoint: ENTRYPOINT.NONE,
393
+ reason: "Copilot review request returned unavailable; human intervention required",
394
+ requiredArgs: baseArgs,
395
+ requiresLocalIsolation,
396
+ confidence,
397
+ }),
398
+ };
399
+ }
400
+
401
+ if (copilotState === "blocked_needs_user_decision") {
402
+ return {
403
+ routingOutcome: ROUTING_OUTCOME.STOP_NEEDS_HUMAN,
404
+ outerAction: "stop",
405
+ stopReason: STOP_REASON.COPILOT_BLOCKED,
406
+ handoffEnvelope: buildEnvelope({
407
+ targetIdentity: normalizedTarget,
408
+ loopFamily: LOOP_FAMILY.NONE,
409
+ entrypoint: ENTRYPOINT.NONE,
410
+ reason: "Copilot loop is blocked and requires human decision",
411
+ requiredArgs: baseArgs,
412
+ requiresLocalIsolation,
413
+ confidence,
414
+ }),
415
+ };
416
+ }
417
+
418
+ // 5. Hard reviewer stop
419
+ if (reviewerState === "blocked_needs_user_decision") {
420
+ return {
421
+ routingOutcome: ROUTING_OUTCOME.STOP_NEEDS_HUMAN,
422
+ outerAction: "stop",
423
+ stopReason: STOP_REASON.REVIEWER_BLOCKED,
424
+ handoffEnvelope: buildEnvelope({
425
+ targetIdentity: normalizedTarget,
426
+ loopFamily: LOOP_FAMILY.NONE,
427
+ entrypoint: ENTRYPOINT.NONE,
428
+ reason: "Reviewer loop is blocked and requires human decision",
429
+ requiredArgs: baseArgs,
430
+ requiresLocalIsolation,
431
+ confidence,
432
+ }),
433
+ };
434
+ }
435
+
436
+ // 6. pr_draft — hand off to the copilot loop; dirty/detached checkouts
437
+ // are surfaced via handoffEnvelope.requiresLocalIsolation so callers can
438
+ // re-enter from an isolated checkout/worktree instead of treating the seam
439
+ // as a terminal stop.
440
+ if (copilotState === "pr_draft") {
441
+ if (ownershipState === OWNERSHIP_LIVE_OWNER) {
442
+ return stayWithLiveOwner({ normalizedTarget, copilotState, reviewerState, baseArgs, requiresLocalIsolation, confidence });
443
+ }
444
+ return {
445
+ routingOutcome: ROUTING_OUTCOME.HANDOFF_TO_COPILOT_LOOP,
446
+ outerAction: "reenter_copilot_loop",
447
+ stopReason: null,
448
+ handoffEnvelope: buildEnvelope({
449
+ targetIdentity: normalizedTarget,
450
+ loopFamily: LOOP_FAMILY.COPILOT_LOOP,
451
+ entrypoint: ENTRYPOINT.COPILOT_PR_HANDOFF,
452
+ reason: `PR is in draft state; copilot loop required: copilot_state=${copilotState}`,
453
+ requiredArgs: baseArgs,
454
+ requiresLocalIsolation,
455
+ confidence,
456
+ }),
457
+ };
458
+ }
459
+
460
+ // 7. Copilot explicit review-settle wait — keep watch semantics until settled
461
+ if (copilotState === "waiting_for_copilot_review") {
462
+ return continueCurrentWait({
463
+ normalizedTarget,
464
+ copilotState,
465
+ reviewerState,
466
+ baseArgs,
467
+ requiresLocalIsolation,
468
+ confidence,
469
+ });
470
+ }
471
+
472
+ // 8. Reviewer active states
473
+ if (REVIEWER_ACTIVE.has(reviewerState)) {
474
+ if (ownershipState === OWNERSHIP_LIVE_OWNER) {
475
+ return stayWithLiveOwner({ normalizedTarget, copilotState, reviewerState, baseArgs, requiresLocalIsolation, confidence });
476
+ }
477
+ return {
478
+ routingOutcome: ROUTING_OUTCOME.HANDOFF_TO_REVIEWER_LOOP,
479
+ outerAction: "reenter_reviewer_loop",
480
+ stopReason: null,
481
+ handoffEnvelope: buildEnvelope({
482
+ targetIdentity: normalizedTarget,
483
+ loopFamily: LOOP_FAMILY.REVIEWER_LOOP,
484
+ entrypoint: ENTRYPOINT.REVIEWER_LOOP_HANDLER,
485
+ reason: `Reviewer loop requires action: reviewer_state=${reviewerState}`,
486
+ requiredArgs: baseArgs,
487
+ requiresLocalIsolation,
488
+ confidence,
489
+ }),
490
+ };
491
+ }
492
+
493
+ // 9. Copilot strong active states — win over reviewer wait states
494
+ if (COPILOT_STRONG_ACTIVE.has(copilotState)) {
495
+ if (ownershipState === OWNERSHIP_LIVE_OWNER) {
496
+ return stayWithLiveOwner({ normalizedTarget, copilotState, reviewerState, baseArgs, requiresLocalIsolation, confidence });
497
+ }
498
+ return {
499
+ routingOutcome: ROUTING_OUTCOME.HANDOFF_TO_COPILOT_LOOP,
500
+ outerAction: "reenter_copilot_loop",
501
+ stopReason: null,
502
+ handoffEnvelope: buildEnvelope({
503
+ targetIdentity: normalizedTarget,
504
+ loopFamily: LOOP_FAMILY.COPILOT_LOOP,
505
+ entrypoint: ENTRYPOINT.COPILOT_PR_HANDOFF,
506
+ reason: `Copilot loop requires action: copilot_state=${copilotState}`,
507
+ requiredArgs: baseArgs,
508
+ requiresLocalIsolation,
509
+ confidence,
510
+ }),
511
+ };
512
+ }
513
+
514
+ // 10. Outer-loop wait states (checked before copilot weak active, since weak yields to reviewer wait)
515
+ if (COPILOT_WAIT.has(copilotState) || REVIEWER_WAIT.has(reviewerState)) {
516
+ return continueCurrentWait({
517
+ normalizedTarget,
518
+ copilotState,
519
+ reviewerState,
520
+ baseArgs,
521
+ requiresLocalIsolation,
522
+ confidence,
523
+ });
524
+ }
525
+
526
+ // 11. Copilot weak active states (yield to reviewer wait states above)
527
+ if (COPILOT_WEAK_ACTIVE.has(copilotState)) {
528
+ if (ownershipState === OWNERSHIP_LIVE_OWNER) {
529
+ return stayWithLiveOwner({ normalizedTarget, copilotState, reviewerState, baseArgs, requiresLocalIsolation, confidence });
530
+ }
531
+ return {
532
+ routingOutcome: ROUTING_OUTCOME.HANDOFF_TO_COPILOT_LOOP,
533
+ outerAction: "reenter_copilot_loop",
534
+ stopReason: null,
535
+ handoffEnvelope: buildEnvelope({
536
+ targetIdentity: normalizedTarget,
537
+ loopFamily: LOOP_FAMILY.COPILOT_LOOP,
538
+ entrypoint: ENTRYPOINT.COPILOT_PR_HANDOFF,
539
+ reason: `Copilot loop requires action: copilot_state=${copilotState}`,
540
+ requiredArgs: baseArgs,
541
+ requiresLocalIsolation,
542
+ confidence,
543
+ }),
544
+ };
545
+ }
546
+
547
+ // 11. Fallback — unrecognized state combination
548
+ return {
549
+ routingOutcome: ROUTING_OUTCOME.NEEDS_RECONCILE,
550
+ outerAction: "stop",
551
+ stopReason: STOP_REASON.UNKNOWN_STATE,
552
+ handoffEnvelope: buildEnvelope({
553
+ targetIdentity: normalizedTarget,
554
+ loopFamily: LOOP_FAMILY.NONE,
555
+ entrypoint: ENTRYPOINT.NONE,
556
+ reason: `Unrecognized combined state: copilot_state=${copilotState}, reviewer_state=${reviewerState}`,
557
+ requiredArgs: baseArgs,
558
+ requiresLocalIsolation,
559
+ confidence,
560
+ }),
561
+ };
562
+ }
563
+
564
+ // ---------------------------------------------------------------------------
565
+ // Shared evaluator / policy entrypoint
566
+ // ---------------------------------------------------------------------------
567
+
568
+ /**
569
+ * Evaluate deterministic conductor routing for an already-targeted active run.
570
+ *
571
+ * This is the single routing authority above family-local state machines.
572
+ * The routing outcome is derived directly from the normalized inputs (states +
573
+ * ownership + isolation); it does NOT take a pre-computed outer-loop action.
574
+ *
575
+ * Returns a closed routing outcome, a derived outer-loop action (for backward
576
+ * compat), and a machine-readable handoff envelope. Ambiguous, conflicting,
577
+ * or insufficient inputs return `needs_reconcile` rather than a guessed handoff.
578
+ *
579
+ * @param {object} input
580
+ * @param {{ repo: string, pr: number }} input.target
581
+ * Explicit target identity (already resolved by the caller).
582
+ * @param {string} [input.ownershipState]
583
+ * Settled ownership/idempotency classification from conductor-ownership (#32).
584
+ * "live_owner" → stay_with_current_live_owner (no new handoff this cycle).
585
+ * "duplicate_local_owners" → needs_reconcile.
586
+ * Other values or omission → routing continues from states.
587
+ * @param {string} input.copilotState
588
+ * Already-detected copilot loop lifecycle state (from copilot-loop-state.mjs STATE).
589
+ * @param {string} input.reviewerState
590
+ * Already-detected reviewer loop lifecycle state (from reviewer-loop-state.mjs REVIEWER_STATE).
591
+ * @param {string} [input.sourceMode]
592
+ * Source/confidence mode: "authoritative" | "local" | "snapshot".
593
+ * Defaults to "local".
594
+ * @param {boolean} [input.requiresLocalIsolation]
595
+ * Whether the checkout is dirty or detached; blocks states that need local execution.
596
+ * Defaults to false.
597
+ * @returns {{ routingOutcome: string, outerAction: string, stopReason: string|null, handoffEnvelope: object }}
598
+ */
599
+ export function evaluateConductorRouting({
600
+ target,
601
+ ownershipState,
602
+ copilotState,
603
+ reviewerState,
604
+ sourceMode,
605
+ requiresLocalIsolation = false,
606
+ }) {
607
+ const confidence = resolveConfidence(sourceMode);
608
+
609
+ // --- 1. Validate target identity ---
610
+ const normalizedTarget = normalizeTarget(target);
611
+ if (!normalizedTarget) {
612
+ return {
613
+ routingOutcome: ROUTING_OUTCOME.NEEDS_RECONCILE,
614
+ outerAction: "stop",
615
+ stopReason: STOP_REASON.UNKNOWN_STATE,
616
+ handoffEnvelope: buildEnvelope({
617
+ targetIdentity: describeMalformedTarget(target),
618
+ loopFamily: LOOP_FAMILY.NONE,
619
+ entrypoint: ENTRYPOINT.NONE,
620
+ reason: "Target identity is missing or malformed; cannot route without a resolved target",
621
+ requiresLocalIsolation,
622
+ confidence,
623
+ }),
624
+ };
625
+ }
626
+
627
+ // --- 2. Validate required state inputs ---
628
+ if (typeof copilotState !== "string" || copilotState.trim().length === 0) {
629
+ return {
630
+ routingOutcome: ROUTING_OUTCOME.NEEDS_RECONCILE,
631
+ outerAction: "stop",
632
+ stopReason: STOP_REASON.UNKNOWN_STATE,
633
+ handoffEnvelope: buildEnvelope({
634
+ targetIdentity: normalizedTarget,
635
+ loopFamily: LOOP_FAMILY.NONE,
636
+ entrypoint: ENTRYPOINT.NONE,
637
+ reason: "Copilot state is missing or empty; cannot route without family-local state",
638
+ requiresLocalIsolation,
639
+ confidence,
640
+ }),
641
+ };
642
+ }
643
+
644
+ if (typeof reviewerState !== "string" || reviewerState.trim().length === 0) {
645
+ return {
646
+ routingOutcome: ROUTING_OUTCOME.NEEDS_RECONCILE,
647
+ outerAction: "stop",
648
+ stopReason: STOP_REASON.UNKNOWN_STATE,
649
+ handoffEnvelope: buildEnvelope({
650
+ targetIdentity: normalizedTarget,
651
+ loopFamily: LOOP_FAMILY.NONE,
652
+ entrypoint: ENTRYPOINT.NONE,
653
+ reason: "Reviewer state is missing or empty; cannot route without family-local state",
654
+ requiresLocalIsolation,
655
+ confidence,
656
+ }),
657
+ };
658
+ }
659
+
660
+ // --- 3. Route from normalized states ---
661
+ return routeFromStates({
662
+ normalizedTarget,
663
+ copilotState,
664
+ reviewerState,
665
+ ownershipState,
666
+ requiresLocalIsolation,
667
+ confidence,
668
+ });
669
+ }
670
+
671
+ /**
672
+ * Deterministic outer-loop graph contract above family-local state machines.
673
+ *
674
+ * This module reuses conductor routing as the single source of truth for
675
+ * authoritative outer runtime states. It does not invent a separate outer
676
+ * taxonomy; instead it exposes routing outcomes as the outer state vocabulary,
677
+ * adds graph metadata (semantic Start / End), and provides a stable inspection-
678
+ * and viewer-friendly interpreter surface.
679
+ */
680
+
681
+ export const OUTER_STATE = Object.freeze({
682
+ CONTINUE_CURRENT_WAIT: ROUTING_OUTCOME.CONTINUE_CURRENT_WAIT,
683
+ HANDOFF_TO_COPILOT_LOOP: ROUTING_OUTCOME.HANDOFF_TO_COPILOT_LOOP,
684
+ HANDOFF_TO_REVIEWER_LOOP: ROUTING_OUTCOME.HANDOFF_TO_REVIEWER_LOOP,
685
+ STAY_WITH_CURRENT_LIVE_OWNER: ROUTING_OUTCOME.STAY_WITH_CURRENT_LIVE_OWNER,
686
+ STOP_NEEDS_HUMAN: ROUTING_OUTCOME.STOP_NEEDS_HUMAN,
687
+ DONE_TERMINAL: ROUTING_OUTCOME.DONE_TERMINAL,
688
+ NEEDS_RECONCILE: ROUTING_OUTCOME.NEEDS_RECONCILE,
689
+ });
690
+
691
+ const OUTER_STATE_VALUES = Object.freeze(Object.values(OUTER_STATE));
692
+ const OUTER_STATE_SET = new Set(OUTER_STATE_VALUES);
693
+
694
+ export const OUTER_TERMINAL_STATES = Object.freeze([
695
+ OUTER_STATE.STOP_NEEDS_HUMAN,
696
+ OUTER_STATE.DONE_TERMINAL,
697
+ OUTER_STATE.NEEDS_RECONCILE,
698
+ ]);
699
+
700
+ export const OUTER_NONTERMINAL_STATES = Object.freeze([
701
+ OUTER_STATE.CONTINUE_CURRENT_WAIT,
702
+ OUTER_STATE.HANDOFF_TO_COPILOT_LOOP,
703
+ OUTER_STATE.HANDOFF_TO_REVIEWER_LOOP,
704
+ OUTER_STATE.STAY_WITH_CURRENT_LIVE_OWNER,
705
+ ]);
706
+
707
+ const OUTER_TERMINAL_STATE_SET = new Set(OUTER_TERMINAL_STATES);
708
+ const ALL_OUTER_STATES = Object.freeze([...OUTER_STATE_VALUES]);
709
+
710
+ export const OUTER_GRAPH = Object.freeze({
711
+ start: Object.freeze({ id: "outer_start", label: "Start", semantic: true }),
712
+ end: Object.freeze({ id: "outer_end", label: "End", semantic: true }),
713
+ entryStates: Object.freeze([...OUTER_STATE_VALUES]),
714
+ terminalStates: OUTER_TERMINAL_STATES,
715
+ });
716
+
717
+ export const OUTER_STATE_TO_OUTER_ACTION = Object.freeze({
718
+ [OUTER_STATE.CONTINUE_CURRENT_WAIT]: "continue_wait",
719
+ [OUTER_STATE.HANDOFF_TO_COPILOT_LOOP]: "reenter_copilot_loop",
720
+ [OUTER_STATE.HANDOFF_TO_REVIEWER_LOOP]: "reenter_reviewer_loop",
721
+ [OUTER_STATE.STAY_WITH_CURRENT_LIVE_OWNER]: "continue_wait",
722
+ [OUTER_STATE.STOP_NEEDS_HUMAN]: "stop",
723
+ [OUTER_STATE.DONE_TERMINAL]: "done",
724
+ [OUTER_STATE.NEEDS_RECONCILE]: "stop",
725
+ });
726
+
727
+ export const OUTER_STATE_TO_ROUTING_OUTCOME = Object.freeze({
728
+ [OUTER_STATE.CONTINUE_CURRENT_WAIT]: ROUTING_OUTCOME.CONTINUE_CURRENT_WAIT,
729
+ [OUTER_STATE.HANDOFF_TO_COPILOT_LOOP]: ROUTING_OUTCOME.HANDOFF_TO_COPILOT_LOOP,
730
+ [OUTER_STATE.HANDOFF_TO_REVIEWER_LOOP]: ROUTING_OUTCOME.HANDOFF_TO_REVIEWER_LOOP,
731
+ [OUTER_STATE.STAY_WITH_CURRENT_LIVE_OWNER]: ROUTING_OUTCOME.STAY_WITH_CURRENT_LIVE_OWNER,
732
+ [OUTER_STATE.STOP_NEEDS_HUMAN]: ROUTING_OUTCOME.STOP_NEEDS_HUMAN,
733
+ [OUTER_STATE.DONE_TERMINAL]: ROUTING_OUTCOME.DONE_TERMINAL,
734
+ [OUTER_STATE.NEEDS_RECONCILE]: ROUTING_OUTCOME.NEEDS_RECONCILE,
735
+ });
736
+
737
+ export const OUTER_NEXT_ACTIONS = Object.freeze({
738
+ [OUTER_STATE.CONTINUE_CURRENT_WAIT]: "Remain in outer wait and re-inspect after the bounded interval.",
739
+ [OUTER_STATE.HANDOFF_TO_COPILOT_LOOP]: "Re-enter the Copilot loop.",
740
+ [OUTER_STATE.HANDOFF_TO_REVIEWER_LOOP]: "Re-enter the reviewer loop.",
741
+ [OUTER_STATE.STAY_WITH_CURRENT_LIVE_OWNER]: "Do not issue a new handoff; wait because a live owner is already active.",
742
+ [OUTER_STATE.STOP_NEEDS_HUMAN]: "Stop and require human intervention before continuing.",
743
+ [OUTER_STATE.DONE_TERMINAL]: "End the orchestrator; no further automated action is needed.",
744
+ [OUTER_STATE.NEEDS_RECONCILE]: "Stop and reconcile conflicting or insufficient state before resuming.",
745
+ });
746
+
747
+ export const OUTER_TRANSITIONS = Object.freeze({
748
+ [OUTER_STATE.CONTINUE_CURRENT_WAIT]: ALL_OUTER_STATES,
749
+ [OUTER_STATE.HANDOFF_TO_COPILOT_LOOP]: ALL_OUTER_STATES,
750
+ [OUTER_STATE.HANDOFF_TO_REVIEWER_LOOP]: ALL_OUTER_STATES,
751
+ [OUTER_STATE.STAY_WITH_CURRENT_LIVE_OWNER]: ALL_OUTER_STATES,
752
+ [OUTER_STATE.STOP_NEEDS_HUMAN]: Object.freeze([]),
753
+ [OUTER_STATE.DONE_TERMINAL]: Object.freeze([]),
754
+ [OUTER_STATE.NEEDS_RECONCILE]: Object.freeze([]),
755
+ });
756
+
757
+ export function getAllowedOuterTransitions(state) {
758
+ return Array.isArray(OUTER_TRANSITIONS[state]) ? [...OUTER_TRANSITIONS[state]] : [];
759
+ }
760
+
761
+ function normalizeOuterState(routingOutcome) {
762
+ return OUTER_STATE_SET.has(routingOutcome) ? routingOutcome : OUTER_STATE.NEEDS_RECONCILE;
763
+ }
764
+
765
+ export function isKnownOuterState(value) {
766
+ return OUTER_STATE_SET.has(value);
767
+ }
768
+
769
+ export function interpretOuterLoopState({
770
+ target,
771
+ ownershipState,
772
+ copilotState,
773
+ reviewerState,
774
+ sourceMode,
775
+ requiresLocalIsolation = false,
776
+ routing = null,
777
+ } = {}) {
778
+ const effectiveRouting = routing && typeof routing === "object" && typeof routing.routingOutcome === "string"
779
+ ? routing
780
+ : evaluateConductorRouting({
781
+ target,
782
+ ownershipState,
783
+ copilotState,
784
+ reviewerState,
785
+ sourceMode,
786
+ requiresLocalIsolation,
787
+ });
788
+
789
+ const state = normalizeOuterState(effectiveRouting.routingOutcome);
790
+ const allowedTransitions = getAllowedOuterTransitions(state);
791
+ const nextAction = OUTER_NEXT_ACTIONS[state] ?? OUTER_NEXT_ACTIONS[OUTER_STATE.NEEDS_RECONCILE];
792
+ const isTerminal = OUTER_TERMINAL_STATE_SET.has(state);
793
+
794
+ if (state === OUTER_STATE.NEEDS_RECONCILE && effectiveRouting.routingOutcome !== OUTER_STATE.NEEDS_RECONCILE) {
795
+ return {
796
+ state,
797
+ allowedTransitions: [],
798
+ nextAction,
799
+ isTerminal,
800
+ routingOutcome: OUTER_STATE.NEEDS_RECONCILE,
801
+ outerAction: "stop",
802
+ stopReason: STOP_REASON.UNKNOWN_STATE,
803
+ handoffEnvelope: effectiveRouting.handoffEnvelope,
804
+ };
805
+ }
806
+
807
+ return {
808
+ state,
809
+ allowedTransitions,
810
+ nextAction,
811
+ isTerminal,
812
+ routingOutcome: effectiveRouting.routingOutcome,
813
+ outerAction: effectiveRouting.outerAction,
814
+ stopReason: effectiveRouting.stopReason,
815
+ handoffEnvelope: effectiveRouting.handoffEnvelope,
816
+ };
817
+ }