agentic-orchestrator 0.1.26 → 0.1.28

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 (172) hide show
  1. package/AGENTS.md +2 -2
  2. package/CLAUDE.md +2 -2
  3. package/README.md +47 -14
  4. package/agentic/orchestrator/agents.yaml +13 -0
  5. package/agentic/orchestrator/policy.yaml +3 -0
  6. package/agentic/orchestrator/schemas/agents.schema.json +76 -0
  7. package/agentic/orchestrator/schemas/policy.schema.json +16 -0
  8. package/agentic/orchestrator/schemas/policy.user.schema.json +16 -0
  9. package/agentic/orchestrator/schemas/state.schema.json +53 -0
  10. package/apps/control-plane/src/application/configuration-service.ts +181 -0
  11. package/apps/control-plane/src/application/kernel-tool-wiring.ts +292 -0
  12. package/apps/control-plane/src/application/services/checkpoint-service.ts +523 -0
  13. package/apps/control-plane/src/application/services/feature-send-message-service.ts +132 -0
  14. package/apps/control-plane/src/application/services/patch-service.ts +29 -5
  15. package/apps/control-plane/src/application/services/repo-operations-service.ts +276 -0
  16. package/apps/control-plane/src/application/services/worktree-watchdog-service.ts +156 -0
  17. package/apps/control-plane/src/cli/cli-argument-parser.ts +12 -0
  18. package/apps/control-plane/src/cli/help-command-handler.ts +17 -0
  19. package/apps/control-plane/src/cli/init-command-handler.ts +31 -0
  20. package/apps/control-plane/src/cli/resume-command-handler.ts +31 -4
  21. package/apps/control-plane/src/cli/rollback-command-handler.ts +217 -0
  22. package/apps/control-plane/src/cli/run-command-handler.ts +8 -0
  23. package/apps/control-plane/src/cli/types.ts +3 -0
  24. package/apps/control-plane/src/core/kernel-types.ts +55 -0
  25. package/apps/control-plane/src/core/kernel.ts +61 -878
  26. package/apps/control-plane/src/core/tool-caller.ts +10 -0
  27. package/apps/control-plane/src/core/utils/field-readers.ts +38 -0
  28. package/apps/control-plane/src/core/utils/index-normalizer.ts +119 -0
  29. package/apps/control-plane/src/core/utils/path-normalizers.ts +22 -0
  30. package/apps/control-plane/src/interfaces/cli/bootstrap.ts +15 -0
  31. package/apps/control-plane/src/providers/api-worker-provider.ts +14 -12
  32. package/apps/control-plane/src/providers/cli-worker-provider.ts +82 -12
  33. package/apps/control-plane/src/providers/providers.ts +45 -24
  34. package/apps/control-plane/src/providers/worker-provider-factory.ts +36 -1
  35. package/apps/control-plane/src/supervisor/run-coordinator.ts +91 -36
  36. package/apps/control-plane/src/supervisor/runtime.ts +107 -1
  37. package/apps/control-plane/src/supervisor/types.ts +9 -0
  38. package/apps/control-plane/src/supervisor/worker-decision-loop.ts +253 -14
  39. package/apps/control-plane/test/checkpoint-service.spec.ts +537 -0
  40. package/apps/control-plane/test/cli-helpers.spec.ts +28 -0
  41. package/apps/control-plane/test/cli.unit.spec.ts +52 -0
  42. package/apps/control-plane/test/configuration-service.spec.ts +466 -0
  43. package/apps/control-plane/test/dashboard-api.integration.spec.ts +537 -0
  44. package/apps/control-plane/test/dashboard-client.spec.ts +233 -0
  45. package/apps/control-plane/test/feature-send-message-service.spec.ts +314 -0
  46. package/apps/control-plane/test/init-wizard.spec.ts +35 -0
  47. package/apps/control-plane/test/path-normalizers.spec.ts +41 -0
  48. package/apps/control-plane/test/repo-operations-service.spec.ts +339 -0
  49. package/apps/control-plane/test/resume-command.spec.ts +33 -0
  50. package/apps/control-plane/test/review-workspace-logic.spec.ts +130 -0
  51. package/apps/control-plane/test/rollback-command.spec.ts +208 -0
  52. package/apps/control-plane/test/run-coordinator.spec.ts +119 -0
  53. package/apps/control-plane/test/worker-decision-loop.spec.ts +209 -0
  54. package/apps/control-plane/test/worker-provider-adapters.spec.ts +102 -0
  55. package/apps/control-plane/test/worker-provider-factory.spec.ts +14 -0
  56. package/apps/control-plane/test/worktree-watchdog-service.spec.ts +147 -0
  57. package/config/agentic/orchestrator/agents.yaml +13 -0
  58. package/dist/apps/control-plane/application/configuration-service.d.ts +19 -0
  59. package/dist/apps/control-plane/application/configuration-service.js +123 -0
  60. package/dist/apps/control-plane/application/configuration-service.js.map +1 -0
  61. package/dist/apps/control-plane/application/kernel-tool-wiring.d.ts +39 -0
  62. package/dist/apps/control-plane/application/kernel-tool-wiring.js +38 -0
  63. package/dist/apps/control-plane/application/kernel-tool-wiring.js.map +1 -0
  64. package/dist/apps/control-plane/application/services/checkpoint-service.d.ts +84 -0
  65. package/dist/apps/control-plane/application/services/checkpoint-service.js +367 -0
  66. package/dist/apps/control-plane/application/services/checkpoint-service.js.map +1 -0
  67. package/dist/apps/control-plane/application/services/feature-send-message-service.d.ts +25 -0
  68. package/dist/apps/control-plane/application/services/feature-send-message-service.js +105 -0
  69. package/dist/apps/control-plane/application/services/feature-send-message-service.js.map +1 -0
  70. package/dist/apps/control-plane/application/services/patch-service.d.ts +6 -0
  71. package/dist/apps/control-plane/application/services/patch-service.js +11 -2
  72. package/dist/apps/control-plane/application/services/patch-service.js.map +1 -1
  73. package/dist/apps/control-plane/application/services/repo-operations-service.d.ts +70 -0
  74. package/dist/apps/control-plane/application/services/repo-operations-service.js +213 -0
  75. package/dist/apps/control-plane/application/services/repo-operations-service.js.map +1 -0
  76. package/dist/apps/control-plane/application/services/worktree-watchdog-service.d.ts +23 -0
  77. package/dist/apps/control-plane/application/services/worktree-watchdog-service.js +119 -0
  78. package/dist/apps/control-plane/application/services/worktree-watchdog-service.js.map +1 -0
  79. package/dist/apps/control-plane/cli/cli-argument-parser.js +12 -0
  80. package/dist/apps/control-plane/cli/cli-argument-parser.js.map +1 -1
  81. package/dist/apps/control-plane/cli/help-command-handler.js +17 -0
  82. package/dist/apps/control-plane/cli/help-command-handler.js.map +1 -1
  83. package/dist/apps/control-plane/cli/init-command-handler.js +23 -0
  84. package/dist/apps/control-plane/cli/init-command-handler.js.map +1 -1
  85. package/dist/apps/control-plane/cli/resume-command-handler.js +25 -5
  86. package/dist/apps/control-plane/cli/resume-command-handler.js.map +1 -1
  87. package/dist/apps/control-plane/cli/rollback-command-handler.d.ts +6 -0
  88. package/dist/apps/control-plane/cli/rollback-command-handler.js +177 -0
  89. package/dist/apps/control-plane/cli/rollback-command-handler.js.map +1 -0
  90. package/dist/apps/control-plane/cli/run-command-handler.js +7 -1
  91. package/dist/apps/control-plane/cli/run-command-handler.js.map +1 -1
  92. package/dist/apps/control-plane/cli/types.d.ts +3 -0
  93. package/dist/apps/control-plane/cli/types.js +1 -0
  94. package/dist/apps/control-plane/cli/types.js.map +1 -1
  95. package/dist/apps/control-plane/core/configuration-service.d.ts +25 -0
  96. package/dist/apps/control-plane/core/configuration-service.js +130 -0
  97. package/dist/apps/control-plane/core/configuration-service.js.map +1 -0
  98. package/dist/apps/control-plane/core/kernel-tool-wiring.d.ts +50 -0
  99. package/dist/apps/control-plane/core/kernel-tool-wiring.js +44 -0
  100. package/dist/apps/control-plane/core/kernel-tool-wiring.js.map +1 -0
  101. package/dist/apps/control-plane/core/kernel-types.d.ts +48 -0
  102. package/dist/apps/control-plane/core/kernel-types.js +2 -0
  103. package/dist/apps/control-plane/core/kernel-types.js.map +1 -0
  104. package/dist/apps/control-plane/core/kernel.d.ts +17 -48
  105. package/dist/apps/control-plane/core/kernel.js +44 -539
  106. package/dist/apps/control-plane/core/kernel.js.map +1 -1
  107. package/dist/apps/control-plane/core/tool-caller.d.ts +10 -0
  108. package/dist/apps/control-plane/core/utils/error-normalizer.d.ts +2 -0
  109. package/dist/apps/control-plane/core/utils/error-normalizer.js +51 -0
  110. package/dist/apps/control-plane/core/utils/error-normalizer.js.map +1 -0
  111. package/dist/apps/control-plane/core/utils/field-readers.d.ts +9 -0
  112. package/dist/apps/control-plane/core/utils/field-readers.js +30 -0
  113. package/dist/apps/control-plane/core/utils/field-readers.js.map +1 -0
  114. package/dist/apps/control-plane/core/utils/index-normalizer.d.ts +7 -0
  115. package/dist/apps/control-plane/core/utils/index-normalizer.js +92 -0
  116. package/dist/apps/control-plane/core/utils/index-normalizer.js.map +1 -0
  117. package/dist/apps/control-plane/core/utils/path-normalizers.d.ts +2 -0
  118. package/dist/apps/control-plane/core/utils/path-normalizers.js +17 -0
  119. package/dist/apps/control-plane/core/utils/path-normalizers.js.map +1 -0
  120. package/dist/apps/control-plane/interfaces/cli/bootstrap.js +13 -1
  121. package/dist/apps/control-plane/interfaces/cli/bootstrap.js.map +1 -1
  122. package/dist/apps/control-plane/providers/api-worker-provider.d.ts +4 -13
  123. package/dist/apps/control-plane/providers/api-worker-provider.js +10 -0
  124. package/dist/apps/control-plane/providers/api-worker-provider.js.map +1 -1
  125. package/dist/apps/control-plane/providers/cli-worker-provider.d.ts +11 -13
  126. package/dist/apps/control-plane/providers/cli-worker-provider.js +64 -0
  127. package/dist/apps/control-plane/providers/cli-worker-provider.js.map +1 -1
  128. package/dist/apps/control-plane/providers/providers.d.ts +31 -24
  129. package/dist/apps/control-plane/providers/providers.js +10 -0
  130. package/dist/apps/control-plane/providers/providers.js.map +1 -1
  131. package/dist/apps/control-plane/providers/worker-provider-factory.d.ts +11 -0
  132. package/dist/apps/control-plane/providers/worker-provider-factory.js +20 -1
  133. package/dist/apps/control-plane/providers/worker-provider-factory.js.map +1 -1
  134. package/dist/apps/control-plane/supervisor/run-coordinator.d.ts +3 -0
  135. package/dist/apps/control-plane/supervisor/run-coordinator.js +81 -33
  136. package/dist/apps/control-plane/supervisor/run-coordinator.js.map +1 -1
  137. package/dist/apps/control-plane/supervisor/runtime.d.ts +8 -1
  138. package/dist/apps/control-plane/supervisor/runtime.js +90 -0
  139. package/dist/apps/control-plane/supervisor/runtime.js.map +1 -1
  140. package/dist/apps/control-plane/supervisor/types.d.ts +11 -0
  141. package/dist/apps/control-plane/supervisor/types.js.map +1 -1
  142. package/dist/apps/control-plane/supervisor/worker-decision-loop.d.ts +21 -1
  143. package/dist/apps/control-plane/supervisor/worker-decision-loop.js +207 -13
  144. package/dist/apps/control-plane/supervisor/worker-decision-loop.js.map +1 -1
  145. package/package.json +1 -1
  146. package/packages/web-dashboard/package.json +2 -0
  147. package/packages/web-dashboard/src/app/analytics/page.tsx +83 -2
  148. package/packages/web-dashboard/src/app/api/actions/route.ts +92 -1
  149. package/packages/web-dashboard/src/app/api/analytics/route.ts +5 -2
  150. package/packages/web-dashboard/src/app/api/features/[id]/checkpoints/[checkpointId]/diff/route.ts +43 -0
  151. package/packages/web-dashboard/src/app/api/features/[id]/checkpoints/compare/route.ts +45 -0
  152. package/packages/web-dashboard/src/app/api/features/[id]/checkpoints/stream/route.ts +170 -0
  153. package/packages/web-dashboard/src/app/api/features/[id]/file-diff/route.ts +144 -0
  154. package/packages/web-dashboard/src/app/api/features/[id]/log-stream/route.ts +167 -0
  155. package/packages/web-dashboard/src/app/api/features/[id]/raw-logs/[filename]/route.ts +65 -0
  156. package/packages/web-dashboard/src/app/api/features/[id]/raw-logs/route.ts +63 -0
  157. package/packages/web-dashboard/src/app/api/features/[id]/timeline/route.ts +60 -0
  158. package/packages/web-dashboard/src/app/feature/[id]/page.tsx +32 -11
  159. package/packages/web-dashboard/src/app/globals.css +2 -0
  160. package/packages/web-dashboard/src/components/detail-panel.tsx +483 -0
  161. package/packages/web-dashboard/src/components/review-workspace.tsx +1162 -0
  162. package/packages/web-dashboard/src/lib/aop-client.ts +725 -0
  163. package/packages/web-dashboard/src/lib/review-contracts.ts +182 -0
  164. package/packages/web-dashboard/src/lib/review-workspace-logic.ts +64 -0
  165. package/packages/web-dashboard/src/lib/types.ts +131 -0
  166. package/packages/web-dashboard/src/styles/dashboard.module.css +333 -0
  167. package/spec-files/completed/agentic_orchestrator_execution_mode_spec.md +1905 -0
  168. package/spec-files/outstanding/agentic_orchestrator_runtime_inspection_spec.md +940 -0
  169. package/spec-files/outstanding/execution_mode_critical_review.md +355 -0
  170. package/spec-files/outstanding/shadow_workspace_implementation_spec.md +1271 -0
  171. package/spec-files/outstanding/shadow_workspace_spec_summary.md +222 -0
  172. package/spec-files/progress.md +269 -1
@@ -0,0 +1,523 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { atomicWriteJson, ensureDir, nowIso, readJson } from '../../core/fs.js';
5
+ import { runGit } from '../../core/git.js';
6
+ import { parseUnifiedDiff } from '../../core/patch.js';
7
+ import type { PatchValidationResult } from './patch-service.js';
8
+ import type { WorktreeWatchdogService } from './worktree-watchdog-service.js';
9
+
10
+ export type ViolationSeverity = 'info' | 'warning' | 'error' | 'critical';
11
+ export type CheckpointValidationStatus = 'valid' | 'invalid' | 'skipped';
12
+ const INTERACTIVE_METRICS_HISTORY_LIMIT = 200;
13
+
14
+ export interface InteractiveExecutionConfig {
15
+ checkpointIntervalMs: number;
16
+ watchdogPollIntervalMs: number;
17
+ maxUncommittedChanges: number;
18
+ validationOnCheckpoint: boolean;
19
+ revertOnViolation: boolean;
20
+ violationSeverity: ViolationSeverity;
21
+ }
22
+
23
+ export interface CreateCheckpointInput {
24
+ featureId: string;
25
+ sessionId?: string | null;
26
+ trigger: 'interval' | 'change_threshold' | 'final';
27
+ }
28
+
29
+ export interface CheckpointRecord {
30
+ checkpoint_id: string;
31
+ timestamp: string;
32
+ files_changed: string[];
33
+ validation_status: CheckpointValidationStatus;
34
+ violations: string[];
35
+ severity?: ViolationSeverity;
36
+ diff_snapshot: string;
37
+ }
38
+
39
+ interface InteractiveCheckpointMetric {
40
+ recorded_at: string;
41
+ feature_id: string;
42
+ checkpoint_id: string;
43
+ trigger: CreateCheckpointInput['trigger'];
44
+ validation_status: CheckpointValidationStatus;
45
+ checkpoint_latency_ms: number;
46
+ validation_latency_ms: number;
47
+ diff_capture_latency_ms: number;
48
+ files_changed: number;
49
+ }
50
+
51
+ interface InteractiveMetricsSnapshot {
52
+ schema_version: number;
53
+ updated_at: string;
54
+ totals: {
55
+ checkpoint_count: number;
56
+ valid_count: number;
57
+ invalid_count: number;
58
+ skipped_count: number;
59
+ files_changed_total: number;
60
+ };
61
+ histories: {
62
+ checkpoint_latency_ms: number[];
63
+ validation_latency_ms: number[];
64
+ diff_capture_latency_ms: number[];
65
+ };
66
+ latest: InteractiveCheckpointMetric | null;
67
+ }
68
+
69
+ interface UpdateStateFn {
70
+ (
71
+ featureId: string,
72
+ expectedVersion: number | null,
73
+ updater: (
74
+ frontMatter: Record<string, unknown>,
75
+ body: string,
76
+ ) => Promise<{ frontMatter?: Record<string, unknown>; body?: string }>,
77
+ ): Promise<Record<string, unknown>>;
78
+ }
79
+
80
+ interface CheckpointServiceDependencies {
81
+ repoRoot: string;
82
+ featurePathResolver: (featureId: string) => string;
83
+ worktreePathResolver: (featureId: string) => string;
84
+ validateDiff: (
85
+ featureId: string,
86
+ parsedDiff: ReturnType<typeof parseUnifiedDiff>,
87
+ ) => Promise<PatchValidationResult>;
88
+ watchdog: WorktreeWatchdogService;
89
+ provider: CheckpointProviderPort;
90
+ readState: (featureId: string) => Promise<{ frontMatter: Record<string, unknown> }>;
91
+ updateState: UpdateStateFn;
92
+ config: InteractiveExecutionConfig;
93
+ }
94
+
95
+ /**
96
+ * Minimal provider contract used by checkpoint validation for agent notifications.
97
+ *
98
+ * This keeps application services decoupled from provider-layer runtime interfaces.
99
+ */
100
+ export interface CheckpointProviderPort {
101
+ sendMessage?: (sessionId: string, message: string) => Promise<void>;
102
+ }
103
+
104
+ function readStringArray(value: unknown): string[] {
105
+ if (!Array.isArray(value)) {
106
+ return [];
107
+ }
108
+ return value
109
+ .filter((item): item is string => typeof item === 'string')
110
+ .map((item) => item.trim())
111
+ .filter((item) => item.length > 0);
112
+ }
113
+
114
+ function extractViolationMessages(error: unknown): string[] {
115
+ const envelope =
116
+ error && typeof error === 'object'
117
+ ? (error as { normalizedResponse?: { error?: { details?: Record<string, unknown> } } })
118
+ .normalizedResponse
119
+ : undefined;
120
+ const details = envelope?.error?.details;
121
+ if (!details || typeof details !== 'object') {
122
+ return ['Checkpoint validation failed'];
123
+ }
124
+
125
+ const violations = details['violations'];
126
+ if (!Array.isArray(violations)) {
127
+ const message =
128
+ typeof envelope?.error?.details?.message === 'string' ? envelope.error.details.message : null;
129
+ return message ? [message] : ['Checkpoint validation failed'];
130
+ }
131
+
132
+ const rendered = violations
133
+ .map((entry) => {
134
+ if (!entry || typeof entry !== 'object') {
135
+ return null;
136
+ }
137
+ const pathValue = (entry as { path?: unknown }).path;
138
+ const reasonValue = (entry as { reason?: unknown }).reason;
139
+ const normalizedPath = typeof pathValue === 'string' ? pathValue : 'unknown_path';
140
+ const normalizedReason =
141
+ typeof reasonValue === 'string' ? reasonValue : 'constraint_violation';
142
+ return `${normalizedPath}: ${normalizedReason}`;
143
+ })
144
+ .filter((value): value is string => typeof value === 'string');
145
+
146
+ return rendered.length > 0 ? rendered : ['Checkpoint validation failed'];
147
+ }
148
+
149
+ function severityRank(severity: ViolationSeverity): number {
150
+ switch (severity) {
151
+ case 'info':
152
+ return 0;
153
+ case 'warning':
154
+ return 1;
155
+ case 'error':
156
+ return 2;
157
+ case 'critical':
158
+ return 3;
159
+ default:
160
+ return 1;
161
+ }
162
+ }
163
+
164
+ function clampHistory(values: number[]): number[] {
165
+ if (values.length <= INTERACTIVE_METRICS_HISTORY_LIMIT) {
166
+ return values;
167
+ }
168
+ return values.slice(values.length - INTERACTIVE_METRICS_HISTORY_LIMIT);
169
+ }
170
+
171
+ function toFiniteNumberArray(value: unknown): number[] {
172
+ if (!Array.isArray(value)) {
173
+ return [];
174
+ }
175
+ return value.filter(
176
+ (entry): entry is number => typeof entry === 'number' && Number.isFinite(entry),
177
+ );
178
+ }
179
+
180
+ function buildEmptyInteractiveMetricsSnapshot(): InteractiveMetricsSnapshot {
181
+ return {
182
+ schema_version: 1,
183
+ updated_at: nowIso(),
184
+ totals: {
185
+ checkpoint_count: 0,
186
+ valid_count: 0,
187
+ invalid_count: 0,
188
+ skipped_count: 0,
189
+ files_changed_total: 0,
190
+ },
191
+ histories: {
192
+ checkpoint_latency_ms: [],
193
+ validation_latency_ms: [],
194
+ diff_capture_latency_ms: [],
195
+ },
196
+ latest: null,
197
+ };
198
+ }
199
+
200
+ /**
201
+ * Captures and validates interactive-mode checkpoints against plan/policy/locks.
202
+ */
203
+ export class CheckpointService {
204
+ private readonly repoRoot: string;
205
+ private readonly featurePathResolver: (featureId: string) => string;
206
+ private readonly worktreePathResolver: (featureId: string) => string;
207
+ private readonly validateDiff: (
208
+ featureId: string,
209
+ parsedDiff: ReturnType<typeof parseUnifiedDiff>,
210
+ ) => Promise<PatchValidationResult>;
211
+ private readonly watchdog: WorktreeWatchdogService;
212
+ private readonly provider: CheckpointProviderPort;
213
+ private readonly readState: (
214
+ featureId: string,
215
+ ) => Promise<{ frontMatter: Record<string, unknown> }>;
216
+ private readonly updateState: UpdateStateFn;
217
+ private readonly config: InteractiveExecutionConfig;
218
+ private readonly inFlightByFeature = new Map<string, Promise<void>>();
219
+
220
+ constructor(dependencies: CheckpointServiceDependencies) {
221
+ this.repoRoot = dependencies.repoRoot;
222
+ this.featurePathResolver = dependencies.featurePathResolver;
223
+ this.worktreePathResolver = dependencies.worktreePathResolver;
224
+ this.validateDiff = dependencies.validateDiff;
225
+ this.watchdog = dependencies.watchdog;
226
+ this.provider = dependencies.provider;
227
+ this.readState = dependencies.readState;
228
+ this.updateState = dependencies.updateState;
229
+ this.config = dependencies.config;
230
+ }
231
+
232
+ async createCheckpoint(input: CreateCheckpointInput): Promise<{
233
+ checkpoint: CheckpointRecord;
234
+ valid: boolean;
235
+ blockMerge: boolean;
236
+ }> {
237
+ return await this.withFeatureLock(input.featureId, async () => {
238
+ const checkpointStartedAt = Date.now();
239
+ const worktreePath = this.worktreePathResolver(input.featureId);
240
+ const filesChanged = await this.watchdog.getChangedFiles(input.featureId);
241
+
242
+ const diffCaptureStartedAt = Date.now();
243
+ const diffResult = await runGit(this.repoRoot, ['diff', '--no-ext-diff', '--binary'], {
244
+ cwd: worktreePath,
245
+ });
246
+ const diffText = diffResult.code === 0 ? diffResult.stdout : '';
247
+ const diffCaptureLatencyMs = Date.now() - diffCaptureStartedAt;
248
+
249
+ const checkpointId = `checkpoint-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
250
+ const checkpointDir = path.join(this.featurePathResolver(input.featureId), 'checkpoints');
251
+ await ensureDir(checkpointDir);
252
+ const checkpointFile = `${checkpointId}.diff`;
253
+ const checkpointPath = path.join(checkpointDir, checkpointFile);
254
+ await fs.writeFile(checkpointPath, diffText, 'utf8');
255
+
256
+ let validationStatus: CheckpointValidationStatus = 'skipped';
257
+ let violations: string[] = [];
258
+ let severity: ViolationSeverity | undefined;
259
+ let validationResult: PatchValidationResult | null = null;
260
+ let validationLatencyMs = 0;
261
+
262
+ if (this.config.validationOnCheckpoint && diffText.trim().length > 0) {
263
+ const validationStartedAt = Date.now();
264
+ try {
265
+ const parsedDiff = parseUnifiedDiff(diffText);
266
+ validationResult = await this.validateDiff(input.featureId, parsedDiff);
267
+ validationStatus = validationResult.valid ? 'valid' : 'invalid';
268
+ violations = validationResult.violations;
269
+ } catch (error) {
270
+ validationStatus = 'invalid';
271
+ violations = extractViolationMessages(error);
272
+ } finally {
273
+ validationLatencyMs = Date.now() - validationStartedAt;
274
+ }
275
+ }
276
+
277
+ if (validationStatus === 'invalid') {
278
+ severity = this.config.violationSeverity;
279
+ await this.notifyAgent(input, checkpointId, filesChanged, violations, severity);
280
+ if (this.config.revertOnViolation && filesChanged.length > 0) {
281
+ await runGit(this.repoRoot, ['checkout', '--', ...filesChanged], {
282
+ cwd: worktreePath,
283
+ });
284
+ }
285
+ }
286
+
287
+ const checkpointRecord: CheckpointRecord = {
288
+ checkpoint_id: checkpointId,
289
+ timestamp: nowIso(),
290
+ files_changed: filesChanged,
291
+ validation_status: validationStatus,
292
+ violations,
293
+ severity,
294
+ diff_snapshot: path
295
+ .join('.aop', 'features', input.featureId, 'checkpoints', checkpointFile)
296
+ .replaceAll('\\\\', '/'),
297
+ };
298
+
299
+ await this.recordCheckpoint(input.featureId, checkpointRecord);
300
+ this.watchdog.resetChangeCount(input.featureId);
301
+
302
+ const checkpointLatencyMs = Date.now() - checkpointStartedAt;
303
+ await this.recordInteractiveMetrics({
304
+ recorded_at: checkpointRecord.timestamp,
305
+ feature_id: input.featureId,
306
+ checkpoint_id: checkpointRecord.checkpoint_id,
307
+ trigger: input.trigger,
308
+ validation_status: checkpointRecord.validation_status,
309
+ checkpoint_latency_ms: checkpointLatencyMs,
310
+ validation_latency_ms: validationLatencyMs,
311
+ diff_capture_latency_ms: diffCaptureLatencyMs,
312
+ files_changed: filesChanged.length,
313
+ });
314
+
315
+ const blockMerge =
316
+ validationStatus === 'invalid' &&
317
+ severity !== undefined &&
318
+ severityRank(severity) >= severityRank('error');
319
+
320
+ return {
321
+ checkpoint: checkpointRecord,
322
+ valid: validationResult?.valid ?? validationStatus !== 'invalid',
323
+ blockMerge,
324
+ };
325
+ });
326
+ }
327
+
328
+ async getCheckpoints(featureId: string): Promise<CheckpointRecord[]> {
329
+ const state = await this.readState(featureId);
330
+ return this.readCheckpoints(state.frontMatter);
331
+ }
332
+
333
+ private async notifyAgent(
334
+ input: CreateCheckpointInput,
335
+ checkpointId: string,
336
+ filesChanged: string[],
337
+ violations: string[],
338
+ severity: ViolationSeverity,
339
+ ): Promise<void> {
340
+ if (!input.sessionId || typeof this.provider.sendMessage !== 'function') {
341
+ return;
342
+ }
343
+
344
+ const payload = {
345
+ type: 'checkpoint_violation',
346
+ severity,
347
+ checkpoint_id: checkpointId,
348
+ trigger: input.trigger,
349
+ files_changed: filesChanged,
350
+ violations,
351
+ action_taken: this.config.revertOnViolation ? 'reverted' : 'none',
352
+ };
353
+
354
+ try {
355
+ await this.provider.sendMessage(input.sessionId, JSON.stringify(payload));
356
+ } catch {
357
+ // Notification failure must not break checkpoint persistence.
358
+ }
359
+ }
360
+
361
+ private async recordCheckpoint(featureId: string, checkpoint: CheckpointRecord): Promise<void> {
362
+ await this.updateState(featureId, null, (frontMatter, body) => {
363
+ const current = this.readCheckpoints(frontMatter);
364
+ return Promise.resolve({
365
+ frontMatter: {
366
+ execution_mode: frontMatter['execution_mode'] ?? 'interactive',
367
+ checkpoints: [...current, checkpoint],
368
+ },
369
+ body,
370
+ });
371
+ });
372
+ }
373
+
374
+ private readCheckpoints(frontMatter: Record<string, unknown>): CheckpointRecord[] {
375
+ const raw = frontMatter['checkpoints'];
376
+ if (!Array.isArray(raw)) {
377
+ return [];
378
+ }
379
+ const normalized: CheckpointRecord[] = [];
380
+ for (const entry of raw) {
381
+ if (!entry || typeof entry !== 'object') {
382
+ continue;
383
+ }
384
+ const record = entry as Record<string, unknown>;
385
+ const checkpointId =
386
+ typeof record['checkpoint_id'] === 'string' ? record['checkpoint_id'] : null;
387
+ const timestamp = typeof record['timestamp'] === 'string' ? record['timestamp'] : null;
388
+ const validationStatus =
389
+ record['validation_status'] === 'valid' ||
390
+ record['validation_status'] === 'invalid' ||
391
+ record['validation_status'] === 'skipped'
392
+ ? record['validation_status']
393
+ : null;
394
+ const diffSnapshot =
395
+ typeof record['diff_snapshot'] === 'string' ? record['diff_snapshot'] : null;
396
+ if (!checkpointId || !timestamp || !validationStatus || !diffSnapshot) {
397
+ continue;
398
+ }
399
+ const severityValue =
400
+ record['severity'] === 'info' ||
401
+ record['severity'] === 'warning' ||
402
+ record['severity'] === 'error' ||
403
+ record['severity'] === 'critical'
404
+ ? (record['severity'] as ViolationSeverity)
405
+ : undefined;
406
+ normalized.push({
407
+ checkpoint_id: checkpointId,
408
+ timestamp,
409
+ files_changed: readStringArray(record['files_changed']),
410
+ validation_status: validationStatus,
411
+ violations: readStringArray(record['violations']),
412
+ severity: severityValue,
413
+ diff_snapshot: diffSnapshot,
414
+ });
415
+ }
416
+ return normalized;
417
+ }
418
+
419
+ private interactiveMetricsPath(): string {
420
+ return path.join(this.repoRoot, '.aop', 'analytics', 'interactive-mode.json');
421
+ }
422
+
423
+ private normalizeMetricsSnapshot(snapshot: unknown): InteractiveMetricsSnapshot {
424
+ if (!snapshot || typeof snapshot !== 'object') {
425
+ return buildEmptyInteractiveMetricsSnapshot();
426
+ }
427
+ const record = snapshot as Record<string, unknown>;
428
+ const totalsRaw =
429
+ record['totals'] && typeof record['totals'] === 'object'
430
+ ? (record['totals'] as Record<string, unknown>)
431
+ : {};
432
+ const historiesRaw =
433
+ record['histories'] && typeof record['histories'] === 'object'
434
+ ? (record['histories'] as Record<string, unknown>)
435
+ : {};
436
+ return {
437
+ schema_version: 1,
438
+ updated_at: typeof record['updated_at'] === 'string' ? record['updated_at'] : nowIso(),
439
+ totals: {
440
+ checkpoint_count:
441
+ typeof totalsRaw['checkpoint_count'] === 'number' ? totalsRaw['checkpoint_count'] : 0,
442
+ valid_count: typeof totalsRaw['valid_count'] === 'number' ? totalsRaw['valid_count'] : 0,
443
+ invalid_count:
444
+ typeof totalsRaw['invalid_count'] === 'number' ? totalsRaw['invalid_count'] : 0,
445
+ skipped_count:
446
+ typeof totalsRaw['skipped_count'] === 'number' ? totalsRaw['skipped_count'] : 0,
447
+ files_changed_total:
448
+ typeof totalsRaw['files_changed_total'] === 'number'
449
+ ? totalsRaw['files_changed_total']
450
+ : 0,
451
+ },
452
+ histories: {
453
+ checkpoint_latency_ms: toFiniteNumberArray(historiesRaw['checkpoint_latency_ms']),
454
+ validation_latency_ms: toFiniteNumberArray(historiesRaw['validation_latency_ms']),
455
+ diff_capture_latency_ms: toFiniteNumberArray(historiesRaw['diff_capture_latency_ms']),
456
+ },
457
+ latest:
458
+ record['latest'] && typeof record['latest'] === 'object'
459
+ ? (record['latest'] as InteractiveCheckpointMetric)
460
+ : null,
461
+ };
462
+ }
463
+
464
+ private async recordInteractiveMetrics(metric: InteractiveCheckpointMetric): Promise<void> {
465
+ try {
466
+ const filePath = this.interactiveMetricsPath();
467
+ await ensureDir(path.dirname(filePath));
468
+ const existing = await readJson<InteractiveMetricsSnapshot>(filePath, null);
469
+ const snapshot = this.normalizeMetricsSnapshot(existing);
470
+
471
+ snapshot.updated_at = nowIso();
472
+ snapshot.totals.checkpoint_count += 1;
473
+ snapshot.totals.files_changed_total += metric.files_changed;
474
+ if (metric.validation_status === 'valid') {
475
+ snapshot.totals.valid_count += 1;
476
+ } else if (metric.validation_status === 'invalid') {
477
+ snapshot.totals.invalid_count += 1;
478
+ } else {
479
+ snapshot.totals.skipped_count += 1;
480
+ }
481
+
482
+ snapshot.histories.checkpoint_latency_ms = clampHistory([
483
+ ...snapshot.histories.checkpoint_latency_ms,
484
+ metric.checkpoint_latency_ms,
485
+ ]);
486
+ snapshot.histories.validation_latency_ms = clampHistory([
487
+ ...snapshot.histories.validation_latency_ms,
488
+ metric.validation_latency_ms,
489
+ ]);
490
+ snapshot.histories.diff_capture_latency_ms = clampHistory([
491
+ ...snapshot.histories.diff_capture_latency_ms,
492
+ metric.diff_capture_latency_ms,
493
+ ]);
494
+ snapshot.latest = metric;
495
+
496
+ await atomicWriteJson(filePath, snapshot);
497
+ } catch {
498
+ // Performance metrics persistence is best-effort and must not fail checkpoints.
499
+ }
500
+ }
501
+
502
+ private async withFeatureLock<T>(featureId: string, operation: () => Promise<T>): Promise<T> {
503
+ const existing = this.inFlightByFeature.get(featureId);
504
+ if (existing !== undefined) {
505
+ await existing.catch(() => undefined);
506
+ }
507
+
508
+ let release: (() => void) | null = null;
509
+ const gate = new Promise<void>((resolve) => {
510
+ release = resolve;
511
+ });
512
+ this.inFlightByFeature.set(featureId, gate);
513
+
514
+ try {
515
+ return await operation();
516
+ } finally {
517
+ release?.();
518
+ if (this.inFlightByFeature.get(featureId) === gate) {
519
+ this.inFlightByFeature.delete(featureId);
520
+ }
521
+ }
522
+ }
523
+ }
@@ -0,0 +1,132 @@
1
+ import { ERROR_CODES } from '../../core/error-codes.js';
2
+ import { fail } from '../../core/response.js';
3
+ import { STATUS, GATE_RESULT } from '../../core/constants.js';
4
+ import type { RuntimeSessionsSnapshot } from '../../core/runtime-sessions.js';
5
+ import { readStringField, readObjectField } from '../../core/utils/field-readers.js';
6
+
7
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
8
+ type AnyRecord = Record<string, any>;
9
+
10
+ interface FeatureSendMessageProvider {
11
+ sendMessage?(sessionId: string, message: string): Promise<void>;
12
+ getSessionInfo?(sessionId: string): Promise<{ active: boolean; provider: string }>;
13
+ }
14
+
15
+ export interface FeatureSendMessageServicePort {
16
+ readState(featureId: string): Promise<{ frontMatter: AnyRecord; body: string; raw: string }>;
17
+ getRuntimeSessions(): Promise<RuntimeSessionsSnapshot>;
18
+ getProvider(): FeatureSendMessageProvider | null;
19
+ }
20
+
21
+ export class FeatureSendMessageService {
22
+ private readonly port: FeatureSendMessageServicePort;
23
+
24
+ constructor(port: FeatureSendMessageServicePort) {
25
+ this.port = port;
26
+ }
27
+
28
+ private async waitForSessionToBecomeActive(sessionId: string): Promise<void> {
29
+ const provider = this.port.getProvider();
30
+ if (!provider?.getSessionInfo) {
31
+ return;
32
+ }
33
+
34
+ const timeoutMs = 5000;
35
+ const pollIntervalMs = 250;
36
+ const deadline = Date.now() + timeoutMs;
37
+
38
+ while (Date.now() <= deadline) {
39
+ const sessionInfo = await provider.getSessionInfo(sessionId).catch(() => null);
40
+ if (sessionInfo?.active) {
41
+ return;
42
+ }
43
+ await new Promise<void>((resolve) => {
44
+ setTimeout(resolve, pollIntervalMs);
45
+ });
46
+ }
47
+ }
48
+
49
+ async featureSendMessage(featureId: string | null, message: string | null): Promise<unknown> {
50
+ if (!featureId) {
51
+ throw {
52
+ normalizedResponse: fail(ERROR_CODES.INVALID_ARGUMENT, 'feature_id is required', {
53
+ retryable: false,
54
+ requires_human: false,
55
+ }),
56
+ };
57
+ }
58
+ if (!message) {
59
+ throw {
60
+ normalizedResponse: fail(
61
+ ERROR_CODES.INVALID_ARGUMENT,
62
+ 'message is required and must not be empty',
63
+ { retryable: false, requires_human: false },
64
+ ),
65
+ };
66
+ }
67
+
68
+ const runtimeSessions = await this.port.getRuntimeSessions();
69
+ const featureSession = runtimeSessions.feature_sessions?.[featureId];
70
+ if (!featureSession) {
71
+ throw {
72
+ normalizedResponse: {
73
+ ok: false,
74
+ error: { code: 'session_not_found', message: 'No active session cluster for feature' },
75
+ },
76
+ };
77
+ }
78
+
79
+ const state = await this.port.readState(featureId);
80
+ const status = typeof state.frontMatter.status === 'string' ? state.frontMatter.status : '';
81
+ const gates = readObjectField(state.frontMatter, 'gates');
82
+
83
+ let targetRole = 'orchestrator';
84
+ let targetSessionId = runtimeSessions.orchestrator_session_id;
85
+
86
+ if (status === STATUS.PLANNING) {
87
+ targetRole = 'planner';
88
+ targetSessionId = featureSession.planner_session_id;
89
+ } else if (status === STATUS.BUILDING) {
90
+ targetRole = 'builder';
91
+ targetSessionId = featureSession.builder_session_id;
92
+ } else if (status === STATUS.QA || status === STATUS.READY_TO_MERGE) {
93
+ targetRole = 'qa';
94
+ targetSessionId = featureSession.qa_session_id;
95
+ } else if (status === STATUS.BLOCKED) {
96
+ const fastGate = readStringField(gates, 'fast');
97
+ const fullGate = readStringField(gates, 'full');
98
+ if (fastGate === GATE_RESULT.FAIL && fullGate !== GATE_RESULT.FAIL) {
99
+ targetRole = 'builder';
100
+ targetSessionId = featureSession.builder_session_id;
101
+ } else {
102
+ targetRole = 'qa';
103
+ targetSessionId = featureSession.qa_session_id;
104
+ }
105
+ }
106
+
107
+ if (!targetSessionId || targetSessionId === 'unassigned' || targetSessionId === 'unknown') {
108
+ targetRole = 'orchestrator';
109
+ targetSessionId = runtimeSessions.orchestrator_session_id;
110
+ }
111
+
112
+ const provider = this.port.getProvider();
113
+ if (!provider?.sendMessage) {
114
+ throw {
115
+ normalizedResponse: {
116
+ ok: false,
117
+ error: { code: 'provider_unsupported', message: 'Provider does not support sendMessage' },
118
+ },
119
+ };
120
+ }
121
+
122
+ await this.waitForSessionToBecomeActive(targetSessionId);
123
+ await provider.sendMessage(targetSessionId, message);
124
+ return {
125
+ feature_id: featureId,
126
+ session_id: targetSessionId,
127
+ target_role: targetRole,
128
+ status,
129
+ delivered: true,
130
+ };
131
+ }
132
+ }
@@ -67,6 +67,12 @@ export interface PatchServicePort {
67
67
  ): Promise<AnyRecord>;
68
68
  }
69
69
 
70
+ export interface PatchValidationResult {
71
+ valid: boolean;
72
+ violations: string[];
73
+ changed_files: string[];
74
+ }
75
+
70
76
  /**
71
77
  * Manages patch validation and atomic application.
72
78
  *
@@ -313,11 +319,7 @@ export class PatchService {
313
319
  const profile = this.gateSelectionService.resolveProfile(resolvedProfile.profileName);
314
320
  const parsedDiff = parseUnifiedDiff(unifiedDiff);
315
321
 
316
- await this.validatePatchPaths(featureId, parsedDiff, plan);
317
- await this.assertPlanLocksHeld(
318
- featureId,
319
- plan ?? { contracts: { openapi: 'none', events: 'none', db: 'none' } },
320
- );
322
+ await this.validateDiff(featureId, parsedDiff, plan);
321
323
 
322
324
  const repoRoot = this.port.getRepoRoot();
323
325
  const worktree = this.port.worktreePath(featureId);
@@ -387,4 +389,26 @@ export class PatchService {
387
389
  },
388
390
  };
389
391
  }
392
+
393
+ async validateDiff(
394
+ featureId: string,
395
+ parsedDiff: unknown,
396
+ planOverride?: AnyRecord | null,
397
+ ): Promise<PatchValidationResult> {
398
+ const plan = planOverride ?? (await this.loadAcceptedPlan(featureId));
399
+ const changedFiles = await this.validatePatchPaths(
400
+ featureId,
401
+ parsedDiff as ReturnType<typeof parseUnifiedDiff>,
402
+ plan,
403
+ );
404
+ await this.assertPlanLocksHeld(
405
+ featureId,
406
+ plan ?? { contracts: { openapi: 'none', events: 'none', db: 'none' } },
407
+ );
408
+ return {
409
+ valid: true,
410
+ violations: [],
411
+ changed_files: changedFiles,
412
+ };
413
+ }
390
414
  }