@innofeight/global-workflow 0.0.1 → 0.0.2
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.
- package/README.md +32 -5
- package/package.json +12 -4
- package/provenance.json +294 -0
- package/rules/SHARED_WORKFLOW_RULES.md +653 -0
- package/scripts/lib/acceptance-mirror.mjs +398 -0
- package/scripts/lib/aet-v2.mjs +673 -0
- package/scripts/lib/clickup-task-comments.mjs +65 -0
- package/scripts/lib/post-merge-evidence.mjs +114 -0
- package/scripts/lib/task-branch.mjs +370 -0
- package/hello-world.mjs +0 -1
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
const SCHEMA = 'rn-launcher/aet-v2-event/v1'
|
|
2
|
+
const METRIC = 'AET-v2'
|
|
3
|
+
|
|
4
|
+
const EVENT_TYPES = new Set([
|
|
5
|
+
'AET_V2_LEDGER_DECLARED',
|
|
6
|
+
'AET_V2_SEGMENT_STARTED',
|
|
7
|
+
'AET_V2_SEGMENT_ENDED',
|
|
8
|
+
'AET_V2_ESTIMATE_REVISED',
|
|
9
|
+
'AET_V2_NON_AET_RECORDED',
|
|
10
|
+
'AET_V2_MILESTONE_RECORDED',
|
|
11
|
+
'AET_V2_CORRECTION_RECORDED'
|
|
12
|
+
])
|
|
13
|
+
|
|
14
|
+
const NON_AET_CLOCKS = new Set([
|
|
15
|
+
'human-attention',
|
|
16
|
+
'human-qa',
|
|
17
|
+
'human-unavailable',
|
|
18
|
+
'human-wait',
|
|
19
|
+
'external-system-wait'
|
|
20
|
+
])
|
|
21
|
+
|
|
22
|
+
const YIELD_CLOCKS = new Set(['human-wait', 'external-system-wait'])
|
|
23
|
+
const EXCLUSIVE_COVERAGE_CLOCKS = YIELD_CLOCKS
|
|
24
|
+
|
|
25
|
+
const COMMON_KEYS = [
|
|
26
|
+
'eventId',
|
|
27
|
+
'eventType',
|
|
28
|
+
'metric',
|
|
29
|
+
'recordedAt',
|
|
30
|
+
'schema',
|
|
31
|
+
'source'
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
const EVENT_KEYS = {
|
|
35
|
+
AET_V2_CORRECTION_RECORDED: ['reason', 'replacement', 'targetEventId'],
|
|
36
|
+
AET_V2_ESTIMATE_REVISED: [
|
|
37
|
+
'approvalSource',
|
|
38
|
+
'reason',
|
|
39
|
+
'revisedTotalEstimateMinutes',
|
|
40
|
+
'scopeDeltaMinutes',
|
|
41
|
+
'taskId',
|
|
42
|
+
'timestamp'
|
|
43
|
+
],
|
|
44
|
+
AET_V2_LEDGER_DECLARED: [
|
|
45
|
+
'calendarStartedAt',
|
|
46
|
+
'originalEstimateMinutes',
|
|
47
|
+
'taskId',
|
|
48
|
+
'taskTitle',
|
|
49
|
+
'workClass'
|
|
50
|
+
],
|
|
51
|
+
AET_V2_MILESTONE_RECORDED: ['evidence', 'milestone', 'taskId', 'timestamp'],
|
|
52
|
+
AET_V2_NON_AET_RECORDED: [
|
|
53
|
+
'clock',
|
|
54
|
+
'endedAt',
|
|
55
|
+
'evidence',
|
|
56
|
+
'startedAt',
|
|
57
|
+
'taskId'
|
|
58
|
+
],
|
|
59
|
+
AET_V2_SEGMENT_ENDED: [
|
|
60
|
+
'endReason',
|
|
61
|
+
'executionId',
|
|
62
|
+
'startEventId',
|
|
63
|
+
'taskId',
|
|
64
|
+
'timestamp'
|
|
65
|
+
],
|
|
66
|
+
AET_V2_SEGMENT_STARTED: [
|
|
67
|
+
'category',
|
|
68
|
+
'executionId',
|
|
69
|
+
'reason',
|
|
70
|
+
'taskId',
|
|
71
|
+
'timestamp'
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function fail(code, detail) {
|
|
76
|
+
throw new Error(`${code}: ${detail}`)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function record(value, label) {
|
|
80
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
81
|
+
fail('aet-v2.invalid-record', `${label} must be an object`)
|
|
82
|
+
return value
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function text(value, label) {
|
|
86
|
+
if (typeof value !== 'string' || !value.trim())
|
|
87
|
+
fail('aet-v2.invalid-field', `${label} must be a non-empty string`)
|
|
88
|
+
return value
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function instant(value, label) {
|
|
92
|
+
const source = text(value, label)
|
|
93
|
+
const milliseconds = Date.parse(source)
|
|
94
|
+
if (
|
|
95
|
+
!Number.isFinite(milliseconds) ||
|
|
96
|
+
new Date(milliseconds).toISOString() !== source
|
|
97
|
+
)
|
|
98
|
+
fail('aet-v2.invalid-timestamp', `${label} must be canonical UTC ISO-8601`)
|
|
99
|
+
return milliseconds
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function positiveNumber(value, label) {
|
|
103
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0)
|
|
104
|
+
fail('aet-v2.invalid-field', `${label} must be a positive number`)
|
|
105
|
+
return value
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function common(event) {
|
|
109
|
+
record(event, 'event')
|
|
110
|
+
if (event.schema !== SCHEMA)
|
|
111
|
+
fail('aet-v2.unsupported-schema', String(event.schema ?? 'missing'))
|
|
112
|
+
if (event.metric !== METRIC)
|
|
113
|
+
fail('aet-v2.metric-regime-mismatch', String(event.metric ?? 'missing'))
|
|
114
|
+
text(event.eventId, 'eventId')
|
|
115
|
+
if (!EVENT_TYPES.has(event.eventType))
|
|
116
|
+
fail('aet-v2.unsupported-event', String(event.eventType ?? 'missing'))
|
|
117
|
+
const allowed = new Set([...COMMON_KEYS, ...EVENT_KEYS[event.eventType]])
|
|
118
|
+
const unknown = Object.keys(event).find((key) => !allowed.has(key))
|
|
119
|
+
if (unknown) fail('aet-v2.unknown-field', `${event.eventType}.${unknown}`)
|
|
120
|
+
instant(event.recordedAt, 'recordedAt')
|
|
121
|
+
text(event.source, 'source')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function validateAetV2Event(event) {
|
|
125
|
+
common(event)
|
|
126
|
+
switch (event.eventType) {
|
|
127
|
+
case 'AET_V2_LEDGER_DECLARED':
|
|
128
|
+
text(event.taskId, 'taskId')
|
|
129
|
+
text(event.taskTitle, 'taskTitle')
|
|
130
|
+
text(event.workClass, 'workClass')
|
|
131
|
+
positiveNumber(event.originalEstimateMinutes, 'originalEstimateMinutes')
|
|
132
|
+
instant(event.calendarStartedAt, 'calendarStartedAt')
|
|
133
|
+
break
|
|
134
|
+
case 'AET_V2_SEGMENT_STARTED':
|
|
135
|
+
text(event.taskId, 'taskId')
|
|
136
|
+
text(event.executionId, 'executionId')
|
|
137
|
+
instant(event.timestamp, 'timestamp')
|
|
138
|
+
text(event.category, 'category')
|
|
139
|
+
text(event.reason, 'reason')
|
|
140
|
+
break
|
|
141
|
+
case 'AET_V2_SEGMENT_ENDED':
|
|
142
|
+
text(event.taskId, 'taskId')
|
|
143
|
+
text(event.executionId, 'executionId')
|
|
144
|
+
instant(event.timestamp, 'timestamp')
|
|
145
|
+
text(event.startEventId, 'startEventId')
|
|
146
|
+
text(event.endReason, 'endReason')
|
|
147
|
+
break
|
|
148
|
+
case 'AET_V2_ESTIMATE_REVISED':
|
|
149
|
+
text(event.taskId, 'taskId')
|
|
150
|
+
instant(event.timestamp, 'timestamp')
|
|
151
|
+
positiveNumber(
|
|
152
|
+
event.revisedTotalEstimateMinutes,
|
|
153
|
+
'revisedTotalEstimateMinutes'
|
|
154
|
+
)
|
|
155
|
+
if (
|
|
156
|
+
typeof event.scopeDeltaMinutes !== 'number' ||
|
|
157
|
+
!Number.isFinite(event.scopeDeltaMinutes)
|
|
158
|
+
)
|
|
159
|
+
fail(
|
|
160
|
+
'aet-v2.invalid-field',
|
|
161
|
+
'scopeDeltaMinutes must be a finite number'
|
|
162
|
+
)
|
|
163
|
+
text(event.reason, 'reason')
|
|
164
|
+
text(event.approvalSource, 'approvalSource')
|
|
165
|
+
break
|
|
166
|
+
case 'AET_V2_NON_AET_RECORDED':
|
|
167
|
+
text(event.taskId, 'taskId')
|
|
168
|
+
if (!NON_AET_CLOCKS.has(event.clock))
|
|
169
|
+
fail('aet-v2.invalid-clock', String(event.clock ?? 'missing'))
|
|
170
|
+
instant(event.startedAt, 'startedAt')
|
|
171
|
+
instant(event.endedAt, 'endedAt')
|
|
172
|
+
if (Date.parse(event.endedAt) <= Date.parse(event.startedAt))
|
|
173
|
+
fail('aet-v2.invalid-interval', event.eventId)
|
|
174
|
+
text(event.evidence, 'evidence')
|
|
175
|
+
break
|
|
176
|
+
case 'AET_V2_MILESTONE_RECORDED':
|
|
177
|
+
text(event.taskId, 'taskId')
|
|
178
|
+
if (!['first-pr-ready', 'merged', 'complete'].includes(event.milestone))
|
|
179
|
+
fail('aet-v2.invalid-milestone', String(event.milestone ?? 'missing'))
|
|
180
|
+
instant(event.timestamp, 'timestamp')
|
|
181
|
+
text(event.evidence, 'evidence')
|
|
182
|
+
break
|
|
183
|
+
case 'AET_V2_CORRECTION_RECORDED': {
|
|
184
|
+
text(event.targetEventId, 'targetEventId')
|
|
185
|
+
text(event.reason, 'reason')
|
|
186
|
+
const replacement = record(event.replacement, 'replacement')
|
|
187
|
+
common(replacement)
|
|
188
|
+
if (replacement.eventId !== event.targetEventId)
|
|
189
|
+
fail(
|
|
190
|
+
'aet-v2.invalid-correction',
|
|
191
|
+
'replacement must retain target eventId'
|
|
192
|
+
)
|
|
193
|
+
if (replacement.eventType === 'AET_V2_CORRECTION_RECORDED')
|
|
194
|
+
fail(
|
|
195
|
+
'aet-v2.invalid-correction',
|
|
196
|
+
'correction events cannot replace corrections'
|
|
197
|
+
)
|
|
198
|
+
validateAetV2Event(replacement)
|
|
199
|
+
break
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return event
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function parseAetV2JsonLines(contents) {
|
|
206
|
+
if (typeof contents !== 'string')
|
|
207
|
+
fail('aet-v2.invalid-ledger', 'ledger must be text')
|
|
208
|
+
const events = contents
|
|
209
|
+
.split(/\r?\n/u)
|
|
210
|
+
.map((line) => line.trim())
|
|
211
|
+
.filter(Boolean)
|
|
212
|
+
.map((line, index) => {
|
|
213
|
+
try {
|
|
214
|
+
return JSON.parse(line)
|
|
215
|
+
} catch {
|
|
216
|
+
fail('aet-v2.invalid-json', `line ${index + 1}`)
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
if (!events.length) fail('aet-v2.empty-ledger', 'no events')
|
|
220
|
+
return events
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function correctedEvents(events) {
|
|
224
|
+
const semantic = new Map()
|
|
225
|
+
const sourceEventIds = new Set()
|
|
226
|
+
const order = []
|
|
227
|
+
const corrected = new Set()
|
|
228
|
+
const correctionTaskIds = []
|
|
229
|
+
for (const event of events) {
|
|
230
|
+
validateAetV2Event(event)
|
|
231
|
+
if (sourceEventIds.has(event.eventId))
|
|
232
|
+
fail('aet-v2.duplicate-event-id', event.eventId)
|
|
233
|
+
sourceEventIds.add(event.eventId)
|
|
234
|
+
if (event.eventType === 'AET_V2_CORRECTION_RECORDED') {
|
|
235
|
+
if (!semantic.has(event.targetEventId))
|
|
236
|
+
fail('aet-v2.correction-target-missing', event.targetEventId)
|
|
237
|
+
const target = semantic.get(event.targetEventId)
|
|
238
|
+
if (target.eventType === 'AET_V2_LEDGER_DECLARED')
|
|
239
|
+
fail('aet-v2.immutable-original-estimate', event.targetEventId)
|
|
240
|
+
if (event.replacement.eventType !== target.eventType)
|
|
241
|
+
fail(
|
|
242
|
+
'aet-v2.correction-type-mismatch',
|
|
243
|
+
`${event.targetEventId} must remain ${target.eventType}`
|
|
244
|
+
)
|
|
245
|
+
if (event.replacement.taskId !== target.taskId)
|
|
246
|
+
fail(
|
|
247
|
+
'aet-v2.correction-task-mismatch',
|
|
248
|
+
`${event.targetEventId} must remain on ${target.taskId}`
|
|
249
|
+
)
|
|
250
|
+
if (corrected.has(event.targetEventId))
|
|
251
|
+
fail('aet-v2.correction-duplicate', event.targetEventId)
|
|
252
|
+
semantic.set(event.targetEventId, event.replacement)
|
|
253
|
+
corrected.add(event.targetEventId)
|
|
254
|
+
correctionTaskIds.push(event.replacement.taskId)
|
|
255
|
+
continue
|
|
256
|
+
}
|
|
257
|
+
semantic.set(event.eventId, event)
|
|
258
|
+
order.push(event.eventId)
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
events: order.map((id) => semantic.get(id)),
|
|
262
|
+
correctionTaskIds,
|
|
263
|
+
corrections: corrected.size
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function overlaps(left, right) {
|
|
268
|
+
return left.start < right.end && right.start < left.end
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function analyzeAetV2Ledger(input, options = {}) {
|
|
272
|
+
const sourceEvents = Array.isArray(input) ? input : parseAetV2JsonLines(input)
|
|
273
|
+
const corrected = correctedEvents(sourceEvents)
|
|
274
|
+
const declarations = new Map()
|
|
275
|
+
const starts = new Map()
|
|
276
|
+
const ends = new Map()
|
|
277
|
+
const nonAet = []
|
|
278
|
+
const milestones = []
|
|
279
|
+
const estimateRevisions = []
|
|
280
|
+
|
|
281
|
+
for (const event of corrected.events) {
|
|
282
|
+
if (event.eventType === 'AET_V2_LEDGER_DECLARED') {
|
|
283
|
+
if (declarations.has(event.taskId))
|
|
284
|
+
fail('aet-v2.duplicate-declaration', event.taskId)
|
|
285
|
+
declarations.set(event.taskId, event)
|
|
286
|
+
} else if (event.eventType === 'AET_V2_SEGMENT_STARTED') {
|
|
287
|
+
starts.set(event.eventId, event)
|
|
288
|
+
} else if (event.eventType === 'AET_V2_SEGMENT_ENDED') {
|
|
289
|
+
if (ends.has(event.startEventId))
|
|
290
|
+
fail('aet-v2.duplicate-end', event.startEventId)
|
|
291
|
+
ends.set(event.startEventId, event)
|
|
292
|
+
} else if (event.eventType === 'AET_V2_ESTIMATE_REVISED') {
|
|
293
|
+
estimateRevisions.push(event)
|
|
294
|
+
} else if (event.eventType === 'AET_V2_NON_AET_RECORDED') nonAet.push(event)
|
|
295
|
+
else if (event.eventType === 'AET_V2_MILESTONE_RECORDED')
|
|
296
|
+
milestones.push(event)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (!declarations.size)
|
|
300
|
+
fail('aet-v2.missing-declaration', 'no task ledger declaration')
|
|
301
|
+
const intervals = []
|
|
302
|
+
for (const [startId, end] of ends) {
|
|
303
|
+
const start = starts.get(startId)
|
|
304
|
+
if (!start) fail('aet-v2.missing-start', startId)
|
|
305
|
+
if (start.taskId !== end.taskId || start.executionId !== end.executionId)
|
|
306
|
+
fail('aet-v2.segment-identity-mismatch', startId)
|
|
307
|
+
const startMs = instant(start.timestamp, 'timestamp')
|
|
308
|
+
const endMs = instant(end.timestamp, 'timestamp')
|
|
309
|
+
if (endMs <= startMs) fail('aet-v2.invalid-interval', startId)
|
|
310
|
+
intervals.push({
|
|
311
|
+
category: start.category,
|
|
312
|
+
end: endMs,
|
|
313
|
+
executionId: start.executionId,
|
|
314
|
+
start: startMs,
|
|
315
|
+
startEventId: startId,
|
|
316
|
+
taskId: start.taskId
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
const openStarts = [...starts.entries()].filter(
|
|
320
|
+
([startId]) => !ends.has(startId)
|
|
321
|
+
)
|
|
322
|
+
if (!options.allowIncomplete && openStarts.length)
|
|
323
|
+
fail('aet-v2.missing-end', openStarts[0][0])
|
|
324
|
+
if (options.allowIncomplete && openStarts.length > 1)
|
|
325
|
+
fail(
|
|
326
|
+
'aet-v2.contradictory-open-segments',
|
|
327
|
+
openStarts.map(([startId]) => startId).join(',')
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
intervals.sort(
|
|
331
|
+
(left, right) => left.start - right.start || left.end - right.end
|
|
332
|
+
)
|
|
333
|
+
for (let index = 1; index < intervals.length; index += 1)
|
|
334
|
+
if (overlaps(intervals[index - 1], intervals[index]))
|
|
335
|
+
fail(
|
|
336
|
+
'aet-v2.overlapping-agent-segments',
|
|
337
|
+
`${intervals[index - 1].startEventId} overlaps ${intervals[index].startEventId}`
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
for (const wait of nonAet) {
|
|
341
|
+
if (!EXCLUSIVE_COVERAGE_CLOCKS.has(wait.clock)) continue
|
|
342
|
+
const waitInterval = {
|
|
343
|
+
start: Date.parse(wait.startedAt),
|
|
344
|
+
end: Date.parse(wait.endedAt)
|
|
345
|
+
}
|
|
346
|
+
const conflict = intervals.find(
|
|
347
|
+
(interval) =>
|
|
348
|
+
interval.taskId === wait.taskId && overlaps(interval, waitInterval)
|
|
349
|
+
)
|
|
350
|
+
if (conflict)
|
|
351
|
+
fail(
|
|
352
|
+
'aet-v2.yield-overlaps-agent-segment',
|
|
353
|
+
`${wait.eventId} overlaps ${conflict.startEventId}`
|
|
354
|
+
)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const exclusiveCoverage = nonAet
|
|
358
|
+
.filter((event) => EXCLUSIVE_COVERAGE_CLOCKS.has(event.clock))
|
|
359
|
+
.map((event) => ({
|
|
360
|
+
end: Date.parse(event.endedAt),
|
|
361
|
+
eventId: event.eventId,
|
|
362
|
+
start: Date.parse(event.startedAt),
|
|
363
|
+
taskId: event.taskId
|
|
364
|
+
}))
|
|
365
|
+
.sort((left, right) => left.start - right.start || left.end - right.end)
|
|
366
|
+
for (const taskId of new Set(exclusiveCoverage.map((item) => item.taskId))) {
|
|
367
|
+
const taskCoverage = exclusiveCoverage.filter(
|
|
368
|
+
(item) => item.taskId === taskId
|
|
369
|
+
)
|
|
370
|
+
for (let index = 1; index < taskCoverage.length; index += 1) {
|
|
371
|
+
const previous = taskCoverage[index - 1]
|
|
372
|
+
const current = taskCoverage[index]
|
|
373
|
+
if (overlaps(previous, current))
|
|
374
|
+
fail(
|
|
375
|
+
'aet-v2.overlapping-exclusive-coverage',
|
|
376
|
+
`${previous.eventId} overlaps ${current.eventId}`
|
|
377
|
+
)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
for (const taskId of new Set(nonAet.map((item) => item.taskId))) {
|
|
382
|
+
const taskClocks = nonAet.filter((item) => item.taskId === taskId)
|
|
383
|
+
for (const clock of new Set(taskClocks.map((item) => item.clock))) {
|
|
384
|
+
const clockIntervals = taskClocks
|
|
385
|
+
.filter((item) => item.clock === clock)
|
|
386
|
+
.map((item) => ({
|
|
387
|
+
end: Date.parse(item.endedAt),
|
|
388
|
+
eventId: item.eventId,
|
|
389
|
+
start: Date.parse(item.startedAt)
|
|
390
|
+
}))
|
|
391
|
+
.sort((left, right) => left.start - right.start || left.end - right.end)
|
|
392
|
+
for (let index = 1; index < clockIntervals.length; index += 1)
|
|
393
|
+
if (overlaps(clockIntervals[index - 1], clockIntervals[index]))
|
|
394
|
+
fail(
|
|
395
|
+
'aet-v2.overlapping-clock-intervals',
|
|
396
|
+
`${clockIntervals[index - 1].eventId} overlaps ${clockIntervals[index].eventId}`
|
|
397
|
+
)
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const tasks = {}
|
|
402
|
+
for (const [taskId, declaration] of declarations) {
|
|
403
|
+
const taskIntervals = intervals.filter(
|
|
404
|
+
(interval) => interval.taskId === taskId
|
|
405
|
+
)
|
|
406
|
+
const categoryMilliseconds = {}
|
|
407
|
+
for (const interval of taskIntervals)
|
|
408
|
+
categoryMilliseconds[interval.category] =
|
|
409
|
+
(categoryMilliseconds[interval.category] ?? 0) +
|
|
410
|
+
interval.end -
|
|
411
|
+
interval.start
|
|
412
|
+
const aetMilliseconds = taskIntervals.reduce(
|
|
413
|
+
(total, interval) => total + interval.end - interval.start,
|
|
414
|
+
0
|
|
415
|
+
)
|
|
416
|
+
const clockMilliseconds = {}
|
|
417
|
+
for (const item of nonAet.filter((event) => event.taskId === taskId))
|
|
418
|
+
clockMilliseconds[item.clock] =
|
|
419
|
+
(clockMilliseconds[item.clock] ?? 0) +
|
|
420
|
+
Date.parse(item.endedAt) -
|
|
421
|
+
Date.parse(item.startedAt)
|
|
422
|
+
const taskMilestones = milestones.filter((event) => event.taskId === taskId)
|
|
423
|
+
const taskEstimateRevisions = estimateRevisions
|
|
424
|
+
.filter((event) => event.taskId === taskId)
|
|
425
|
+
.sort(
|
|
426
|
+
(left, right) =>
|
|
427
|
+
Date.parse(left.timestamp) - Date.parse(right.timestamp) ||
|
|
428
|
+
left.eventId.localeCompare(right.eventId)
|
|
429
|
+
)
|
|
430
|
+
const latestEvidenceAt = Math.max(
|
|
431
|
+
Date.parse(declaration.calendarStartedAt),
|
|
432
|
+
...taskIntervals.map((interval) => interval.end),
|
|
433
|
+
...nonAet
|
|
434
|
+
.filter((event) => event.taskId === taskId)
|
|
435
|
+
.map((event) => Date.parse(event.endedAt)),
|
|
436
|
+
...taskMilestones.map((event) => Date.parse(event.timestamp)),
|
|
437
|
+
...taskEstimateRevisions.map((event) => Date.parse(event.timestamp))
|
|
438
|
+
)
|
|
439
|
+
const milestonesNamed = (name) =>
|
|
440
|
+
taskMilestones
|
|
441
|
+
.filter((event) => event.milestone === name)
|
|
442
|
+
.sort(
|
|
443
|
+
(left, right) =>
|
|
444
|
+
Date.parse(left.timestamp) - Date.parse(right.timestamp) ||
|
|
445
|
+
left.eventId.localeCompare(right.eventId)
|
|
446
|
+
)
|
|
447
|
+
const merges = milestonesNamed('merged')
|
|
448
|
+
const completions = milestonesNamed('complete')
|
|
449
|
+
const firstPrReadies = milestonesNamed('first-pr-ready')
|
|
450
|
+
const merged = merges.at(-1)
|
|
451
|
+
const complete = completions.at(-1)
|
|
452
|
+
if (options.postMerge && (!merged || !complete))
|
|
453
|
+
fail(
|
|
454
|
+
'aet-v2.post-merge-incomplete',
|
|
455
|
+
`${taskId} requires merged and complete milestones`
|
|
456
|
+
)
|
|
457
|
+
if (complete && !merged)
|
|
458
|
+
fail('aet-v2.invalid-milestone-order', `${taskId} complete without merge`)
|
|
459
|
+
if (
|
|
460
|
+
complete &&
|
|
461
|
+
merged &&
|
|
462
|
+
Date.parse(complete.timestamp) < Date.parse(merged.timestamp)
|
|
463
|
+
)
|
|
464
|
+
fail(
|
|
465
|
+
'aet-v2.invalid-milestone-order',
|
|
466
|
+
`${taskId} completion precedes latest merge`
|
|
467
|
+
)
|
|
468
|
+
const firstPrReady = firstPrReadies[0]
|
|
469
|
+
if (
|
|
470
|
+
firstPrReadies.some(
|
|
471
|
+
(event) =>
|
|
472
|
+
merged &&
|
|
473
|
+
Date.parse(event.timestamp) > Date.parse(merges[0].timestamp)
|
|
474
|
+
)
|
|
475
|
+
)
|
|
476
|
+
fail(
|
|
477
|
+
'aet-v2.invalid-milestone-order',
|
|
478
|
+
`${taskId} first PR-ready follows merge`
|
|
479
|
+
)
|
|
480
|
+
const latestEstimateRevision = taskEstimateRevisions.at(-1)
|
|
481
|
+
let approvedEstimateMinutes = declaration.originalEstimateMinutes
|
|
482
|
+
for (const revision of taskEstimateRevisions) {
|
|
483
|
+
if (
|
|
484
|
+
Math.abs(
|
|
485
|
+
approvedEstimateMinutes +
|
|
486
|
+
revision.scopeDeltaMinutes -
|
|
487
|
+
revision.revisedTotalEstimateMinutes
|
|
488
|
+
) > Number.EPSILON
|
|
489
|
+
)
|
|
490
|
+
fail(
|
|
491
|
+
'aet-v2.inconsistent-estimate-revision',
|
|
492
|
+
`${revision.eventId} delta does not reconcile`
|
|
493
|
+
)
|
|
494
|
+
approvedEstimateMinutes = revision.revisedTotalEstimateMinutes
|
|
495
|
+
}
|
|
496
|
+
const latestApprovedEstimateMinutes =
|
|
497
|
+
latestEstimateRevision?.revisedTotalEstimateMinutes ??
|
|
498
|
+
declaration.originalEstimateMinutes
|
|
499
|
+
const calendarStartedAt = Date.parse(declaration.calendarStartedAt)
|
|
500
|
+
for (const [startId, start] of openStarts) {
|
|
501
|
+
if (start.taskId !== taskId) continue
|
|
502
|
+
const startMs = instant(start.timestamp, 'timestamp')
|
|
503
|
+
if (startMs < calendarStartedAt || complete)
|
|
504
|
+
fail('aet-v2.invalid-open-segment', startId)
|
|
505
|
+
const conflict = taskIntervals.find((interval) =>
|
|
506
|
+
overlaps(interval, { start: startMs, end: latestEvidenceAt + 1 })
|
|
507
|
+
)
|
|
508
|
+
if (conflict)
|
|
509
|
+
fail(
|
|
510
|
+
'aet-v2.overlapping-agent-segments',
|
|
511
|
+
`${startId} overlaps ${conflict.startEventId}`
|
|
512
|
+
)
|
|
513
|
+
}
|
|
514
|
+
const taskExclusiveCoverage = exclusiveCoverage.filter(
|
|
515
|
+
(interval) => interval.taskId === taskId
|
|
516
|
+
)
|
|
517
|
+
const reportingBoundary = options.postMerge
|
|
518
|
+
? Date.parse(complete.timestamp)
|
|
519
|
+
: complete
|
|
520
|
+
? Date.parse(complete.timestamp)
|
|
521
|
+
: latestEvidenceAt
|
|
522
|
+
const assertInDomain = (start, end, eventId) => {
|
|
523
|
+
if (start < calendarStartedAt || end > reportingBoundary)
|
|
524
|
+
fail(
|
|
525
|
+
'aet-v2.evidence-outside-lifecycle',
|
|
526
|
+
`${eventId} outside ${declaration.calendarStartedAt}..${new Date(reportingBoundary).toISOString()}`
|
|
527
|
+
)
|
|
528
|
+
}
|
|
529
|
+
for (const interval of taskIntervals)
|
|
530
|
+
assertInDomain(interval.start, interval.end, interval.startEventId)
|
|
531
|
+
for (const event of nonAet.filter((item) => item.taskId === taskId))
|
|
532
|
+
assertInDomain(
|
|
533
|
+
Date.parse(event.startedAt),
|
|
534
|
+
Date.parse(event.endedAt),
|
|
535
|
+
event.eventId
|
|
536
|
+
)
|
|
537
|
+
for (const event of taskMilestones)
|
|
538
|
+
assertInDomain(
|
|
539
|
+
Date.parse(event.timestamp),
|
|
540
|
+
Date.parse(event.timestamp),
|
|
541
|
+
event.eventId
|
|
542
|
+
)
|
|
543
|
+
for (const event of taskEstimateRevisions)
|
|
544
|
+
assertInDomain(
|
|
545
|
+
Date.parse(event.timestamp),
|
|
546
|
+
Date.parse(event.timestamp),
|
|
547
|
+
event.eventId
|
|
548
|
+
)
|
|
549
|
+
const coverage = [
|
|
550
|
+
...taskIntervals.map((interval) => ({
|
|
551
|
+
end: interval.end,
|
|
552
|
+
eventId: interval.startEventId,
|
|
553
|
+
start: interval.start
|
|
554
|
+
})),
|
|
555
|
+
...taskExclusiveCoverage
|
|
556
|
+
].sort((left, right) => left.start - right.start || left.end - right.end)
|
|
557
|
+
if (!options.allowIncomplete && !coverage.length)
|
|
558
|
+
fail('aet-v2.uncovered-timeline', `${taskId} has no capacity coverage`)
|
|
559
|
+
let coveredThrough = calendarStartedAt
|
|
560
|
+
for (const interval of coverage) {
|
|
561
|
+
if (
|
|
562
|
+
interval.end <= calendarStartedAt ||
|
|
563
|
+
interval.start >= reportingBoundary
|
|
564
|
+
)
|
|
565
|
+
continue
|
|
566
|
+
const clippedStart = Math.max(interval.start, calendarStartedAt)
|
|
567
|
+
const clippedEnd = Math.min(interval.end, reportingBoundary)
|
|
568
|
+
if (!options.allowIncomplete && clippedStart > coveredThrough)
|
|
569
|
+
fail(
|
|
570
|
+
'aet-v2.uncovered-timeline',
|
|
571
|
+
`${taskId} gap ${new Date(coveredThrough).toISOString()}..${new Date(clippedStart).toISOString()}`
|
|
572
|
+
)
|
|
573
|
+
coveredThrough = Math.max(coveredThrough, clippedEnd)
|
|
574
|
+
}
|
|
575
|
+
if (!options.allowIncomplete && coveredThrough < reportingBoundary)
|
|
576
|
+
fail(
|
|
577
|
+
'aet-v2.uncovered-timeline',
|
|
578
|
+
`${taskId} gap ${new Date(coveredThrough).toISOString()}..${new Date(reportingBoundary).toISOString()}`
|
|
579
|
+
)
|
|
580
|
+
tasks[taskId] = {
|
|
581
|
+
aetMilliseconds: options.allowIncomplete ? null : aetMilliseconds,
|
|
582
|
+
measuredLowerBoundAetMilliseconds: options.allowIncomplete
|
|
583
|
+
? aetMilliseconds
|
|
584
|
+
: null,
|
|
585
|
+
calendarCycleMilliseconds:
|
|
586
|
+
reportingBoundary - Date.parse(declaration.calendarStartedAt),
|
|
587
|
+
categoryMilliseconds,
|
|
588
|
+
clockMilliseconds,
|
|
589
|
+
completedAt: complete?.timestamp ?? null,
|
|
590
|
+
corrections: corrected.correctionTaskIds.filter(
|
|
591
|
+
(correctionTaskId) => correctionTaskId === taskId
|
|
592
|
+
).length,
|
|
593
|
+
estimateHistory: [
|
|
594
|
+
{
|
|
595
|
+
estimateMinutes: declaration.originalEstimateMinutes,
|
|
596
|
+
kind: 'original',
|
|
597
|
+
recordedAt: declaration.recordedAt
|
|
598
|
+
},
|
|
599
|
+
...taskEstimateRevisions.map((event) => ({
|
|
600
|
+
approvalSource: event.approvalSource,
|
|
601
|
+
estimateMinutes: event.revisedTotalEstimateMinutes,
|
|
602
|
+
kind: 'revision',
|
|
603
|
+
reason: event.reason,
|
|
604
|
+
recordedAt: event.timestamp,
|
|
605
|
+
scopeDeltaMinutes: event.scopeDeltaMinutes
|
|
606
|
+
}))
|
|
607
|
+
],
|
|
608
|
+
estimateRatio: options.allowIncomplete
|
|
609
|
+
? null
|
|
610
|
+
: aetMilliseconds / (declaration.originalEstimateMinutes * 60_000),
|
|
611
|
+
estimateVarianceMilliseconds: options.allowIncomplete
|
|
612
|
+
? null
|
|
613
|
+
: aetMilliseconds - declaration.originalEstimateMinutes * 60_000,
|
|
614
|
+
firstPrReadyAt: firstPrReady?.timestamp ?? null,
|
|
615
|
+
latestApprovedEstimateMinutes,
|
|
616
|
+
latestApprovedEstimateRatio: options.allowIncomplete
|
|
617
|
+
? null
|
|
618
|
+
: aetMilliseconds / (latestApprovedEstimateMinutes * 60_000),
|
|
619
|
+
latestApprovedEstimateVarianceMilliseconds: options.allowIncomplete
|
|
620
|
+
? null
|
|
621
|
+
: aetMilliseconds - latestApprovedEstimateMinutes * 60_000,
|
|
622
|
+
lifecycleFinal: Boolean(complete),
|
|
623
|
+
lifecycleStatus: complete
|
|
624
|
+
? 'final-complete'
|
|
625
|
+
: merged
|
|
626
|
+
? 'intermediate-post-merge'
|
|
627
|
+
: 'active-intermediate',
|
|
628
|
+
measurementComplete: !options.allowIncomplete,
|
|
629
|
+
mergedAt: merged?.timestamp ?? null,
|
|
630
|
+
metric: METRIC,
|
|
631
|
+
originalEstimateRatio: options.allowIncomplete
|
|
632
|
+
? null
|
|
633
|
+
: aetMilliseconds / (declaration.originalEstimateMinutes * 60_000),
|
|
634
|
+
originalEstimateMinutes: declaration.originalEstimateMinutes,
|
|
635
|
+
originalEstimateVarianceMilliseconds: options.allowIncomplete
|
|
636
|
+
? null
|
|
637
|
+
: aetMilliseconds - declaration.originalEstimateMinutes * 60_000,
|
|
638
|
+
remediationCycles: taskIntervals.filter(
|
|
639
|
+
(interval) => interval.category === 'remediation'
|
|
640
|
+
).length,
|
|
641
|
+
reportingBoundaryAt: new Date(reportingBoundary).toISOString(),
|
|
642
|
+
taskTitle: declaration.taskTitle,
|
|
643
|
+
workClass: declaration.workClass
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
for (const interval of intervals)
|
|
648
|
+
if (!declarations.has(interval.taskId))
|
|
649
|
+
fail('aet-v2.missing-declaration', interval.taskId)
|
|
650
|
+
for (const event of [...nonAet, ...milestones, ...estimateRevisions])
|
|
651
|
+
if (!declarations.has(event.taskId))
|
|
652
|
+
fail('aet-v2.missing-declaration', event.taskId)
|
|
653
|
+
|
|
654
|
+
return {
|
|
655
|
+
corrections: corrected.corrections,
|
|
656
|
+
measurementComplete: !options.allowIncomplete,
|
|
657
|
+
metric: METRIC,
|
|
658
|
+
schema: 'rn-launcher/aet-v2-report/v1',
|
|
659
|
+
tasks
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
export function stableAetV2Json(value) {
|
|
664
|
+
if (Array.isArray(value)) return `[${value.map(stableAetV2Json).join(',')}]`
|
|
665
|
+
if (value && typeof value === 'object')
|
|
666
|
+
return `{${Object.keys(value)
|
|
667
|
+
.sort()
|
|
668
|
+
.map((key) => `${JSON.stringify(key)}:${stableAetV2Json(value[key])}`)
|
|
669
|
+
.join(',')}}`
|
|
670
|
+
return JSON.stringify(value)
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
export const aetV2Contract = Object.freeze({ metric: METRIC, schema: SCHEMA })
|