@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,793 @@
1
+ /**
2
+ * Deterministic mid-flight operator steering contract for active dev loops.
3
+ *
4
+ * This module provides:
5
+ * - STEERING_KIND: stable steering kind constants
6
+ * - STEERING_RESULT: acknowledgement/result constants
7
+ * - SAFE_POINT_CATEGORY: safe-point classification constants
8
+ * - normalizeSteeringEvent: validate and canonicalize a raw steering event
9
+ * - normalizeSteeringState: load and validate persisted steering state
10
+ * - createSteeringState: create a fresh steering state for a new run
11
+ * - classifySafePoint: map a copilot loop state to a safe-point category
12
+ * - submitSteering: process a steering event against current run state
13
+ * - promoteQueuedSteering: apply queued steering when the loop reaches a safe point
14
+ * - getEffectiveConstraints: get the current effective steering constraints
15
+ * - resolveEffectiveLoopState: get loop interpretation augmented with active steering
16
+ * - getSteeringStatus: get full inspection output for a run's steering state
17
+ *
18
+ * The proving target for this first implementation slice is the async Copilot
19
+ * review/fix loop (copilot-loop-state.mjs). Safe-point rules are defined for
20
+ * that loop's state set and the resolveEffectiveLoopState integration changes
21
+ * loop behavior when stop_at_next_safe_gate steering is active.
22
+ */
23
+
24
+ import { normalizeRepoSlug } from "../github/repo-slug.mjs";
25
+ import { STATE, interpretLoopState } from "./copilot-loop-state.mjs";
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Constants
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /** Steering kind constants. */
32
+ export const STEERING_KIND = Object.freeze({
33
+ /** A hard requirement that must be respected by subsequent steps. */
34
+ HARD_CONSTRAINT: "hard_constraint",
35
+ /** A preference that should be followed but is not required. */
36
+ PREFERENCE: "preference",
37
+ /** A clarification that does not change requirements but affects interpretation. */
38
+ CLARIFICATION: "clarification",
39
+ /** Request the loop to stop at the next safe approval/mutation gate. */
40
+ STOP_AT_NEXT_SAFE_GATE: "stop_at_next_safe_gate",
41
+ });
42
+
43
+ /** Acknowledgement/result constants for steering events. */
44
+ export const STEERING_RESULT = Object.freeze({
45
+ /** Steering was applied immediately to the current run state. */
46
+ APPLIED_NOW: "applied_now",
47
+ /** Steering was queued; will be applied when the loop reaches the next safe point. */
48
+ QUEUED_FOR_SAFE_POINT: "queued_for_safe_point",
49
+ /** Steering was rejected because applying it now would be unsafe. */
50
+ REJECTED_UNSAFE_NOW: "rejected_unsafe_now",
51
+ /** Steering was rejected because it is invalid, malformed, or conflicts with existing steering. */
52
+ REJECTED_INVALID_OR_CONFLICTING: "rejected_invalid_or_conflicting",
53
+ /** Steering cannot be resolved automatically and requires human decision. */
54
+ NEEDS_HUMAN_DECISION: "needs_human_decision",
55
+ });
56
+
57
+ /** Safe-point category constants for loop states. */
58
+ export const SAFE_POINT_CATEGORY = Object.freeze({
59
+ /** Steering can be applied immediately. */
60
+ IMMEDIATE: "immediate",
61
+ /** Steering must be queued for the next safe point. */
62
+ NEXT_POINT: "next_point",
63
+ /** Steering is rejected because the loop is in a terminal or error state. */
64
+ TERMINAL: "terminal",
65
+ });
66
+
67
+ const CURRENT_SCHEMA_VERSION = 1;
68
+ const VALID_STEERING_KINDS = new Set(Object.values(STEERING_KIND));
69
+ const VALID_STEERING_RESULTS = new Set(Object.values(STEERING_RESULT));
70
+ const VALID_APPLY_MODES = new Set(["immediate", "next_safe_point"]);
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Safe-point classification
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /**
77
+ * Map a copilot loop state to a safe-point category.
78
+ *
79
+ * Safe-point rules for the async Copilot review/fix loop:
80
+ *
81
+ * IMMEDIATE — between steps / idle / waiting on external state.
82
+ * Steering can be applied right now without risk of splitting a mutation.
83
+ * States: pr_ready_no_feedback, waiting_for_copilot_review,
84
+ * waiting_for_ci, ready_to_rerequest_review
85
+ *
86
+ * NEXT_POINT — actively computing or in a non-interruptible mutation.
87
+ * Applying steering now could produce a half-applied or inconsistent state.
88
+ * Queue the event and promote it when the loop next reaches an IMMEDIATE state.
89
+ * States: pr_draft, unresolved_feedback_present, already_fixed_needs_reply_resolve
90
+ *
91
+ * TERMINAL — run is done, irreversibly failed, or has no active run.
92
+ * Steering is rejected; it would have no effect or could mask a real error.
93
+ * States: no_pr, done, review_request_unavailable, blocked_needs_user_decision
94
+ *
95
+ * @param {string} loopState - a copilot loop STATE value
96
+ * @returns {"immediate"|"next_point"|"terminal"}
97
+ */
98
+ export function classifySafePoint(loopState) {
99
+ switch (loopState) {
100
+ // Between steps / idle
101
+ case STATE.PR_READY_NO_FEEDBACK:
102
+ case STATE.READY_TO_REREQUEST_REVIEW:
103
+ // Waiting on external state
104
+ case STATE.WAITING_FOR_COPILOT_REVIEW:
105
+ case STATE.WAITING_FOR_CI:
106
+ return SAFE_POINT_CATEGORY.IMMEDIATE;
107
+
108
+ // In a pre-ready state (not yet at a mutation gate)
109
+ case STATE.PR_DRAFT:
110
+ // Actively computing — about to apply fixes to resolve feedback
111
+ case STATE.UNRESOLVED_FEEDBACK_PRESENT:
112
+ // In the middle of a non-interruptible mutation (reply/resolve review threads)
113
+ case STATE.ALREADY_FIXED_NEEDS_REPLY_RESOLVE:
114
+ return SAFE_POINT_CATEGORY.NEXT_POINT;
115
+
116
+ // Terminal states: run is done, error, or has no active run
117
+ case STATE.NO_PR:
118
+ case STATE.DONE:
119
+ case STATE.REVIEW_REQUEST_UNAVAILABLE:
120
+ case STATE.BLOCKED_NEEDS_USER_DECISION:
121
+ default:
122
+ return SAFE_POINT_CATEGORY.TERMINAL;
123
+ }
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Schema normalization
128
+ // ---------------------------------------------------------------------------
129
+
130
+ /**
131
+ * Normalize a raw steering event into a validated, canonical shape.
132
+ *
133
+ * Schema:
134
+ * - eventId {string} — unique identifier for this event
135
+ * - runId {string} — target run identity
136
+ * - kind {"hard_constraint"|"preference"|"clarification"|"stop_at_next_safe_gate"}
137
+ * - directive {string} — operator payload / directive text
138
+ * - submittedAt {string} — ISO 8601 timestamp
139
+ * - seq {number} — positive integer; monotonically increasing for durable ordering
140
+ * - applyMode {"immediate"|"next_safe_point"} — default "immediate"
141
+ *
142
+ * @param {object} raw
143
+ * @returns {object} normalized event
144
+ * @throws {Error} if required fields are missing or invalid
145
+ */
146
+ export function normalizeSteeringEvent(raw) {
147
+ if (!raw || typeof raw !== "object") {
148
+ throw new Error("Steering event must be a non-null object");
149
+ }
150
+
151
+ const eventId = typeof raw.eventId === "string" && raw.eventId.trim().length > 0
152
+ ? raw.eventId.trim()
153
+ : null;
154
+ if (!eventId) {
155
+ throw new Error("Steering event requires a non-empty eventId");
156
+ }
157
+
158
+ const runId = typeof raw.runId === "string" && raw.runId.trim().length > 0
159
+ ? raw.runId.trim()
160
+ : null;
161
+ if (!runId) {
162
+ throw new Error("Steering event requires a non-empty runId");
163
+ }
164
+
165
+ const kind = VALID_STEERING_KINDS.has(raw.kind) ? raw.kind : null;
166
+ if (!kind) {
167
+ throw new Error(`Steering event kind must be one of: ${[...VALID_STEERING_KINDS].join(", ")}`);
168
+ }
169
+
170
+ const directive = typeof raw.directive === "string" && raw.directive.trim().length > 0
171
+ ? raw.directive.trim()
172
+ : null;
173
+ if (!directive) {
174
+ throw new Error("Steering event requires a non-empty directive");
175
+ }
176
+
177
+ const submittedAt = typeof raw.submittedAt === "string" && raw.submittedAt.trim().length > 0
178
+ ? raw.submittedAt.trim()
179
+ : new Date().toISOString();
180
+
181
+ const seq = typeof raw.seq === "number" && Number.isFinite(raw.seq) && raw.seq > 0
182
+ ? Math.floor(raw.seq)
183
+ : null;
184
+ if (seq === null) {
185
+ throw new Error("Steering event requires a positive integer seq");
186
+ }
187
+
188
+ const applyMode = VALID_APPLY_MODES.has(raw.applyMode) ? raw.applyMode : "immediate";
189
+
190
+ return { eventId, runId, kind, directive, submittedAt, seq, applyMode };
191
+ }
192
+
193
+ /**
194
+ * Normalize a raw steering state object into a validated, canonical shape.
195
+ *
196
+ * Durable steering state schema:
197
+ * - runId {string} — identifies the target run
198
+ * - schemaVersion {1} — version discriminator for future migration
199
+ * - events {object[]} — ordered log of all submitted events
200
+ * - effectiveStack {object[]} — events currently in effect
201
+ * - queuedEvents {object[]} — events waiting for the next safe point
202
+ * - resultHistory {object[]} — ordered log of all acknowledgement results
203
+ * - latestResult {object|null} — most recent acknowledgement result
204
+ * - nextSeq {number} — next expected seq value for ordering validation
205
+ *
206
+ * @param {object} raw
207
+ * @returns {object} normalized steering state
208
+ * @throws {Error} if required fields are missing
209
+ */
210
+ function normalizeSteeringTarget(raw) {
211
+ if (raw === null || raw === undefined) {
212
+ return null;
213
+ }
214
+ if (!raw || typeof raw !== "object") {
215
+ throw new Error("target must be an object when present");
216
+ }
217
+
218
+ const repo = normalizeRepoSlug(raw.repo, {
219
+ errorMessage: "target.repo must be a non-empty owner/name repo slug",
220
+ });
221
+
222
+ const pr = typeof raw.pr === "number" && Number.isFinite(raw.pr) && raw.pr > 0
223
+ ? Math.floor(raw.pr)
224
+ : null;
225
+ if (pr === null) {
226
+ throw new Error("target.pr must be a positive integer");
227
+ }
228
+
229
+ return { repo, pr };
230
+ }
231
+
232
+ function normalizeResultEntry(raw, fieldName) {
233
+ if (!raw || typeof raw !== "object") {
234
+ throw new Error(`${fieldName} entry must be an object`);
235
+ }
236
+
237
+ const eventId = typeof raw.eventId === "string" && raw.eventId.trim().length > 0
238
+ ? raw.eventId.trim()
239
+ : null;
240
+ if (!eventId) {
241
+ throw new Error(`${fieldName} entry requires a non-empty eventId`);
242
+ }
243
+
244
+ const seq = typeof raw.seq === "number" && Number.isFinite(raw.seq) && raw.seq > 0
245
+ ? Math.floor(raw.seq)
246
+ : null;
247
+ if (seq === null) {
248
+ throw new Error(`${fieldName} entry requires a positive integer seq`);
249
+ }
250
+
251
+ const result = VALID_STEERING_RESULTS.has(raw.result) ? raw.result : null;
252
+ if (!result) {
253
+ throw new Error(`${fieldName} entry result must be one of: ${[...VALID_STEERING_RESULTS].join(", ")}`);
254
+ }
255
+
256
+ const acknowledgedAt = typeof raw.acknowledgedAt === "string" && raw.acknowledgedAt.trim().length > 0
257
+ ? raw.acknowledgedAt.trim()
258
+ : null;
259
+ if (!acknowledgedAt) {
260
+ throw new Error(`${fieldName} entry requires a non-empty acknowledgedAt timestamp`);
261
+ }
262
+
263
+ let reason = null;
264
+ if (raw.reason !== null && raw.reason !== undefined) {
265
+ if (typeof raw.reason !== "string") {
266
+ throw new Error(`${fieldName} entry reason must be a string or null`);
267
+ }
268
+ reason = raw.reason;
269
+ }
270
+
271
+ return { eventId, seq, result, reason, acknowledgedAt };
272
+ }
273
+
274
+ function normalizeEventList(rawList, fieldName) {
275
+ if (!Array.isArray(rawList)) {
276
+ return [];
277
+ }
278
+ return rawList.map((entry, index) => {
279
+ try {
280
+ return normalizeSteeringEvent(entry);
281
+ } catch (error) {
282
+ const detail = error instanceof Error ? error.message : String(error);
283
+ throw new Error(`${fieldName}[${index}] is invalid: ${detail}`);
284
+ }
285
+ });
286
+ }
287
+
288
+ function normalizeResultList(rawList, fieldName) {
289
+ if (!Array.isArray(rawList)) {
290
+ return [];
291
+ }
292
+ return rawList.map((entry, index) => {
293
+ try {
294
+ return normalizeResultEntry(entry, `${fieldName}[${index}]`);
295
+ } catch (error) {
296
+ const detail = error instanceof Error ? error.message : String(error);
297
+ throw new Error(detail);
298
+ }
299
+ });
300
+ }
301
+
302
+ export function normalizeSteeringState(raw) {
303
+ if (!raw || typeof raw !== "object") {
304
+ throw new Error("Steering state must be a non-null object");
305
+ }
306
+
307
+ const runId = typeof raw.runId === "string" && raw.runId.trim().length > 0
308
+ ? raw.runId.trim()
309
+ : null;
310
+ if (!runId) {
311
+ throw new Error("Steering state requires a non-empty runId");
312
+ }
313
+
314
+ if (raw.schemaVersion !== undefined && raw.schemaVersion !== CURRENT_SCHEMA_VERSION) {
315
+ throw new Error(`Unsupported steering state schemaVersion '${raw.schemaVersion}'; expected ${CURRENT_SCHEMA_VERSION}`);
316
+ }
317
+
318
+ const events = normalizeEventList(raw.events, "events");
319
+ const effectiveStack = normalizeEventList(raw.effectiveStack, "effectiveStack");
320
+ const queuedEvents = normalizeEventList(raw.queuedEvents, "queuedEvents");
321
+ const resultHistory = normalizeResultList(raw.resultHistory, "resultHistory");
322
+
323
+ let latestResult = null;
324
+ if (raw.latestResult !== null && raw.latestResult !== undefined) {
325
+ latestResult = normalizeResultEntry(raw.latestResult, "latestResult");
326
+ }
327
+
328
+ let target = null;
329
+ if (raw.target !== undefined) {
330
+ try {
331
+ target = normalizeSteeringTarget(raw.target);
332
+ } catch (error) {
333
+ const detail = error instanceof Error ? error.message : String(error);
334
+ throw new Error(`target is invalid: ${detail}`);
335
+ }
336
+ }
337
+
338
+ return {
339
+ runId,
340
+ schemaVersion: CURRENT_SCHEMA_VERSION,
341
+ target,
342
+ events,
343
+ effectiveStack,
344
+ queuedEvents,
345
+ resultHistory,
346
+ latestResult,
347
+ nextSeq: typeof raw.nextSeq === "number" && Number.isFinite(raw.nextSeq) && raw.nextSeq > 0
348
+ ? Math.floor(raw.nextSeq)
349
+ : 1,
350
+ };
351
+ }
352
+
353
+ /**
354
+ * Create a fresh steering state for a new run.
355
+ *
356
+ * @param {string} runId
357
+ * @param {{repo:string,pr:number}|null} [target]
358
+ * @returns {object}
359
+ */
360
+ export function createSteeringState(runId, target = null) {
361
+ if (typeof runId !== "string" || runId.trim().length === 0) {
362
+ throw new Error("createSteeringState requires a non-empty runId");
363
+ }
364
+ return {
365
+ runId: runId.trim(),
366
+ schemaVersion: CURRENT_SCHEMA_VERSION,
367
+ target: target ? normalizeSteeringTarget(target) : null,
368
+ events: [],
369
+ effectiveStack: [],
370
+ queuedEvents: [],
371
+ resultHistory: [],
372
+ latestResult: null,
373
+ nextSeq: 1,
374
+ };
375
+ }
376
+
377
+ // ---------------------------------------------------------------------------
378
+ // Conflict detection
379
+ // ---------------------------------------------------------------------------
380
+
381
+ /**
382
+ * Check if a new steering event conflicts with the existing effective stack.
383
+ *
384
+ * Conflict rules for v1:
385
+ * - Exact duplicate hard_constraint directives (case-insensitive) are rejected.
386
+ * Two hard constraints with different content are allowed (additive stacking).
387
+ *
388
+ * @param {object} event - normalized steering event
389
+ * @param {object[]} effectiveStack - current effective steering events
390
+ * @param {object[]} queuedEvents - current queued steering events
391
+ * @returns {string|null} conflict reason string, or null if no conflict
392
+ */
393
+ function detectConflict(event, effectiveStack, queuedEvents = []) {
394
+ if (event.kind !== STEERING_KIND.HARD_CONSTRAINT) {
395
+ return null;
396
+ }
397
+
398
+ const existingEvents = [...effectiveStack, ...queuedEvents];
399
+ for (const existing of existingEvents) {
400
+ if (
401
+ existing.kind === STEERING_KIND.HARD_CONSTRAINT
402
+ && existing.directive.toLowerCase() === event.directive.toLowerCase()
403
+ ) {
404
+ const location = effectiveStack.includes(existing) ? "effective stack" : "queued events";
405
+ return `Duplicate hard_constraint directive already present in ${location} (seq ${existing.seq})`;
406
+ }
407
+ }
408
+ return null;
409
+ }
410
+
411
+ function hasStopAtNextSafeGate(events) {
412
+ return events.some((event) => event.kind === STEERING_KIND.STOP_AT_NEXT_SAFE_GATE);
413
+ }
414
+
415
+ // ---------------------------------------------------------------------------
416
+ // Steering submission
417
+ // ---------------------------------------------------------------------------
418
+
419
+ /**
420
+ * Process a steering event against current run state and produce an
421
+ * acknowledgement/result plus updated steering state.
422
+ *
423
+ * This is the main entry point for operators submitting mid-flight corrections.
424
+ *
425
+ * Result semantics:
426
+ * - applied_now: event is immediately effective; effectiveStack is updated.
427
+ * - queued_for_safe_point: loop is in a non-safe state; event is queued and
428
+ * will be promoted by promoteQueuedSteering when the loop reaches a safe point.
429
+ * - rejected_unsafe_now: terminal loop state (done/unavailable/no_pr); steering
430
+ * would have no effect or could mask a real issue.
431
+ * - rejected_invalid_or_conflicting: event is malformed, has an out-of-order seq,
432
+ * or exactly duplicates an existing hard_constraint.
433
+ * - needs_human_decision: loop is in blocked_needs_user_decision; human must act
434
+ * before automated steering can be safely applied.
435
+ *
436
+ * @param {object} event - normalized steering event (from normalizeSteeringEvent)
437
+ * @param {object} steeringState - current steering state (from normalizeSteeringState)
438
+ * @param {string} loopState - current copilot loop state (a STATE constant value)
439
+ * @returns {{ steeringState: object, result: object }}
440
+ */
441
+ export function submitSteering(event, steeringState, loopState) {
442
+ const acknowledgedAt = new Date().toISOString();
443
+
444
+ // Validate seq ordering
445
+ if (event.seq < steeringState.nextSeq) {
446
+ const ackResult = {
447
+ eventId: event.eventId,
448
+ seq: event.seq,
449
+ result: STEERING_RESULT.REJECTED_INVALID_OR_CONFLICTING,
450
+ reason: `Sequence number ${event.seq} is out of order; expected >= ${steeringState.nextSeq}`,
451
+ acknowledgedAt,
452
+ };
453
+ return {
454
+ steeringState: {
455
+ ...steeringState,
456
+ latestResult: ackResult,
457
+ resultHistory: [...steeringState.resultHistory, ackResult],
458
+ },
459
+ result: ackResult,
460
+ };
461
+ }
462
+
463
+ // Check for conflicts with existing effective stack
464
+ const conflictReason = detectConflict(event, steeringState.effectiveStack, steeringState.queuedEvents);
465
+ if (conflictReason) {
466
+ const ackResult = {
467
+ eventId: event.eventId,
468
+ seq: event.seq,
469
+ result: STEERING_RESULT.REJECTED_INVALID_OR_CONFLICTING,
470
+ reason: conflictReason,
471
+ acknowledgedAt,
472
+ };
473
+ return {
474
+ steeringState: {
475
+ ...steeringState,
476
+ latestResult: ackResult,
477
+ resultHistory: [...steeringState.resultHistory, ackResult],
478
+ events: [...steeringState.events, event],
479
+ nextSeq: Math.max(steeringState.nextSeq, event.seq + 1),
480
+ },
481
+ result: ackResult,
482
+ };
483
+ }
484
+
485
+ const safePointCategory = classifySafePoint(loopState);
486
+
487
+ if (event.kind === STEERING_KIND.STOP_AT_NEXT_SAFE_GATE) {
488
+ if (hasStopAtNextSafeGate(steeringState.effectiveStack)) {
489
+ const ackResult = {
490
+ eventId: event.eventId,
491
+ seq: event.seq,
492
+ result: STEERING_RESULT.APPLIED_NOW,
493
+ reason: "stop_at_next_safe_gate is already effective for this run",
494
+ acknowledgedAt,
495
+ };
496
+ return {
497
+ steeringState: {
498
+ ...steeringState,
499
+ latestResult: ackResult,
500
+ resultHistory: [...steeringState.resultHistory, ackResult],
501
+ events: [...steeringState.events, event],
502
+ nextSeq: Math.max(steeringState.nextSeq, event.seq + 1),
503
+ },
504
+ result: ackResult,
505
+ };
506
+ }
507
+
508
+ if (hasStopAtNextSafeGate(steeringState.queuedEvents)) {
509
+ const ackResult = {
510
+ eventId: event.eventId,
511
+ seq: event.seq,
512
+ result: STEERING_RESULT.QUEUED_FOR_SAFE_POINT,
513
+ reason: "stop_at_next_safe_gate is already queued for the next safe point",
514
+ acknowledgedAt,
515
+ };
516
+ return {
517
+ steeringState: {
518
+ ...steeringState,
519
+ latestResult: ackResult,
520
+ resultHistory: [...steeringState.resultHistory, ackResult],
521
+ events: [...steeringState.events, event],
522
+ nextSeq: Math.max(steeringState.nextSeq, event.seq + 1),
523
+ },
524
+ result: ackResult,
525
+ };
526
+ }
527
+ }
528
+
529
+ // Terminal loop states: reject or route to human decision
530
+ if (safePointCategory === SAFE_POINT_CATEGORY.TERMINAL) {
531
+ let result;
532
+ let reason;
533
+
534
+ if (loopState === STATE.BLOCKED_NEEDS_USER_DECISION) {
535
+ result = STEERING_RESULT.NEEDS_HUMAN_DECISION;
536
+ reason = "Loop is in blocked_needs_user_decision; human decision is required before steering can be applied";
537
+ } else if (loopState === STATE.DONE) {
538
+ result = STEERING_RESULT.REJECTED_UNSAFE_NOW;
539
+ reason = "Loop run is already complete (done); steering has no effect";
540
+ } else if (loopState === STATE.REVIEW_REQUEST_UNAVAILABLE) {
541
+ result = STEERING_RESULT.REJECTED_UNSAFE_NOW;
542
+ reason = "Loop is in review_request_unavailable terminal state; steering cannot be applied";
543
+ } else {
544
+ result = STEERING_RESULT.REJECTED_UNSAFE_NOW;
545
+ reason = `Loop state '${loopState}' does not have an active run to steer`;
546
+ }
547
+
548
+ const ackResult = { eventId: event.eventId, seq: event.seq, result, reason, acknowledgedAt };
549
+ return {
550
+ steeringState: {
551
+ ...steeringState,
552
+ latestResult: ackResult,
553
+ resultHistory: [...steeringState.resultHistory, ackResult],
554
+ events: [...steeringState.events, event],
555
+ nextSeq: Math.max(steeringState.nextSeq, event.seq + 1),
556
+ },
557
+ result: ackResult,
558
+ };
559
+ }
560
+
561
+ // Non-safe states or caller-requested deferred application: queue the event
562
+ if (safePointCategory === SAFE_POINT_CATEGORY.NEXT_POINT || event.applyMode === "next_safe_point") {
563
+ const ackResult = {
564
+ eventId: event.eventId,
565
+ seq: event.seq,
566
+ result: STEERING_RESULT.QUEUED_FOR_SAFE_POINT,
567
+ reason: `Loop is in '${loopState}' (not a safe point for immediate application); steering queued for next safe point`,
568
+ acknowledgedAt,
569
+ };
570
+ return {
571
+ steeringState: {
572
+ ...steeringState,
573
+ latestResult: ackResult,
574
+ resultHistory: [...steeringState.resultHistory, ackResult],
575
+ events: [...steeringState.events, event],
576
+ queuedEvents: [...steeringState.queuedEvents, event],
577
+ nextSeq: Math.max(steeringState.nextSeq, event.seq + 1),
578
+ },
579
+ result: ackResult,
580
+ };
581
+ }
582
+
583
+ // Safe point and immediate mode: apply now
584
+ const ackResult = {
585
+ eventId: event.eventId,
586
+ seq: event.seq,
587
+ result: STEERING_RESULT.APPLIED_NOW,
588
+ reason: null,
589
+ acknowledgedAt,
590
+ };
591
+ return {
592
+ steeringState: {
593
+ ...steeringState,
594
+ latestResult: ackResult,
595
+ resultHistory: [...steeringState.resultHistory, ackResult],
596
+ events: [...steeringState.events, event],
597
+ effectiveStack: [...steeringState.effectiveStack, event],
598
+ nextSeq: Math.max(steeringState.nextSeq, event.seq + 1),
599
+ },
600
+ result: ackResult,
601
+ };
602
+ }
603
+
604
+ // ---------------------------------------------------------------------------
605
+ // Queued steering promotion
606
+ // ---------------------------------------------------------------------------
607
+
608
+ /**
609
+ * Promote queued steering events to the effective stack when the loop reaches
610
+ * a safe point.
611
+ *
612
+ * Call this whenever the loop transitions to a new state. If the new state is
613
+ * an IMMEDIATE safe point and there are queued events, they are moved to the
614
+ * effective stack and their results updated to applied_now.
615
+ *
616
+ * @param {object} steeringState
617
+ * @param {string} loopState - current loop state after the transition
618
+ * @returns {{ steeringState: object, promoted: object[] }}
619
+ */
620
+ export function promoteQueuedSteering(steeringState, loopState) {
621
+ if (steeringState.queuedEvents.length === 0) {
622
+ return { steeringState, promoted: [] };
623
+ }
624
+
625
+ const category = classifySafePoint(loopState);
626
+ if (category !== SAFE_POINT_CATEGORY.IMMEDIATE) {
627
+ return { steeringState, promoted: [] };
628
+ }
629
+
630
+ const promoted = [...steeringState.queuedEvents];
631
+ const now = new Date().toISOString();
632
+
633
+ const newResults = promoted.map((event) => ({
634
+ eventId: event.eventId,
635
+ seq: event.seq,
636
+ result: STEERING_RESULT.APPLIED_NOW,
637
+ reason: `Promoted from queue at safe point '${loopState}'`,
638
+ acknowledgedAt: now,
639
+ }));
640
+
641
+ const updatedState = {
642
+ ...steeringState,
643
+ queuedEvents: [],
644
+ effectiveStack: [...steeringState.effectiveStack, ...promoted],
645
+ resultHistory: [...steeringState.resultHistory, ...newResults],
646
+ latestResult: newResults.length > 0 ? newResults[newResults.length - 1] : steeringState.latestResult,
647
+ };
648
+
649
+ return { steeringState: updatedState, promoted };
650
+ }
651
+
652
+ // ---------------------------------------------------------------------------
653
+ // Effective constraints query
654
+ // ---------------------------------------------------------------------------
655
+
656
+ /**
657
+ * Get the current effective steering constraints for a run.
658
+ *
659
+ * Returns a structured view over the effective stack, split by kind.
660
+ *
661
+ * @param {object} steeringState
662
+ * @returns {{ hardConstraints: string[], preferences: string[], clarifications: string[], stopAtNextSafeGate: boolean, unknownConstraints: object[] }}
663
+ */
664
+ export function getEffectiveConstraints(steeringState) {
665
+ const hardConstraints = [];
666
+ const preferences = [];
667
+ const clarifications = [];
668
+ const unknownConstraints = [];
669
+ let stopAtNextSafeGate = false;
670
+
671
+ for (const event of steeringState.effectiveStack) {
672
+ switch (event.kind) {
673
+ case STEERING_KIND.HARD_CONSTRAINT:
674
+ hardConstraints.push(event.directive);
675
+ break;
676
+ case STEERING_KIND.PREFERENCE:
677
+ preferences.push(event.directive);
678
+ break;
679
+ case STEERING_KIND.CLARIFICATION:
680
+ clarifications.push(event.directive);
681
+ break;
682
+ case STEERING_KIND.STOP_AT_NEXT_SAFE_GATE:
683
+ stopAtNextSafeGate = true;
684
+ break;
685
+ default:
686
+ unknownConstraints.push({
687
+ kind: event.kind,
688
+ directive: event.directive,
689
+ seq: event.seq,
690
+ });
691
+ break;
692
+ }
693
+ }
694
+
695
+ return { hardConstraints, preferences, clarifications, stopAtNextSafeGate, unknownConstraints };
696
+ }
697
+
698
+ // ---------------------------------------------------------------------------
699
+ // Loop state augmentation with effective steering
700
+ // ---------------------------------------------------------------------------
701
+
702
+ /**
703
+ * Get the loop interpretation augmented with any active steering directives.
704
+ *
705
+ * This is the main integration point between the steering contract and the
706
+ * async Copilot review/fix loop (the first proving target). Call this instead
707
+ * of interpretLoopState when the run may have active steering state.
708
+ *
709
+ * Behavioral change applied by this function:
710
+ * - When stop_at_next_safe_gate is effective and the loop is at an IMMEDIATE
711
+ * safe point, the nextAction is overridden to direct the loop to stop rather
712
+ * than continue to the next step.
713
+ *
714
+ * The steeringApplied flag lets callers know whether active steering changed
715
+ * the default interpretation. The effectiveConstraints field exposes all
716
+ * current steering for downstream use (e.g. injecting hard constraints into
717
+ * agent context before a fix step).
718
+ *
719
+ * @param {object} snapshot - raw or normalized loop snapshot
720
+ * @param {object} steeringState - current steering state for this run
721
+ * @returns {{ state: string, allowedTransitions: string[], nextAction: string, steeringApplied: boolean, pendingStopAtNextSafeGate: boolean, terminalStopAtNextSafeGate: boolean, effectiveConstraints: object }}
722
+ */
723
+ export function resolveEffectiveLoopState(snapshot, steeringState) {
724
+ const base = interpretLoopState(snapshot);
725
+ const constraints = getEffectiveConstraints(steeringState);
726
+ const category = classifySafePoint(base.state);
727
+
728
+ const steeringApplied = constraints.stopAtNextSafeGate
729
+ || constraints.hardConstraints.length > 0
730
+ || constraints.preferences.length > 0
731
+ || constraints.clarifications.length > 0
732
+ || constraints.unknownConstraints.length > 0;
733
+ const pendingStopAtNextSafeGate = constraints.stopAtNextSafeGate && category === SAFE_POINT_CATEGORY.NEXT_POINT;
734
+ const terminalStopAtNextSafeGate = constraints.stopAtNextSafeGate && category === SAFE_POINT_CATEGORY.TERMINAL;
735
+
736
+ let nextAction = base.nextAction;
737
+
738
+ if (constraints.stopAtNextSafeGate && category === SAFE_POINT_CATEGORY.IMMEDIATE) {
739
+ nextAction = "Stop at this safe gate: a stop_at_next_safe_gate steering directive is active. Do not proceed to the next loop step.";
740
+ } else if (pendingStopAtNextSafeGate) {
741
+ nextAction = `Pending stop_at_next_safe_gate: stop at the next safe gate. Until then, current state remains '${base.state}' and the immediate action is: ${base.nextAction}`;
742
+ } else if (terminalStopAtNextSafeGate) {
743
+ nextAction = `stop_at_next_safe_gate is currently inactive because the loop is in terminal state '${base.state}'. Current action remains: ${base.nextAction}`;
744
+ }
745
+
746
+ return {
747
+ ...base,
748
+ nextAction,
749
+ steeringApplied,
750
+ pendingStopAtNextSafeGate,
751
+ terminalStopAtNextSafeGate,
752
+ effectiveConstraints: constraints,
753
+ };
754
+ }
755
+
756
+ // ---------------------------------------------------------------------------
757
+ // Status/inspection output
758
+ // ---------------------------------------------------------------------------
759
+
760
+ /**
761
+ * Get full inspection output for a run's steering state.
762
+ *
763
+ * Returns:
764
+ * - runId: the target run
765
+ * - schemaVersion: for migration awareness
766
+ * - eventCount: total events submitted
767
+ * - queuedCount: events waiting for the next safe point
768
+ * - effectiveStackCount: events currently in effect
769
+ * - effectiveConstraints: structured view over the effective stack
770
+ * - latestResult: most recent acknowledgement/result
771
+ * - resultHistory: all historical acknowledgements
772
+ * - history: all submitted events
773
+ * - nextSeq: next expected sequence number
774
+ *
775
+ * @param {object} steeringState
776
+ * @returns {object}
777
+ */
778
+ export function getSteeringStatus(steeringState) {
779
+ const effectiveConstraints = getEffectiveConstraints(steeringState);
780
+ return {
781
+ runId: steeringState.runId,
782
+ ...(steeringState.target ? { target: steeringState.target } : {}),
783
+ schemaVersion: steeringState.schemaVersion,
784
+ eventCount: steeringState.events.length,
785
+ queuedCount: steeringState.queuedEvents.length,
786
+ effectiveStackCount: steeringState.effectiveStack.length,
787
+ effectiveConstraints,
788
+ latestResult: steeringState.latestResult,
789
+ resultHistory: steeringState.resultHistory,
790
+ history: steeringState.events,
791
+ nextSeq: steeringState.nextSeq,
792
+ };
793
+ }