@defrex/autobuild 0.11.0 → 0.13.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 (136) hide show
  1. package/README.md +3 -1
  2. package/SPEC.md +335 -31
  3. package/docs/architecture.md +125 -7
  4. package/docs/configuration.md +142 -21
  5. package/docs/hosted-dispatcher.md +17 -2
  6. package/docs/hosted-service.md +240 -0
  7. package/docs/mcp.md +8 -2
  8. package/docs/operator-api.md +97 -7
  9. package/docs/remote-store-protocol.md +199 -144
  10. package/docs/remote-ticket-protocol.md +249 -19
  11. package/docs/setup.md +199 -16
  12. package/docs/ticket-dependencies.md +1 -1
  13. package/docs/ticket-migration.md +64 -0
  14. package/package.json +7 -6
  15. package/packages/core/src/cli/attention.ts +209 -0
  16. package/packages/core/src/cli/build-control.ts +12 -1
  17. package/packages/core/src/cli/configured-store.ts +17 -0
  18. package/packages/core/src/cli/connect.ts +364 -0
  19. package/packages/core/src/cli/context.ts +7 -1
  20. package/packages/core/src/cli/dashboard/model.ts +4 -5
  21. package/packages/core/src/cli/dashboard/poll.ts +1 -1
  22. package/packages/core/src/cli/dispatch.ts +336 -41
  23. package/packages/core/src/cli/harvest.ts +13 -3
  24. package/packages/core/src/cli/help.ts +51 -16
  25. package/packages/core/src/cli/init-validation.ts +27 -2
  26. package/packages/core/src/cli/installation.ts +67 -13
  27. package/packages/core/src/cli/main.ts +60 -16
  28. package/packages/core/src/cli/mcp.ts +9 -2
  29. package/packages/core/src/cli/pr-summary-publication.ts +220 -0
  30. package/packages/core/src/cli/pr-summary.ts +2 -0
  31. package/packages/core/src/cli/project-credentials.ts +132 -0
  32. package/packages/core/src/cli/repo-state.ts +15 -2
  33. package/packages/core/src/cli/self-update.ts +15 -1
  34. package/packages/core/src/cli/store-opening.ts +22 -14
  35. package/packages/core/src/cli/terminals.ts +0 -10
  36. package/packages/core/src/cli/ticket-assets.ts +7 -2
  37. package/packages/core/src/cli/ticket-editor.ts +82 -0
  38. package/packages/core/src/cli/ticket.ts +172 -35
  39. package/packages/core/src/cli/upgrade-compatibility.ts +80 -0
  40. package/packages/core/src/cli/wait.ts +84 -17
  41. package/packages/core/src/cli/watch.ts +197 -60
  42. package/packages/core/src/config/live.ts +3 -0
  43. package/packages/core/src/config/schema.ts +90 -11
  44. package/packages/core/src/connection.ts +86 -0
  45. package/packages/core/src/events/catalog.ts +11 -2
  46. package/packages/core/src/events/payloads.ts +50 -0
  47. package/packages/core/src/events/repository.ts +13 -0
  48. package/packages/core/src/integration/harness.ts +37 -2
  49. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +145 -83
  50. package/packages/core/src/kernel/engine.ts +24 -0
  51. package/packages/core/src/kernel/generators/build-log.ts +11 -1
  52. package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
  53. package/packages/core/src/kernel/log-index.ts +17 -0
  54. package/packages/core/src/kernel/pr-summary.ts +89 -0
  55. package/packages/core/src/kernel/reducer-registry.ts +10 -0
  56. package/packages/core/src/kernel/reducer.ts +8 -0
  57. package/packages/core/src/ontology.ts +2 -0
  58. package/packages/core/src/operator/annotations.ts +12 -0
  59. package/packages/core/src/operator/index.ts +12 -0
  60. package/packages/core/src/operator/protocol.ts +19 -2
  61. package/packages/core/src/operator/query.ts +38 -16
  62. package/packages/core/src/operator/registry.ts +61 -8
  63. package/packages/core/src/operator/ticket-source-backend.ts +17 -20
  64. package/packages/core/src/operator/tickets.ts +136 -28
  65. package/packages/core/src/plugin-sdk/index.ts +7 -0
  66. package/packages/core/src/plugins/manifest.ts +5 -3
  67. package/packages/core/src/plugins/runtimes.ts +7 -1
  68. package/packages/core/src/ports/forge/fake.ts +13 -1
  69. package/packages/core/src/ports/forge/github-transport.ts +36 -1
  70. package/packages/core/src/ports/forge/github.ts +29 -5
  71. package/packages/core/src/ports/forge/local-git.ts +12 -4
  72. package/packages/core/src/ports/forge/summary-marker.ts +8 -0
  73. package/packages/core/src/ports/runner/session-env.ts +3 -0
  74. package/packages/core/src/ports/tickets/contract.ts +66 -1
  75. package/packages/core/src/ports/tickets/create.ts +54 -10
  76. package/packages/core/src/ports/tickets/fake.ts +30 -1
  77. package/packages/core/src/ports/tickets/file-assets.ts +159 -0
  78. package/packages/core/src/ports/tickets/file-codec.ts +90 -0
  79. package/packages/core/src/ports/tickets/file.ts +114 -120
  80. package/packages/core/src/ports/tickets/grants.ts +47 -0
  81. package/packages/core/src/ports/tickets/hosted-index.ts +17 -3
  82. package/packages/core/src/ports/tickets/identity.ts +20 -0
  83. package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
  84. package/packages/core/src/ports/tickets/linear.ts +74 -9
  85. package/packages/core/src/ports/tickets/migration.ts +336 -0
  86. package/packages/core/src/ports/tickets/operations.ts +38 -7
  87. package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
  88. package/packages/core/src/ports/tickets/remote-protocol.ts +96 -0
  89. package/packages/core/src/ports/tickets/remote.ts +61 -3
  90. package/packages/core/src/ports/tickets/requested-id.ts +46 -0
  91. package/packages/core/src/ports/tickets/revision.ts +62 -0
  92. package/packages/core/src/ports/tickets/states.ts +61 -0
  93. package/packages/core/src/ports/types.ts +40 -3
  94. package/packages/core/src/ports/workspace/build-execution.ts +6 -1
  95. package/packages/core/src/ports/workspace/create.ts +15 -2
  96. package/packages/core/src/ports/workspace/distribution-archive.ts +3 -5
  97. package/packages/core/src/ports/workspace/harvest-execution.ts +3 -1
  98. package/packages/core/src/ports/workspace/local-build-execution.ts +8 -1
  99. package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
  100. package/packages/core/src/ports/workspace/vercel-sandbox.ts +51 -19
  101. package/packages/core/src/processes/build-execution-state.ts +1 -1
  102. package/packages/core/src/processes/build-runner.ts +87 -26
  103. package/packages/core/src/processes/dispatch-failure-budget.ts +83 -0
  104. package/packages/core/src/processes/dispatcher.ts +168 -41
  105. package/packages/core/src/processes/execution-no-progress.ts +176 -0
  106. package/packages/core/src/processes/harvest-child.ts +31 -4
  107. package/packages/core/src/processes/harvest-execution-state.ts +19 -2
  108. package/packages/core/src/processes/harvest-runner.ts +166 -104
  109. package/packages/core/src/processes/harvest.ts +34 -14
  110. package/packages/core/src/processes/phase-failure-budget.ts +2 -0
  111. package/packages/core/src/processes/publication-loss.ts +18 -1
  112. package/packages/core/src/processes/repo-view.ts +35 -0
  113. package/packages/core/src/store/contract.ts +32 -1
  114. package/packages/core/src/store/local/store.ts +1 -1
  115. package/packages/core/src/store/remote/client.ts +1 -1
  116. package/packages/core/src/store/remote/compatibility.ts +90 -0
  117. package/packages/core/src/store/remote/credentials.ts +28 -0
  118. package/packages/core/src/store/remote/index.ts +14 -1
  119. package/packages/core/src/store/remote/protocol.ts +25 -0
  120. package/packages/core/src/store/remote/token.ts +74 -13
  121. package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
  122. package/packages/core/src/store/ticket-assets.ts +86 -8
  123. package/packages/core/src/store/types.ts +1 -1
  124. package/packages/core/src/testing/index.ts +7 -0
  125. package/skills/guide/SKILL.md +232 -34
  126. package/skills/guide/references/operator-api.md +142 -13
  127. package/skills/guide/references/plugin-authoring.md +10 -2
  128. package/skills/guide/references/remote-store-protocol.md +199 -144
  129. package/skills/guide/references/remote-ticket-protocol.md +253 -18
  130. package/skills/guide/references/setup.md +199 -16
  131. package/skills/guide/references/ticket-dependencies.md +1 -1
  132. package/skills/guide/references/ticket-migration.md +64 -0
  133. package/skills/operate/SKILL.md +126 -36
  134. package/skills/tickets/SKILL.md +71 -0
  135. package/templates/hosted-service/package.json +13 -0
  136. package/templates/hosted-service/vercel.json +7 -0
@@ -12,6 +12,7 @@
12
12
  * validated.
13
13
  */
14
14
  import { z } from 'zod'
15
+ import { normalizeServiceURL } from '../connection'
15
16
  import { prImageHostSchema } from '../ontology'
16
17
  import { SANDBOX_FORBIDDEN_ENV } from '../ports/workspace/operator-sandbox'
17
18
  import {
@@ -60,14 +61,22 @@ function stepSection<T, C>(
60
61
  label: string,
61
62
  stepsSchema: z.ZodType<string[]>,
62
63
  tableSchema: z.ZodType<T>,
63
- compose: (steps: string[], stepConfigs: Record<string, T>) => C,
64
+ compose: (steps: string[], stepConfigs: Record<string, T>, afterReconcile: boolean) => C,
65
+ allowAfterReconcile = false,
64
66
  ) {
65
67
  return z
66
68
  .unknown()
67
69
  .transform((input, ctx): C => {
68
70
  const stepConfigs: Record<string, T> = Object.create(null)
69
71
  let steps: string[] = []
72
+ let afterReconcile = true
70
73
  for (const [key, raw] of ownEntries(input, label, ctx)) {
74
+ if (key === 'afterReconcile' && allowAfterReconcile) {
75
+ const parsed = z.boolean().safeParse(raw)
76
+ if (parsed.success) afterReconcile = parsed.data
77
+ else forwardIssues(parsed.error.issues, ctx, [key])
78
+ continue
79
+ }
71
80
  if (key === 'steps') {
72
81
  const parsed = stepsSchema.safeParse(raw)
73
82
  if (parsed.success) steps = parsed.data
@@ -77,7 +86,7 @@ function stepSection<T, C>(
77
86
  const table = parseEntry(tableSchema, raw, key, ctx)
78
87
  if (table.ok) defineEntry(stepConfigs, key, table.value)
79
88
  }
80
- return compose(steps, stepConfigs)
89
+ return compose(steps, stepConfigs, afterReconcile)
81
90
  })
82
91
  .prefault({})
83
92
  }
@@ -367,6 +376,7 @@ export const verifyPathGlobSchema = z.string().superRefine((pattern, ctx) => {
367
376
  })
368
377
 
369
378
  const verifyApplicabilityShape = {
379
+ afterReconcile: z.boolean().optional(),
370
380
  /** Any-match positive path selectors; absent means unconditional. */
371
381
  paths: z
372
382
  .array(verifyPathGlobSchema)
@@ -399,6 +409,7 @@ export const verifyStepConfigSchema = z.discriminatedUnion('kind', [
399
409
  export type VerifyStepConfig = z.infer<typeof verifyStepConfigSchema>
400
410
 
401
411
  export interface VerifyConfig {
412
+ afterReconcile: boolean
402
413
  /** Step order — each entry names a `verify:<step>` phase (§15.2.1). */
403
414
  steps: string[]
404
415
  /** The [verify.<step>] tables, keyed by step name. */
@@ -406,8 +417,8 @@ export interface VerifyConfig {
406
417
  }
407
418
 
408
419
  /**
409
- * [verify] mixes one known key (`steps`) with an open set of per-step
410
- * subtables, so it cannot be a strictObject; instead every non-`steps` key is
420
+ * [verify] mixes known scalars (`steps`, `afterReconcile`) with per-step
421
+ * subtables, so it cannot be a strictObject; every other key is
411
422
  * validated as a step table — nothing passes through loosely, and no step name
412
423
  * is lost to the prototype chain (see `stepSection`).
413
424
  */
@@ -415,7 +426,8 @@ export const verifySectionSchema = stepSection(
415
426
  '[verify]',
416
427
  z.array(z.string().min(1)).default([]),
417
428
  verifyStepConfigSchema,
418
- (steps, stepConfigs): VerifyConfig => ({ steps, stepConfigs }),
429
+ (steps, stepConfigs, afterReconcile): VerifyConfig => ({ steps, stepConfigs, afterReconcile }),
430
+ true,
419
431
  )
420
432
 
421
433
  // ── [finalize.<step>] ────────────────────────────────────────────────────────
@@ -500,6 +512,8 @@ export const policySchema = z.strictObject({
500
512
  /** Same-finding survival threshold before auto-escalate (§10, §15.4). */
501
513
  stallRounds: z.number().int().positive().default(3),
502
514
  maxVerifyAttempts: z.number().int().positive().default(3),
515
+ /** Consecutive dispatch failures before human intervention is required. */
516
+ maxDispatchAttempts: z.number().int().positive().default(3),
503
517
  /** Consecutive workspace setup failures before human intervention is required. */
504
518
  maxSetupAttempts: z.number().int().positive().default(3),
505
519
  /** Consecutive provider lifecycle failures before recovery requires a human retry. */
@@ -521,7 +535,8 @@ export type PolicyConfig = z.infer<typeof policySchema>
521
535
  // API key comes from the LINEAR_API_KEY environment variable, never from this
522
536
  // file.
523
537
 
524
- export const ticketsSchema = z.strictObject({
538
+ export const ticketsInputSchema = z.strictObject({
539
+ url: z.string().optional(),
525
540
  /** Builtin or plugin-registered TicketSource name. Registry membership is
526
541
  * validated after configured plugins load. */
527
542
  source: z
@@ -555,7 +570,8 @@ export const ticketsSchema = z.strictObject({
555
570
  .refine(
556
571
  (s) => s.trim().length > 0,
557
572
  '[tickets].readyState must not be blank — name the one workflow state a ticket must sit in to be dispatched (e.g. "ready").',
558
- ),
573
+ )
574
+ .optional(),
559
575
  /**
560
576
  * Narrows readiness to tickets assigned to one Linear user — a third
561
577
  * conjunctive gate beside readyState and readyLabels. `"me"` is the owner of
@@ -573,15 +589,33 @@ export const ticketsSchema = z.strictObject({
573
589
  /** Backend team key (e.g. "ENG") — required for linear and hosted. */
574
590
  teamKey: z.string().min(1).optional(),
575
591
  /** Workflow state claim() moves an issue to (§12); Linear and hosted. */
576
- claimedState: z.string().min(1).optional(),
592
+ claimedState: z
593
+ .string()
594
+ .min(1)
595
+ .refine((value) => value.trim().length > 0, 'state must be nonblank')
596
+ .optional(),
597
+ /** Hosted project completion state; defaults to done. */
598
+ doneState: z
599
+ .string()
600
+ .min(1)
601
+ .refine((value) => value.trim().length > 0, 'state must be nonblank')
602
+ .optional(),
577
603
  /** State create() files new tickets into. Absent = the provider's default
578
604
  * (Linear: the team's default state, e.g. Backlog; file: Triage). */
579
- createState: z.string().min(1).optional(),
605
+ createState: z
606
+ .string()
607
+ .min(1)
608
+ .refine((value) => value.trim().length > 0, 'state must be nonblank')
609
+ .optional(),
580
610
  /** State the dispatcher hands tickets back to for human triage — spec-gate
581
611
  * bounces (§6.3), aborted builds, closed-unmerged PRs. Absent = the
582
612
  * provider's default (Linear: Backlog; file: Triage) — resolved by
583
613
  * defaultTriageState in src/processes/dispatcher.ts. */
584
- triageState: z.string().min(1).optional(),
614
+ triageState: z
615
+ .string()
616
+ .min(1)
617
+ .refine((value) => value.trim().length > 0, 'state must be nonblank')
618
+ .optional(),
585
619
  /** State harvest files its synthesized proposals into (§12). Absent = the
586
620
  * triage state, which keeps the constitutional grooming gate: a proposal
587
621
  * waits for a human before it can be dispatched. Setting it to readyState
@@ -590,7 +624,11 @@ export const ticketsSchema = z.strictObject({
590
624
  * does not also redirect spec-gate bounces, aborts, and closed-unmerged PRs
591
625
  * into the ready state, where a bounce would re-dispatch itself. Resolved by
592
626
  * defaultProposalState in src/processes/dispatcher.ts. */
593
- proposalState: z.string().min(1).optional(),
627
+ proposalState: z
628
+ .string()
629
+ .min(1)
630
+ .refine((value) => value.trim().length > 0, 'state must be nonblank')
631
+ .optional(),
594
632
  /** Directory of state dirs (`triage/ ready/ doing/ done/`) holding `<id>.md`
595
633
  * ticket files; optional — defaults to `.autobuild/tickets`, resolved
596
634
  * relative to the repo. Kept schema-optional (not `.default()`) so the
@@ -598,6 +636,36 @@ export const ticketsSchema = z.strictObject({
598
636
  * still tell a defaulted dir from an explicit one. */
599
637
  dir: z.string().min(1).optional(),
600
638
  })
639
+ export const ticketsSchema = ticketsInputSchema
640
+ .superRefine((tickets, ctx) => {
641
+ if (tickets.source === 'hosted' && tickets.url !== undefined) {
642
+ try {
643
+ normalizeServiceURL(tickets.url, 'tickets.url')
644
+ } catch (error) {
645
+ ctx.addIssue({ code: 'custom', path: ['url'], message: (error as Error).message })
646
+ }
647
+ }
648
+ if (tickets.source !== 'hosted' && tickets.readyState === undefined)
649
+ ctx.addIssue({
650
+ code: 'custom',
651
+ path: ['readyState'],
652
+ message:
653
+ '[tickets].readyState is required — name the one workflow state a ticket must sit in to be dispatched (e.g. "ready").',
654
+ })
655
+ if (['file', 'linear'].includes(tickets.source) && tickets.url !== undefined)
656
+ ctx.addIssue({
657
+ code: 'custom',
658
+ path: ['url'],
659
+ message: '[tickets].url applies only to source = "hosted"',
660
+ })
661
+ })
662
+ .transform((tickets) => ({
663
+ ...tickets,
664
+ readyState: tickets.readyState ?? 'ready',
665
+ ...(tickets.source === 'hosted' && tickets.url !== undefined
666
+ ? { url: normalizeServiceURL(tickets.url, 'tickets.url') }
667
+ : {}),
668
+ }))
601
669
  export type TicketsConfig = z.infer<typeof ticketsSchema>
602
670
 
603
671
  // ── [orchestrator] ──────────────────────────────────────────────────────────
@@ -725,7 +793,18 @@ export type OrchestratorConfig = z.infer<typeof orchestratorSchema>
725
793
 
726
794
  // ── Whole file ───────────────────────────────────────────────────────────────
727
795
 
796
+ export const storeSchema = z.strictObject({
797
+ url: z.string().transform((value, ctx) => {
798
+ try {
799
+ return normalizeServiceURL(value)
800
+ } catch (error) {
801
+ ctx.addIssue({ code: 'custom', message: (error as Error).message })
802
+ return z.NEVER
803
+ }
804
+ }),
805
+ })
728
806
  const configRootSchema = z.strictObject({
807
+ store: storeSchema.optional(),
729
808
  /** Base branch builds cut from and target (§16.1). */
730
809
  baseBranch: z.string().min(1).default('main'),
731
810
  /** Concurrent builds for this repository (§16.1). */
@@ -0,0 +1,86 @@
1
+ import { normalizeGitRemoteUrl } from './kernel/origin'
2
+ /** Public device-approval protocol. Secrets travel only in capability POST bodies. */
3
+ export type ConnectionStatus = 'pending' | 'approved' | 'denied' | 'expired'
4
+ export interface ConnectionProposal {
5
+ repo: string
6
+ credentialId: string
7
+ digest: string
8
+ }
9
+ export interface ConnectionOutcome {
10
+ repo: string
11
+ credentialId: string
12
+ status: ConnectionStatus
13
+ }
14
+ export interface ConnectionStarted extends ConnectionOutcome {
15
+ id: string
16
+ capability: string
17
+ code: string
18
+ verificationURL: string
19
+ interval: number
20
+ expiresAt: string
21
+ }
22
+ export function normalizeServiceURL(raw: string, setting = 'store.url'): string {
23
+ let url: URL
24
+ try {
25
+ url = new URL(raw)
26
+ } catch {
27
+ throw new Error(`${setting} must be an absolute HTTPS service URL`)
28
+ }
29
+ if (
30
+ !raw.trim() ||
31
+ raw !== raw.trim() ||
32
+ url.username ||
33
+ url.password ||
34
+ url.search ||
35
+ url.hash ||
36
+ !(
37
+ url.protocol === 'https:' ||
38
+ (url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))
39
+ )
40
+ ) {
41
+ throw new Error(
42
+ `${setting} must be HTTPS (HTTP is allowed on loopback), without credentials, query or fragment`,
43
+ )
44
+ }
45
+ return url.href.replace(/\/+$/, '')
46
+ }
47
+
48
+ export function normalizeConnectionRepository(raw: string): string {
49
+ const value = raw.trim()
50
+ // SSH's conventional git user is transport syntax; URL passwords and HTTPS
51
+ // userinfo are never part of an identity (normalization would discard them).
52
+ if (/[\s\\?#]/.test(value) || !value || (/^[^/]*:[^/]*@/.test(value) && !value.includes('://')))
53
+ throw new Error('invalid repository identity')
54
+ if (value.includes('://')) {
55
+ let input: URL
56
+ try {
57
+ input = new URL(value)
58
+ } catch {
59
+ throw new Error('invalid repository identity')
60
+ }
61
+ if (input.password || (input.username && !['ssh:', 'git+ssh:'].includes(input.protocol))) {
62
+ throw new Error('repository identity must not contain credentials')
63
+ }
64
+ if (input.search || input.hash) throw new Error('invalid repository identity')
65
+ }
66
+ const normalized = normalizeGitRemoteUrl(value)
67
+ let url: URL
68
+ try {
69
+ url = new URL(normalized)
70
+ } catch {
71
+ throw new Error('invalid repository identity')
72
+ }
73
+ if (
74
+ url.protocol !== 'https:' ||
75
+ !url.hostname ||
76
+ url.username ||
77
+ url.password ||
78
+ url.search ||
79
+ url.hash ||
80
+ url.pathname === '/' ||
81
+ /%|\\/.test(url.pathname)
82
+ ) {
83
+ throw new Error('repository identity must be a normalized HTTPS repository origin')
84
+ }
85
+ return normalized
86
+ }
@@ -51,6 +51,11 @@ export const allowedActorKinds: Record<EventType, readonly ActorKind[]> = {
51
51
  'execution.ended': ['dispatcher'],
52
52
  'infrastructure.failed': ['dispatcher'],
53
53
  'infrastructure.cleanup-attempted': ['dispatcher'],
54
+ 'pr-summary.requested': ['kernel'],
55
+ 'pr-summary.attempted': ['kernel'],
56
+ 'pr-summary.result': ['kernel'],
57
+ 'pr-summary.settled': ['kernel'],
58
+ 'finalize.step-started': ['kernel'],
54
59
  'publication.requested': ['agent', 'kernel'],
55
60
  // Only the dispatcher releases workspaces and settles publications (§15.7);
56
61
  // the kernel never destroys one, so it never records a loss.
@@ -88,6 +93,7 @@ export const allowedActorKinds: Record<EventType, readonly ActorKind[]> = {
88
93
  'code-review.started': ['kernel'],
89
94
  'code-review.verdict': ['agent'],
90
95
 
96
+ 'verify.reconcile-policy-captured': ['kernel'],
91
97
  'verify.started': ['kernel'],
92
98
  'verify.completed': ['kernel', 'agent'],
93
99
  'finalize.started': ['kernel'],
@@ -177,10 +183,13 @@ export function validateEventWrite(input: {
177
183
  const escalation = payloadResult.data as EventPayload<'escalation.raised'>
178
184
  if (
179
185
  actor.kind === 'dispatcher' &&
180
- (escalation.source !== 'policy' || escalation.policyCause !== 'infrastructure-failure-limit')
186
+ (escalation.source !== 'policy' ||
187
+ (escalation.policyCause !== 'infrastructure-failure-limit' &&
188
+ escalation.policyCause !== 'dispatch-failure-limit' &&
189
+ escalation.policyCause !== 'execution-no-progress'))
181
190
  ) {
182
191
  throw new EventValidationError(
183
- 'invalid dispatcher escalation: only policy/infrastructure-failure-limit may be raised outside the kernel',
192
+ 'invalid dispatcher escalation: only policy/infrastructure-failure-limit or policy/dispatch-failure-limit or policy/execution-no-progress may be raised outside the kernel',
184
193
  )
185
194
  }
186
195
  if (escalation.source === 'policy' && escalation.policyCause === undefined) {
@@ -172,6 +172,7 @@ const verifyCompletedPayloadSchema = z.union([
172
172
  z.strictObject({
173
173
  ...verifyCompletionBase,
174
174
  outcome: z.literal('skipped'),
175
+ skipCause: z.literal('post-reconcile-policy').optional(),
175
176
  reason: z.string().trim().min(1, 'a skipped verification requires a non-blank reason'),
176
177
  }),
177
178
  ]),
@@ -195,6 +196,12 @@ const finalizeStepCompletedPayloadSchema = z.discriminatedUnion('ok', [
195
196
  /** One ticket asset revision a build froze (SPEC §6.3): the identity and
196
197
  * shape of what `listTicketAssets` returned at the pinning moment. */
197
198
  export const pinnedAssetSchema = z.strictObject({
199
+ snapshot: z
200
+ .strictObject({
201
+ kind: z.string().regex(/^ticket-snapshot-[a-f0-9-]+$/),
202
+ revision: z.number().int().nonnegative(),
203
+ })
204
+ .optional(),
198
205
  kind: z.string().min(1),
199
206
  name: z.string().min(1),
200
207
  revision: z.number().int().nonnegative(),
@@ -336,6 +343,26 @@ export const eventPayloadSchemas = {
336
343
  })
337
344
  .optional(),
338
345
  }),
346
+ /** Optional summary publication identities and durable write budgets. */
347
+ 'pr-summary.requested': z.strictObject({
348
+ occurrence: z.number().int().positive(),
349
+ boundary: z.number().int().positive(),
350
+ }),
351
+ 'pr-summary.attempted': z.strictObject({
352
+ occurrence: z.number().int().positive(),
353
+ boundary: z.number().int().positive(),
354
+ pr: z.number().int().positive(),
355
+ }),
356
+ 'pr-summary.result': z.strictObject({
357
+ attempt: z.number().int().positive(),
358
+ outcome: z.enum(['success', 'failure', 'rate-limited']),
359
+ eligibleAfter: z.string().datetime().optional(),
360
+ }),
361
+ 'pr-summary.settled': z.strictObject({
362
+ request: z.number().int().positive(),
363
+ disposition: z.enum(['attempted', 'skipped', 'deferred']),
364
+ }),
365
+ 'finalize.step-started': z.strictObject({ step: z.string().min(1) }),
339
366
  /** Remote phases deposit their output and park before trusted publication.
340
367
  * The dispatcher settles the request only after the VM session and execution
341
368
  * lease are gone, then appends the ordinary phase completion fact. */
@@ -515,6 +542,27 @@ export const eventPayloadSchemas = {
515
542
  * starts. */
516
543
  feedback: feedbackSchema.optional(),
517
544
  }),
545
+ 'verify.reconcile-policy-captured': z.strictObject({
546
+ reconcileSeq: z.number().int().positive(),
547
+ steps: z
548
+ .array(
549
+ z.strictObject({
550
+ step: z.string().min(1),
551
+ enabled: z.boolean(),
552
+ source: z.string().min(1),
553
+ }),
554
+ )
555
+ .superRefine((steps, ctx) => {
556
+ if (new Set(steps.map((s) => s.step)).size !== steps.length)
557
+ ctx.addIssue({ code: 'custom', message: 'duplicate policy step' })
558
+ for (const s of steps)
559
+ if (
560
+ s.source !== 'verify.afterReconcile' &&
561
+ s.source !== `verify.${s.step}.afterReconcile`
562
+ )
563
+ ctx.addIssue({ code: 'custom', message: 'invalid policy source' })
564
+ }),
565
+ }),
518
566
  'verify.completed': verifyCompletedPayloadSchema,
519
567
  'finalize.started': empty,
520
568
  'finalize.completed': z.strictObject({
@@ -630,6 +678,7 @@ export interface NormalizedVerifyCompletion {
630
678
  outcome: VerifyOutcome
631
679
  report?: ArtifactRef
632
680
  reason?: string
681
+ skipCause?: 'post-reconcile-policy'
633
682
  }
634
683
 
635
684
  /**
@@ -653,6 +702,7 @@ export function normalizeVerifyCompletion(
653
702
  attempt: payload.attempt,
654
703
  outcome: 'skipped',
655
704
  reason: payload.reason,
705
+ ...(payload.skipCause !== undefined ? { skipCause: payload.skipCause } : {}),
656
706
  }
657
707
  }
658
708
  return {
@@ -90,6 +90,7 @@ const tickCountersSchema = z.strictObject({
90
90
  const restartRequiredConfigPathSchema = z.enum([
91
91
  'forge',
92
92
  'plugins',
93
+ 'store',
93
94
  'workspace.provider',
94
95
  'workspace.config',
95
96
  'tickets.source',
@@ -154,6 +155,16 @@ export const harvestEventPayloadSchemas = {
154
155
  * command supervising the guest runner. Written as soon as the launch
155
156
  * succeeds so a later process can re-observe the execution without
156
157
  * process memory. */
158
+ 'harvest.execution.authorized': z.strictObject({
159
+ execution: z.string().min(1),
160
+ run: z.string().min(1),
161
+ launch: z.enum(['started', 'resumed']),
162
+ leaseHolder: z.string().min(1),
163
+ }),
164
+ 'harvest.execution.ended': z.strictObject({
165
+ execution: z.string().min(1),
166
+ run: z.string().min(1),
167
+ }),
157
168
  'harvest.execution.started': z.strictObject({
158
169
  execution: z.string().min(1),
159
170
  provider: z.string().min(1),
@@ -557,6 +568,8 @@ const allowedActorKinds: Record<RepositoryEventType, readonly ActorKind[]> = {
557
568
  'harvest.recovery-requested': ['kernel'],
558
569
  'harvest.recovery-exhausted': ['kernel'],
559
570
  'harvest.started': ['dispatcher', 'kernel'],
571
+ 'harvest.execution.authorized': ['dispatcher'],
572
+ 'harvest.execution.ended': ['dispatcher'],
560
573
  'harvest.execution.started': ['dispatcher'],
561
574
  'harvest.execution.released': ['dispatcher'],
562
575
  'harvest.step.started': ['kernel'],
@@ -57,7 +57,13 @@ import type {
57
57
  import { GitWorktreeProvider, spawnExec } from '../ports/workspace/git-worktree'
58
58
  import { InProcessBuildExecution } from '../ports/workspace/in-process-build-execution'
59
59
  import { BuildRunner, LeaseHeldError, SetupFailureError } from '../processes/build-runner'
60
- import { diagnosticArtifact } from '../processes/build-execution-state'
60
+ import {
61
+ diagnosticArtifact,
62
+ BUILD_EFFECTIVE_CONFIG_ARTIFACT,
63
+ effectiveBuildConfigContent,
64
+ parseEffectiveBuildConfig,
65
+ } from '../processes/build-execution-state'
66
+ import { composeBuildConfig } from '../config/live'
61
67
  import { Dispatcher, type LaunchRunnerResult } from '../processes/dispatcher'
62
68
  import {
63
69
  getHarvestStatus,
@@ -120,6 +126,7 @@ export const CONFORMING_BODY = [
120
126
  export function readyTicket(id: string, over: Partial<Omit<Ticket, 'ref'>> = {}): Ticket {
121
127
  const title = over.title ?? 'Add rate limiting'
122
128
  return {
129
+ revision: 'fixture',
123
130
  ref: { source: 'fake', id, title },
124
131
  title,
125
132
  body: over.body ?? CONFORMING_BODY,
@@ -398,6 +405,10 @@ export async function makeHarness(opts: {
398
405
  * CONFIG_TOML, so every existing scenario is untouched; a scenario proving
399
406
  * two-axis routing (§9) supplies its own `[roles.default]`/phase roles. */
400
407
  configToml?: string
408
+ /** Force a routing disagreement while retaining the runner's branch config. */
409
+ dispatcherConfig?: (config: Config) => Config
410
+ pinnedConfig?: boolean
411
+ getConfig?: () => Config
401
412
  /** Forge gate existence is independent from current PR mergeability. */
402
413
  gatePresence?: 'present' | 'absent'
403
414
  /** Optional adapter-level runtime composition for focused integration tests.
@@ -602,6 +613,20 @@ export async function makeHarness(opts: {
602
613
  runner: new BuildRunner({
603
614
  store: activeStore.scopeBuild(slug),
604
615
  config,
616
+ ...(opts.pinnedConfig
617
+ ? {
618
+ getConfig: async () => {
619
+ const artifact = await activeStore.getArtifact(
620
+ slug,
621
+ BUILD_EFFECTIVE_CONFIG_ARTIFACT,
622
+ )
623
+ return composeBuildConfig(
624
+ artifact === null ? config : parseEffectiveBuildConfig(artifact),
625
+ opts.getConfig?.() ?? config,
626
+ )
627
+ },
628
+ }
629
+ : {}),
605
630
  // Two-axis registry (§9): every runtime is backed by the SAME scripted
606
631
  // runner instance, so the `s_1…s_N` session numbering scenarios rely on
607
632
  // is preserved regardless of which runtime a role selects. `scripted`
@@ -629,6 +654,15 @@ export async function makeHarness(opts: {
629
654
  }
630
655
  }
631
656
  const launchRunner = async (slug: string): Promise<LaunchRunnerResult> => {
657
+ if (
658
+ opts.pinnedConfig &&
659
+ (await activeStore.getArtifact(slug, BUILD_EFFECTIVE_CONFIG_ARTIFACT)) === null
660
+ ) {
661
+ await activeStore.putArtifact(slug, {
662
+ kind: BUILD_EFFECTIVE_CONFIG_ARTIFACT,
663
+ content: effectiveBuildConfigContent(config),
664
+ })
665
+ }
632
666
  launched.push({ slug, ...(await makeRunner(slug)) })
633
667
  return 'scheduled'
634
668
  }
@@ -638,10 +672,11 @@ export async function makeHarness(opts: {
638
672
  tickets: ticketSource,
639
673
  workspaces,
640
674
  forge: selectedForge,
641
- config,
675
+ config: opts.dispatcherConfig?.(structuredClone(config)) ?? config,
642
676
  repo: origin,
643
677
  exec: spawnExec,
644
678
  launchRunner,
679
+ ...(opts.getConfig ? { getConfig: opts.getConfig } : {}),
645
680
  ids,
646
681
  clock,
647
682
  })