@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,800 @@
1
+ /**
2
+ * Deterministic handoff envelope — machine-generated JSON contract for
3
+ * `dev-loop` subagent dispatch.
4
+ *
5
+ * Replaces dispatch prose with a purely derived envelope from three
6
+ * authoritative sources:
7
+ * 1. Resolver output (bundle) → target, gate, nextAction, requiredReads, executionMode
8
+ * 2. Settings (DevLoopConfig) → gateConfig, stopRules, asyncStartMode, requireDraftFirst, maxCopilotRounds
9
+ * 3. Gate state (detectors) → head SHA, CI status, thread count, round count
10
+ *
11
+ * Acceptance criteria, evidence lists, maxFinalizationTurns, and control
12
+ * params are derived from a static strategy+gate mapping table.
13
+ *
14
+ * Unknown strategy/gate combos throw explicit errors.
15
+ */
16
+
17
+ import {
18
+ DEV_LOOP_TARGET_KIND,
19
+ INTERNAL_DEV_LOOP_STRATEGY,
20
+ } from "./public-dev-loop-routing-contract.mjs";
21
+ import { normalizeRepoSlug } from "../github/repo-slug.mjs";
22
+ import { COPILOT_REVIEW_WAIT_TIMEOUT_MS } from "./policy-constants.mjs";
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Constants
26
+ // ---------------------------------------------------------------------------
27
+
28
+ const H_VER = 1;
29
+ const ENVELOPE_HANDOFF_VERSION = H_VER;
30
+
31
+ const WATCH_NEEDS_ATTENTION_MS = COPILOT_REVIEW_WAIT_TIMEOUT_MS; // matches external healthy wait budget (policy-constants)
32
+ const WATCH_ACTIVE_NOTICE_MS = COPILOT_REVIEW_WAIT_TIMEOUT_MS; // matches external healthy wait budget (policy-constants)
33
+ const DEFAULT_NEEDS_ATTENTION_MS = 300_000; // 5 minutes
34
+ const DEFAULT_ACTIVE_NOTICE_MS = 300_000;
35
+
36
+ /** Maps normalized strategy name to its default stop rules */
37
+ const STRATEGY_DEFAULT_STOP_RULES = Object.freeze({
38
+ [INTERNAL_DEV_LOOP_STRATEGY.COPILOT_PR_FOLLOWUP]: ["draft-pr", "merge"],
39
+ [INTERNAL_DEV_LOOP_STRATEGY.ISSUE_INTAKE]: ["merge"],
40
+ [INTERNAL_DEV_LOOP_STRATEGY.EXTERNAL_PR_FOLLOWUP]: ["merge"],
41
+ [INTERNAL_DEV_LOOP_STRATEGY.REVIEWER_FIXER]: ["merge"],
42
+ [INTERNAL_DEV_LOOP_STRATEGY.WAIT_WATCH]: ["merge"],
43
+ [INTERNAL_DEV_LOOP_STRATEGY.FINAL_APPROVAL]: ["merge"],
44
+ [INTERNAL_DEV_LOOP_STRATEGY.LOCAL_IMPLEMENTATION]: [],
45
+ });
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Acceptance template table
49
+ // ---------------------------------------------------------------------------
50
+
51
+ const ACCEPTANCE_TEMPLATES = new Map();
52
+
53
+ function acceptanceKey(strategy, gate) {
54
+ return `${strategy}::${gate}`;
55
+ }
56
+
57
+ function register(strategy, gate, template) {
58
+ ACCEPTANCE_TEMPLATES.set(acceptanceKey(strategy, gate), deepFreeze({ ...template }));
59
+ }
60
+
61
+ // copilot_pr_followup sub-gates
62
+ register(INTERNAL_DEV_LOOP_STRATEGY.COPILOT_PR_FOLLOWUP, "draft", {
63
+ criteria: [
64
+ { id: "ac-check", must: "Verify all acceptance criteria from linked issue are met or tracked.", severity: "required" },
65
+ { id: "scope", must: "Every changed file belongs in this PR; no unrelated or out-of-scope changes.", severity: "required" },
66
+ { id: "coverage", must: "Tests cover changed behavior including edge cases and error paths.", severity: "required" },
67
+ { id: "dod-alignment", must: "Implementation aligns with the issue's definition of done.", severity: "required" },
68
+ ],
69
+ evidence: ["commands-run", "validation-output", "review-findings"],
70
+ maxFinalizationTurns: 4,
71
+ needsAttentionAfterMs: DEFAULT_NEEDS_ATTENTION_MS,
72
+ activeNoticeAfterMs: DEFAULT_ACTIVE_NOTICE_MS,
73
+ });
74
+
75
+ register(INTERNAL_DEV_LOOP_STRATEGY.COPILOT_PR_FOLLOWUP, "watch", {
76
+ criteria: [
77
+ { id: "copilot-activity", must: "Detect new Copilot review activity (comments, threads, review submissions).", severity: "required" },
78
+ { id: "no-stuck-watch", must: "Watch cycle must not stall; timeout or activity triggers follow-up.", severity: "required" },
79
+ ],
80
+ evidence: ["commands-run"],
81
+ maxFinalizationTurns: 2,
82
+ needsAttentionAfterMs: WATCH_NEEDS_ATTENTION_MS,
83
+ activeNoticeAfterMs: WATCH_ACTIVE_NOTICE_MS,
84
+ });
85
+
86
+ register(INTERNAL_DEV_LOOP_STRATEGY.COPILOT_PR_FOLLOWUP, "pre-approval", {
87
+ criteria: [
88
+ { id: "full-gate-chain", must: "Complete pre-approval gate chain with all configured review angles.", severity: "required" },
89
+ { id: "clean-verdict", must: "Pre-approval gate must return clean verdict (no must-fix or worth-fixing-now findings).", severity: "required" },
90
+ { id: "unresolved-threads", must: "All review threads must be resolved before pre-approval gate runs.", severity: "required" },
91
+ { id: "ci-green", must: "CI must be green on the current head SHA.", severity: "required" },
92
+ ],
93
+ evidence: ["commands-run", "validation-output", "review-findings", "residual-risks"],
94
+ maxFinalizationTurns: 6,
95
+ needsAttentionAfterMs: DEFAULT_NEEDS_ATTENTION_MS,
96
+ activeNoticeAfterMs: DEFAULT_ACTIVE_NOTICE_MS,
97
+ });
98
+
99
+ // final_approval
100
+ register(INTERNAL_DEV_LOOP_STRATEGY.FINAL_APPROVAL, "default", {
101
+ criteria: [
102
+ { id: "gate-evidence", must: "All required gate evidence (draft_gate, pre_approval_gate) is present and visible.", severity: "required" },
103
+ { id: "human-confirmation", must: "Human operator must explicitly confirm merge readiness.", severity: "required" },
104
+ { id: "ci-green", must: "CI must be green on the current head SHA.", severity: "required" },
105
+ ],
106
+ evidence: ["validation-output", "manual-notes"],
107
+ maxFinalizationTurns: 2,
108
+ needsAttentionAfterMs: DEFAULT_NEEDS_ATTENTION_MS,
109
+ activeNoticeAfterMs: DEFAULT_ACTIVE_NOTICE_MS,
110
+ });
111
+
112
+ // local_implementation
113
+ register(INTERNAL_DEV_LOOP_STRATEGY.LOCAL_IMPLEMENTATION, "default", {
114
+ criteria: [
115
+ { id: "phase-ac", must: "All phase acceptance criteria from the active phase doc are satisfied.", severity: "required" },
116
+ { id: "verify-green", must: "`npm run verify` passes with no failures.", severity: "required" },
117
+ ],
118
+ evidence: ["commands-run", "validation-output", "changed-files"],
119
+ maxFinalizationTurns: 6,
120
+ needsAttentionAfterMs: DEFAULT_NEEDS_ATTENTION_MS,
121
+ activeNoticeAfterMs: DEFAULT_ACTIVE_NOTICE_MS,
122
+ });
123
+
124
+ // wait_watch — dedicated window matching external healthy wait budget (policy-constants)
125
+ register(INTERNAL_DEV_LOOP_STRATEGY.WAIT_WATCH, "default", {
126
+ criteria: [
127
+ { id: "contract-compliance", must: "Implementation complies with the governing contract and acceptance criteria.", severity: "required" },
128
+ ],
129
+ evidence: ["commands-run", "validation-output"],
130
+ maxFinalizationTurns: 4,
131
+ needsAttentionAfterMs: WATCH_NEEDS_ATTENTION_MS,
132
+ activeNoticeAfterMs: WATCH_ACTIVE_NOTICE_MS,
133
+ });
134
+
135
+ // Remaining strategies get a generic acceptance template
136
+ function registerGeneric(strategy) {
137
+ register(strategy, "default", {
138
+ criteria: [
139
+ { id: "contract-compliance", must: "Implementation complies with the governing contract and acceptance criteria.", severity: "required" },
140
+ ],
141
+ evidence: ["commands-run", "validation-output"],
142
+ maxFinalizationTurns: 4,
143
+ needsAttentionAfterMs: DEFAULT_NEEDS_ATTENTION_MS,
144
+ activeNoticeAfterMs: DEFAULT_ACTIVE_NOTICE_MS,
145
+ });
146
+ }
147
+
148
+ for (const s of [
149
+ INTERNAL_DEV_LOOP_STRATEGY.ISSUE_INTAKE,
150
+ INTERNAL_DEV_LOOP_STRATEGY.EXTERNAL_PR_FOLLOWUP,
151
+ INTERNAL_DEV_LOOP_STRATEGY.REVIEWER_FIXER,
152
+ ]) {
153
+ if (![...ACCEPTANCE_TEMPLATES.keys()].some((k) => k.startsWith(`${s}::`))) {
154
+ registerGeneric(s);
155
+ }
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Normalization helpers
160
+ // ---------------------------------------------------------------------------
161
+
162
+ function normalizeRepo(repo) {
163
+ try {
164
+ return normalizeRepoSlug(repo);
165
+ } catch {
166
+ return null;
167
+ }
168
+ }
169
+
170
+ function normalizeTargetKind(kind) {
171
+ if (typeof kind !== "string") return null;
172
+ const normalized = kind.trim().toLowerCase();
173
+ return Object.values(DEV_LOOP_TARGET_KIND).includes(normalized) ? normalized : null;
174
+ }
175
+
176
+ function normalizePositiveInt(v) {
177
+ if (!Number.isInteger(v) || v < 0) return null;
178
+ return v;
179
+ }
180
+
181
+ function normalizeString(v) {
182
+ return typeof v === "string" && v.trim().length > 0 ? v.trim() : null;
183
+ }
184
+
185
+ function normalizeStringOrNull(v) {
186
+ return v === null || v === undefined ? null : normalizeString(v);
187
+ }
188
+
189
+ function requireString(v, label) {
190
+ const s = normalizeString(v);
191
+ if (s === null) throw new Error(`handoff-envelope: ${label} is required and must be a non-empty string`);
192
+ return s;
193
+ }
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Target derivation
197
+ // ---------------------------------------------------------------------------
198
+
199
+ function deriveTarget(bundle, repo) {
200
+ const artifact = bundle?.activeArtifact ?? bundle?.canonicalState?.target ?? {};
201
+
202
+ const kind = normalizeTargetKind(artifact.kind);
203
+ if (!kind) throw new Error("handoff-envelope: resolver output must include a valid target kind");
204
+
205
+ const target = { kind, repo };
206
+
207
+ if (kind === DEV_LOOP_TARGET_KIND.ISSUE) {
208
+ const issue = artifact.issue;
209
+ if (!Number.isInteger(issue) || issue < 1) {
210
+ throw new Error("handoff-envelope: issue target must include a valid positive issue number");
211
+ }
212
+ target.issue = issue;
213
+ if (Number.isInteger(artifact.pr) && artifact.pr > 0) target.pr = artifact.pr;
214
+ if (Number.isInteger(artifact.linkedPr) && artifact.linkedPr > 0) target.linkedPr = artifact.linkedPr;
215
+ } else if (kind === DEV_LOOP_TARGET_KIND.PR) {
216
+ const pr = artifact.pr;
217
+ if (!Number.isInteger(pr) || pr < 1) {
218
+ throw new Error("handoff-envelope: PR target must include a valid positive PR number");
219
+ }
220
+ target.pr = pr;
221
+ if (Number.isInteger(artifact.issue) && artifact.issue > 0) target.issue = artifact.issue;
222
+ } else if (kind === DEV_LOOP_TARGET_KIND.LOCAL_BRANCH) {
223
+ const branch = normalizeString(artifact.branch);
224
+ if (!branch) throw new Error("handoff-envelope: local_branch target must include a non-empty branch name");
225
+ target.branch = branch;
226
+ if (Number.isInteger(artifact.issue) && artifact.issue > 0) target.issue = artifact.issue;
227
+ } else if (kind === DEV_LOOP_TARGET_KIND.LOCAL_PHASE) {
228
+ const phase = normalizeString(artifact.phase);
229
+ const validIssue = Number.isInteger(artifact.issue) && artifact.issue > 0;
230
+ if (!phase && !validIssue) {
231
+ throw new Error("handoff-envelope: local_phase target must include a non-empty phase or a valid positive issue number");
232
+ }
233
+ if (phase) target.phase = phase;
234
+ if (validIssue) target.issue = artifact.issue;
235
+ }
236
+
237
+ return target;
238
+ }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // Stop rules derivation
242
+ // ---------------------------------------------------------------------------
243
+
244
+ function deriveStopRules(settings, strategy) {
245
+ if (settings?.autonomy?.stopAt && Array.isArray(settings.autonomy.stopAt)) {
246
+ return [...settings.autonomy.stopAt];
247
+ }
248
+ return [...(STRATEGY_DEFAULT_STOP_RULES[strategy] ?? [])];
249
+ }
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // requiredReads derivation
253
+ // ---------------------------------------------------------------------------
254
+
255
+ function deriveRequiredReads(bundle, resolverOutput) {
256
+ const topReads = resolverOutput?.requiredReads;
257
+ if (Array.isArray(topReads) && topReads.length > 0) return [...topReads];
258
+ const reads = bundle?.requiredReads;
259
+ return Array.isArray(reads) ? [...reads] : [];
260
+ }
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // Gate config derivation
264
+ // ---------------------------------------------------------------------------
265
+
266
+ function deriveGateConfig(settings, subGate) {
267
+ const gateKey = subGate === "pre-approval" ? "preApproval" : subGate;
268
+ const gateSettings = settings?.gates?.[gateKey];
269
+ if (!gateSettings) return undefined;
270
+
271
+ const angles = Array.isArray(gateSettings.angles) ? [...gateSettings.angles] : [];
272
+ const excludeAngles = Array.isArray(gateSettings.excludeAngles) ? [...gateSettings.excludeAngles] : [];
273
+ const filteredAngles = angles.filter((a) => !excludeAngles.includes(a));
274
+
275
+ return {
276
+ angles: filteredAngles,
277
+ excludeAngles: excludeAngles.length > 0 ? excludeAngles : undefined,
278
+ blockCleanOnFindingSeverities: Array.isArray(gateSettings.blockCleanOnFindingSeverities)
279
+ ? [...gateSettings.blockCleanOnFindingSeverities]
280
+ : ["must-fix"],
281
+ requireCi: gateSettings.requireCi ?? true,
282
+ };
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Acceptance template lookup
287
+ // ---------------------------------------------------------------------------
288
+
289
+ function lookupAcceptanceTemplate(strategy, gate) {
290
+ const key = acceptanceKey(strategy, gate);
291
+ const template = ACCEPTANCE_TEMPLATES.get(key);
292
+ if (!template) {
293
+ throw new Error(
294
+ `handoff-envelope: no acceptance template for strategy "${strategy}" + gate "${gate}". ` +
295
+ `Known combos: ${[...ACCEPTANCE_TEMPLATES.keys()].join(", ")}`
296
+ );
297
+ }
298
+ return template;
299
+ }
300
+
301
+ // ---------------------------------------------------------------------------
302
+ // cwd derivation
303
+ // ---------------------------------------------------------------------------
304
+
305
+ function deriveCwd(bundle, options = {}) {
306
+ if (options.worktreeCwd && typeof options.worktreeCwd === "string" && options.worktreeCwd.trim().length > 0) {
307
+ return options.worktreeCwd.trim();
308
+ }
309
+
310
+ const root = options.repoRoot && typeof options.repoRoot === "string"
311
+ ? options.repoRoot.trim()
312
+ : null;
313
+
314
+ const artifact = bundle?.activeArtifact ?? bundle?.canonicalState?.target ?? {};
315
+ const kind = normalizeTargetKind(artifact.kind);
316
+
317
+ if (root) {
318
+ const slug = buildWorktreeSlug(artifact, kind);
319
+ if (slug) {
320
+ return `${root}/tmp/worktrees/${slug}`;
321
+ }
322
+ }
323
+
324
+ return null;
325
+ }
326
+
327
+ function flattenSlugSegment(s) {
328
+ if (typeof s !== "string") return "";
329
+ return s.replace(/[/\\]/g, "-").replace(/[^a-zA-Z0-9._-]/g, "");
330
+ }
331
+
332
+ function buildWorktreeSlug(artifact, kind) {
333
+ if (kind === DEV_LOOP_TARGET_KIND.ISSUE && Number.isInteger(artifact.issue) && artifact.issue > 0) {
334
+ const branch = normalizeString(artifact.branch);
335
+ return branch ? `issue-${artifact.issue}-${flattenSlugSegment(branch)}` : `issue-${artifact.issue}`;
336
+ }
337
+ if (kind === DEV_LOOP_TARGET_KIND.PR && Number.isInteger(artifact.pr) && artifact.pr > 0) {
338
+ const branch = normalizeString(artifact.branch);
339
+ return branch ? `pr-${artifact.pr}-${flattenSlugSegment(branch)}` : `pr-${artifact.pr}`;
340
+ }
341
+ if (kind === DEV_LOOP_TARGET_KIND.LOCAL_BRANCH) {
342
+ const branch = normalizeString(artifact.branch);
343
+ return branch ? flattenSlugSegment(branch) : null;
344
+ }
345
+ if (kind === DEV_LOOP_TARGET_KIND.LOCAL_PHASE) {
346
+ const phase = normalizeString(artifact.phase);
347
+ const issue = Number.isInteger(artifact.issue) && artifact.issue > 0 ? artifact.issue : null;
348
+ if (phase && issue) return `phase-${issue}-${flattenSlugSegment(phase)}`;
349
+ if (phase) return `phase-${flattenSlugSegment(phase)}`;
350
+ if (issue) return `issue-${issue}`;
351
+ return null;
352
+ }
353
+ return null;
354
+ }
355
+
356
+ // ---------------------------------------------------------------------------
357
+ // gateState normalization
358
+ // ---------------------------------------------------------------------------
359
+
360
+ function normalizeGateState(gateState) {
361
+ const gs = gateState ?? {};
362
+
363
+ return {
364
+ currentHeadSha: normalizeStringOrNull(gs.currentHeadSha) ?? null,
365
+ ciStatus: normalizeStringOrNull(gs.ciStatus) ?? null,
366
+ unresolvedThreadCount: normalizePositiveInt(gs.unresolvedThreadCount) ?? 0,
367
+ copilotRoundCount: normalizePositiveInt(gs.copilotRoundCount) ?? 0,
368
+ currentSubGate: normalizeString(gs.currentSubGate) ?? undefined,
369
+ };
370
+ }
371
+
372
+ // ---------------------------------------------------------------------------
373
+ // Sub-gate resolution
374
+ // ---------------------------------------------------------------------------
375
+
376
+ function resolveSubGate(strategy, gateState) {
377
+ if (strategy === INTERNAL_DEV_LOOP_STRATEGY.COPILOT_PR_FOLLOWUP) {
378
+ const sub = gateState.currentSubGate;
379
+ if (sub === "draft" || sub === "watch" || sub === "pre-approval") return sub;
380
+ return "draft";
381
+ }
382
+ return "default";
383
+ }
384
+
385
+
386
+ // ---------------------------------------------------------------------------
387
+ // Deep freeze helper
388
+ // ---------------------------------------------------------------------------
389
+
390
+ function deepFreeze(obj) {
391
+ if (obj == null || typeof obj !== "object") return obj;
392
+ Object.freeze(obj);
393
+ for (const key of Object.keys(obj)) {
394
+ deepFreeze(obj[key]);
395
+ }
396
+ return obj;
397
+ }
398
+
399
+ // ---------------------------------------------------------------------------
400
+ // Public API
401
+ // ---------------------------------------------------------------------------
402
+
403
+ /**
404
+ * Build a deterministic handoff envelope from resolver output + settings + gate state.
405
+ */
406
+ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState = {}, options = {}, now = null) {
407
+ if (!resolverOutput || typeof resolverOutput !== "object") {
408
+ throw new Error("handoff-envelope: resolverOutput is required and must be an object");
409
+ }
410
+
411
+ const bundle = resolverOutput.bundle ?? resolverOutput;
412
+ const strategy = requireString(bundle.selectedStrategy, "resolverOutput.selectedStrategy");
413
+ const executionMode = requireString(bundle.executionMode, "resolverOutput.executionMode");
414
+ const nextAction = requireString(bundle.nextAction, "resolverOutput.nextAction");
415
+
416
+ const repo = normalizeRepo(options.repoSlug ?? bundle.repoSlug ?? bundle.repo);
417
+ if (!repo) throw new Error("handoff-envelope: repo slug is required (owner/name)");
418
+
419
+ const gs = normalizeGateState(gateState);
420
+ const subGate = resolveSubGate(strategy, gs);
421
+
422
+ const target = deriveTarget(bundle, repo);
423
+ const requiredReads = deriveRequiredReads(bundle, resolverOutput);
424
+ const stopRules = deriveStopRules(settings, strategy);
425
+ const gateConfig = deriveGateConfig(settings, subGate);
426
+ const derivedCwd = deriveCwd(bundle, { repoRoot: options.repoRoot, worktreeCwd: options.worktreeCwd });
427
+ const template = lookupAcceptanceTemplate(strategy, subGate);
428
+
429
+ const overrides = options.overrides && typeof options.overrides === "object" && Object.keys(options.overrides).length > 0
430
+ ? { ...options.overrides }
431
+ : undefined;
432
+
433
+ const envelope = {
434
+ handoffVersion: ENVELOPE_HANDOFF_VERSION,
435
+ derivedAt: (now ?? new Date()).toISOString(),
436
+
437
+ target,
438
+ currentGate: subGate,
439
+ currentHeadSha: gs.currentHeadSha,
440
+ ciStatus: gs.ciStatus,
441
+ unresolvedThreadCount: gs.unresolvedThreadCount,
442
+ copilotRoundCount: gs.copilotRoundCount,
443
+ maxCopilotRounds: settings?.refinement?.maxCopilotRounds ?? 5,
444
+ executionMode,
445
+
446
+ nextAction,
447
+ requiredReads,
448
+
449
+ stopRules,
450
+ asyncStartMode: settings?.workflow?.asyncStartMode ?? "required",
451
+ requireDraftFirst: settings?.workflow?.requireDraftFirst ?? false,
452
+
453
+ cwd: derivedCwd,
454
+ worktreeRequired: true,
455
+
456
+ acceptance: {
457
+ criteria: [...template.criteria],
458
+ evidence: [...template.evidence],
459
+ maxFinalizationTurns: template.maxFinalizationTurns,
460
+ },
461
+
462
+ control: {
463
+ needsAttentionAfterMs: template.needsAttentionAfterMs,
464
+ activeNoticeAfterMs: template.activeNoticeAfterMs,
465
+ },
466
+ };
467
+
468
+ if (gateConfig) {
469
+ envelope.gateConfig = gateConfig;
470
+ }
471
+
472
+ if (overrides) {
473
+ envelope.overrides = overrides;
474
+ }
475
+
476
+ // Optional refinement contract (AC/DoD matrix) from the refiner.
477
+ // Set via options.refinementContract, bundle.refinementContract, or resolverOutput.refinementContract.
478
+ const refinementContract = options.refinementContract ?? bundle.refinementContract ?? resolverOutput.refinementContract ?? null;
479
+ if (refinementContract != null) {
480
+ envelope.refinementContract = refinementContract;
481
+ }
482
+
483
+ return deepFreeze(envelope);
484
+ }
485
+
486
+ // ---------------------------------------------------------------------------
487
+ // Consumer-side validation
488
+ // ---------------------------------------------------------------------------
489
+
490
+ const VALID_TARGET_KINDS = Object.freeze(["issue", "pr", "local_branch", "local_phase"]);
491
+ const VALID_EXECUTION_MODES = Object.freeze(["bounded_handoff", "durable_auto"]);
492
+ const VALID_ASYNC_START_MODES = Object.freeze(["required", "allowed"]);
493
+
494
+ /**
495
+ * Validate a handoff envelope on the consumer side before reading requiredReads
496
+ * or executing nextAction. Returns `{ ok: true, errors: [], warnings?: [...] }` for valid envelopes, or
497
+ * `{ ok: false, errors, warnings? }` with structured field-level error details
498
+ * for malformed envelopes.
499
+ *
500
+ * Rejects envelopes with:
501
+ * - Missing or wrong-type root fields (handoffVersion, target, nextAction,
502
+ * requiredReads, acceptance, stopRules)
503
+ * - Missing required sub-fields (target.kind, target.repo, acceptance.criteria)
504
+ * - Malformed acceptance criteria entries
505
+ * - Wrong handoffVersion (negative/non-integer; version mismatch produces a warning)
506
+ * - Type errors in requiredReads, stopRules, etc.
507
+ *
508
+ * Does not throw — always returns a structured result.
509
+ */
510
+ export function validateHandoffEnvelope(envelope) {
511
+ const errors = [];
512
+ const warnings = [];
513
+
514
+ // ----- structural check -----
515
+ if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) {
516
+ return {
517
+ ok: false,
518
+ errors: [{ field: "_root", reason: "envelope must be a non-null, non-array object", got: envelope }],
519
+ };
520
+ }
521
+
522
+ // ----- handoffVersion -----
523
+ if (!Number.isInteger(envelope.handoffVersion) || envelope.handoffVersion < 1) {
524
+ errors.push({
525
+ field: "handoffVersion",
526
+ reason: `must be a positive integer (current: ${ENVELOPE_HANDOFF_VERSION})`,
527
+ got: envelope.handoffVersion,
528
+ });
529
+ } else if (envelope.handoffVersion !== ENVELOPE_HANDOFF_VERSION) {
530
+ warnings.push({
531
+ field: "handoffVersion",
532
+ reason: `expected version ${ENVELOPE_HANDOFF_VERSION}, got ${envelope.handoffVersion}`,
533
+ });
534
+ }
535
+
536
+ // ----- target -----
537
+ if (!envelope.target || typeof envelope.target !== "object" || Array.isArray(envelope.target)) {
538
+ errors.push({ field: "target", reason: "must be a non-array object with kind and repo", got: envelope.target });
539
+ } else {
540
+ if (!envelope.target.kind || !VALID_TARGET_KINDS.includes(envelope.target.kind)) {
541
+ errors.push({
542
+ field: "target.kind",
543
+ reason: `must be one of: ${VALID_TARGET_KINDS.join(", ")}`,
544
+ got: envelope.target.kind,
545
+ });
546
+ }
547
+ if (typeof envelope.target.repo !== "string" || !envelope.target.repo.includes("/")) {
548
+ errors.push({
549
+ field: "target.repo",
550
+ reason: "must be a non-empty owner/name string",
551
+ got: envelope.target.repo,
552
+ });
553
+ } else {
554
+ let normalized;
555
+ try {
556
+ normalized = normalizeRepoSlug(envelope.target.repo);
557
+ } catch (_e) {
558
+ normalized = null;
559
+ }
560
+ if (!normalized || normalized !== envelope.target.repo) {
561
+ errors.push({
562
+ field: "target.repo",
563
+ reason: "must be a valid normalized repo slug (owner/name)",
564
+ got: envelope.target.repo,
565
+ });
566
+ }
567
+ }
568
+ // target-kind specific required fields
569
+ const kind = envelope.target.kind;
570
+ if (kind === "issue") {
571
+ if (!Number.isInteger(envelope.target.issue) || envelope.target.issue < 1) {
572
+ errors.push({ field: "target.issue", reason: "must be a positive integer", got: envelope.target.issue });
573
+ }
574
+ }
575
+ if (kind === "pr") {
576
+ if (!Number.isInteger(envelope.target.pr) || envelope.target.pr < 1) {
577
+ errors.push({ field: "target.pr", reason: "must be a positive integer", got: envelope.target.pr });
578
+ }
579
+ }
580
+ if (kind === "local_branch" && (typeof envelope.target.branch !== "string" || !envelope.target.branch.trim())) {
581
+ errors.push({ field: "target.branch", reason: "required for local_branch target kind", got: envelope.target.branch });
582
+ }
583
+ if (kind === "local_phase") {
584
+ if (!Number.isInteger(envelope.target.issue) || envelope.target.issue < 1) {
585
+ if (typeof envelope.target.phase !== "string" || !envelope.target.phase.trim()) {
586
+ errors.push({ field: "target.phase", reason: "required for local_phase target kind", got: envelope.target.phase });
587
+ }
588
+ }
589
+ }
590
+ }
591
+
592
+ // ----- nextAction -----
593
+ if (typeof envelope.nextAction !== "string" || !envelope.nextAction.trim()) {
594
+ errors.push({
595
+ field: "nextAction",
596
+ reason: "must be a non-empty string",
597
+ got: envelope.nextAction,
598
+ });
599
+ }
600
+
601
+ // ----- requiredReads -----
602
+ if (!Array.isArray(envelope.requiredReads)) {
603
+ errors.push({ field: "requiredReads", reason: "must be an array", got: envelope.requiredReads });
604
+ } else if (envelope.requiredReads.length === 0) {
605
+ warnings.push({ field: "requiredReads", reason: "array is empty — no files to load" });
606
+ } else {
607
+ const bad = [];
608
+ for (let i = 0; i < envelope.requiredReads.length; i++) {
609
+ if (typeof envelope.requiredReads[i] !== "string" || !envelope.requiredReads[i].trim()) {
610
+ bad.push(i);
611
+ }
612
+ }
613
+ if (bad.length > 0) {
614
+ errors.push({
615
+ field: "requiredReads",
616
+ reason: `entries at indices [${bad.join(",")}] must be non-empty strings`,
617
+ got: envelope.requiredReads,
618
+ });
619
+ }
620
+ }
621
+
622
+ // ----- acceptance -----
623
+ if (!envelope.acceptance || typeof envelope.acceptance !== "object" || Array.isArray(envelope.acceptance)) {
624
+ errors.push({ field: "acceptance", reason: "must be a non-array object with criteria array", got: envelope.acceptance });
625
+ } else {
626
+ if (!Array.isArray(envelope.acceptance.criteria)) {
627
+ errors.push({ field: "acceptance.criteria", reason: "must be an array", got: envelope.acceptance.criteria });
628
+ } else if (envelope.acceptance.criteria.length === 0) {
629
+ errors.push({ field: "acceptance.criteria", reason: "must not be empty", got: envelope.acceptance.criteria });
630
+ } else {
631
+ const VALID_SEVERITIES = ["required", "recommended"];
632
+ const bad = [];
633
+ for (let i = 0; i < envelope.acceptance.criteria.length; i++) {
634
+ const c = envelope.acceptance.criteria[i];
635
+ if (!c || typeof c !== "object" || typeof c.id !== "string" || !c.id.trim() ||
636
+ typeof c.must !== "string" || !c.must.trim() ||
637
+ typeof c.severity !== "string" || !VALID_SEVERITIES.includes(c.severity)) {
638
+ bad.push(i);
639
+ }
640
+ }
641
+ if (bad.length > 0) {
642
+ errors.push({
643
+ field: "acceptance.criteria",
644
+ reason: `entries at indices [${bad.join(",")}] must have valid id, must, and severity fields`,
645
+ got: envelope.acceptance.criteria,
646
+ });
647
+ }
648
+ }
649
+ }
650
+
651
+ // ----- stopRules -----
652
+ if (!Array.isArray(envelope.stopRules)) {
653
+ errors.push({ field: "stopRules", reason: "must be an array", got: envelope.stopRules });
654
+ } else {
655
+ const bad = [];
656
+ for (let i = 0; i < envelope.stopRules.length; i++) {
657
+ if (typeof envelope.stopRules[i] !== "string") {
658
+ bad.push(i);
659
+ }
660
+ }
661
+ if (bad.length > 0) {
662
+ errors.push({
663
+ field: "stopRules",
664
+ reason: `entries at indices [${bad.join(",")}] must be strings`,
665
+ got: envelope.stopRules,
666
+ });
667
+ }
668
+ }
669
+
670
+ // ----- executionMode (required field) -----
671
+ if (envelope.executionMode === undefined || envelope.executionMode === null) {
672
+ errors.push({
673
+ field: "executionMode",
674
+ reason: "must be present",
675
+ got: envelope.executionMode,
676
+ });
677
+ } else if (!VALID_EXECUTION_MODES.includes(envelope.executionMode)) {
678
+ errors.push({
679
+ field: "executionMode",
680
+ reason: `must be one of: ${VALID_EXECUTION_MODES.join(", ")}`,
681
+ got: envelope.executionMode,
682
+ });
683
+ }
684
+
685
+ // ----- asyncStartMode (required field) -----
686
+ if (envelope.asyncStartMode === undefined || envelope.asyncStartMode === null) {
687
+ errors.push({
688
+ field: "asyncStartMode",
689
+ reason: "must be present",
690
+ got: envelope.asyncStartMode,
691
+ });
692
+ } else if (!VALID_ASYNC_START_MODES.includes(envelope.asyncStartMode)) {
693
+ errors.push({
694
+ field: "asyncStartMode",
695
+ reason: `must be one of: ${VALID_ASYNC_START_MODES.join(", ")}`,
696
+ got: envelope.asyncStartMode,
697
+ });
698
+ }
699
+
700
+ // ----- refinementContract (optional) -----
701
+ if (envelope.refinementContract !== undefined && envelope.refinementContract !== null) {
702
+ if (typeof envelope.refinementContract !== "object" || Array.isArray(envelope.refinementContract)) {
703
+ errors.push({
704
+ field: "refinementContract",
705
+ reason: "if present, must be a non-array object with schema, items, generatedAt, and isComplete",
706
+ got: envelope.refinementContract,
707
+ });
708
+ } else {
709
+ if (envelope.refinementContract.schema !== "ac-dod-matrix/v1") {
710
+ warnings.push({
711
+ field: "refinementContract.schema",
712
+ reason: "expected 'ac-dod-matrix/v1'",
713
+ got: envelope.refinementContract.schema,
714
+ });
715
+ }
716
+ if (!Array.isArray(envelope.refinementContract.items) || envelope.refinementContract.items.length === 0) {
717
+ errors.push({
718
+ field: "refinementContract.items",
719
+ reason: "must be a non-empty array of AC/DoD matrix items",
720
+ got: envelope.refinementContract.items,
721
+ });
722
+ } else {
723
+ const bad = [];
724
+ for (let i = 0; i < envelope.refinementContract.items.length; i++) {
725
+ const item = envelope.refinementContract.items[i];
726
+ if (
727
+ !item || typeof item !== "object" ||
728
+ typeof item.item !== "string" || !item.item.trim() ||
729
+ !["AC", "DoD", "Non-goal"].includes(item.type) ||
730
+ !["Met", "Partial", "Unmet", "Unverified"].includes(item.status) ||
731
+ typeof item.evidence !== "string" ||
732
+ typeof item.notes !== "string"
733
+ ) {
734
+ bad.push(i);
735
+ }
736
+ }
737
+ if (bad.length > 0) {
738
+ errors.push({
739
+ field: "refinementContract.items",
740
+ reason: `entries at indices [${bad.join(",")}] must have valid item, type, status, evidence, and notes fields`,
741
+ got: envelope.refinementContract.items,
742
+ });
743
+ }
744
+ }
745
+ if (typeof envelope.refinementContract.generatedAt !== "string" || isNaN(Date.parse(envelope.refinementContract.generatedAt))) {
746
+ errors.push({
747
+ field: "refinementContract.generatedAt",
748
+ reason: "must be a valid ISO 8601 timestamp",
749
+ got: envelope.refinementContract.generatedAt,
750
+ });
751
+ }
752
+ if (typeof envelope.refinementContract.isComplete !== "boolean") {
753
+ errors.push({
754
+ field: "refinementContract.isComplete",
755
+ reason: "must be a boolean",
756
+ got: envelope.refinementContract.isComplete,
757
+ });
758
+ } else if (envelope.refinementContract.items && Array.isArray(envelope.refinementContract.items)) {
759
+ const allMet = envelope.refinementContract.items.every(
760
+ (item) => item && typeof item === "object" && item.status === "Met"
761
+ );
762
+ if (envelope.refinementContract.isComplete !== allMet) {
763
+ errors.push({
764
+ field: "refinementContract.isComplete",
765
+ reason: "must match items status (true iff every item has status 'Met')",
766
+ got: { isComplete: envelope.refinementContract.isComplete, allItemsMet: allMet },
767
+ });
768
+ }
769
+ }
770
+ }
771
+ }
772
+
773
+ // ----- derivedAt (informational, warn on missing) -----
774
+ if (typeof envelope.derivedAt !== "string" || !envelope.derivedAt.trim()) {
775
+ warnings.push({ field: "derivedAt", reason: "should be an ISO 8601 timestamp" });
776
+ }
777
+
778
+ return {
779
+ ok: errors.length === 0,
780
+ errors,
781
+ ...(warnings.length > 0 && { warnings }),
782
+ };
783
+ }
784
+
785
+ export {
786
+ ACCEPTANCE_TEMPLATES,
787
+ ENVELOPE_HANDOFF_VERSION,
788
+ STRATEGY_DEFAULT_STOP_RULES,
789
+ acceptanceKey,
790
+ deriveTarget,
791
+ deriveStopRules,
792
+ deriveGateConfig,
793
+ deriveCwd,
794
+ deriveRequiredReads,
795
+ normalizeGateState,
796
+ resolveSubGate,
797
+ lookupAcceptanceTemplate,
798
+ buildWorktreeSlug,
799
+ flattenSlugSegment,
800
+ };