crewly 1.20.358 → 1.20.359
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/dist/backend/backend/src/constants.d.ts +59 -0
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +59 -0
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/index.d.ts +21 -0
- package/dist/backend/backend/src/index.d.ts.map +1 -1
- package/dist/backend/backend/src/index.js +162 -11
- package/dist/backend/backend/src/index.js.map +1 -1
- package/dist/backend/backend/src/services/memory/agent-memory-refresh.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/memory/agent-memory-refresh.service.js +5 -0
- package/dist/backend/backend/src/services/memory/agent-memory-refresh.service.js.map +1 -1
- package/dist/backend/backend/src/services/memory/startup-briefing.service.d.ts +10 -0
- package/dist/backend/backend/src/services/memory/startup-briefing.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/memory/startup-briefing.service.js +65 -22
- package/dist/backend/backend/src/services/memory/startup-briefing.service.js.map +1 -1
- package/dist/backend/backend/src/services/session/session-binding.d.ts +25 -2
- package/dist/backend/backend/src/services/session/session-binding.d.ts.map +1 -1
- package/dist/backend/backend/src/services/session/session-binding.js +32 -4
- package/dist/backend/backend/src/services/session/session-binding.js.map +1 -1
- package/dist/backend/backend/src/services/wiki/wiki-ingest.service.d.ts +3 -1
- package/dist/backend/backend/src/services/wiki/wiki-ingest.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/wiki/wiki-ingest.service.js.map +1 -1
- package/dist/backend/backend/src/services/wiki/wiki-log-compile.service.d.ts +27 -2
- package/dist/backend/backend/src/services/wiki/wiki-log-compile.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/wiki/wiki-log-compile.service.js +105 -8
- package/dist/backend/backend/src/services/wiki/wiki-log-compile.service.js.map +1 -1
- package/dist/backend/backend/src/services/wiki/work-record-transcript.d.ts +64 -0
- package/dist/backend/backend/src/services/wiki/work-record-transcript.d.ts.map +1 -0
- package/dist/backend/backend/src/services/wiki/work-record-transcript.js +194 -0
- package/dist/backend/backend/src/services/wiki/work-record-transcript.js.map +1 -0
- package/dist/backend/backend/src/services/wiki/work-record.d.ts +239 -0
- package/dist/backend/backend/src/services/wiki/work-record.d.ts.map +1 -0
- package/dist/backend/backend/src/services/wiki/work-record.js +631 -0
- package/dist/backend/backend/src/services/wiki/work-record.js.map +1 -0
- package/dist/backend/backend/src/services/wiki/work-record.service.d.ts +209 -0
- package/dist/backend/backend/src/services/wiki/work-record.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/wiki/work-record.service.js +514 -0
- package/dist/backend/backend/src/services/wiki/work-record.service.js.map +1 -0
- package/dist/backend/build-info.json +2 -2
- package/dist/cli/backend/src/constants.d.ts +59 -0
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +59 -0
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/dist/cli/backend/src/services/memory/startup-briefing.service.d.ts +10 -0
- package/dist/cli/backend/src/services/memory/startup-briefing.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/memory/startup-briefing.service.js +65 -22
- package/dist/cli/backend/src/services/memory/startup-briefing.service.js.map +1 -1
- package/dist/cli/backend/src/services/wiki/wiki-ingest.service.d.ts +3 -1
- package/dist/cli/backend/src/services/wiki/wiki-ingest.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/wiki/wiki-ingest.service.js.map +1 -1
- package/dist/cli/backend/src/services/wiki/work-record-transcript.d.ts +64 -0
- package/dist/cli/backend/src/services/wiki/work-record-transcript.d.ts.map +1 -0
- package/dist/cli/backend/src/services/wiki/work-record-transcript.js +194 -0
- package/dist/cli/backend/src/services/wiki/work-record-transcript.js.map +1 -0
- package/dist/cli/backend/src/services/wiki/work-record.d.ts +239 -0
- package/dist/cli/backend/src/services/wiki/work-record.d.ts.map +1 -0
- package/dist/cli/backend/src/services/wiki/work-record.js +631 -0
- package/dist/cli/backend/src/services/wiki/work-record.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Work records (#1349) — the pure half.
|
|
3
|
+
*
|
|
4
|
+
* A work record is one structured `(record)` entry in a vault's activity log
|
|
5
|
+
* describing a finished (or long-blocked) task: who asked for it, who did it,
|
|
6
|
+
* what came out, what went wrong on the way and how it was resolved, who was
|
|
7
|
+
* pulled in, and how it ended. This module knows the shape, assembles a
|
|
8
|
+
* record from already-loaded inputs (no I/O, so fixtures can drive it),
|
|
9
|
+
* renders it as a log body, and parses a body back for the digest and the
|
|
10
|
+
* startup briefing.
|
|
11
|
+
*
|
|
12
|
+
* Owner-facing lines never carry ticket ids or WorkItem ids; those live on
|
|
13
|
+
* the `meta:` line, which the digest reads but never renders.
|
|
14
|
+
*
|
|
15
|
+
* Body layout (one field per line, parse-friendly):
|
|
16
|
+
*
|
|
17
|
+
* (record) **<title>** — <outcome phrase>
|
|
18
|
+
* asked by: <who> — "<quote>"
|
|
19
|
+
* done by: <name>
|
|
20
|
+
* did: <summary>
|
|
21
|
+
* outputs: <a>; <b>
|
|
22
|
+
* errors: <what> (fixed); <what> (unresolved)
|
|
23
|
+
* helpers: delegated to Leo: "<title>" → done; asked Ella → answered; reviewed by Owen
|
|
24
|
+
* outcome: <phrase>
|
|
25
|
+
* meta: wi=<id> phase=final status=verified asked_by=owen:lead done_by=nova …
|
|
26
|
+
*
|
|
27
|
+
* @module services/wiki/work-record
|
|
28
|
+
*/
|
|
29
|
+
import { WORK_RECORD_CONSTANTS } from '../../constants.js';
|
|
30
|
+
import { TICKET_CONSTANTS } from '../../constants.js';
|
|
31
|
+
import { stripTicketIds } from '../slack/agent-coordination.js';
|
|
32
|
+
const C = WORK_RECORD_CONSTANTS;
|
|
33
|
+
const UUID_RE = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?::[A-Za-z]+:[0-9a-f-]+)*\b/gi;
|
|
34
|
+
const WORK_ITEM_WORD_RE = /\b(?:WorkItem|work item)\s+(?=\[?[0-9a-f]{8}-)/gi;
|
|
35
|
+
const URL_RE = /https?:\/\/[^\s)"'<>]+/g;
|
|
36
|
+
/**
|
|
37
|
+
* `work-record:<workItemId>#<phase>` — the entry's `ref:` and its dedupe key.
|
|
38
|
+
*
|
|
39
|
+
* @param workItemId - WorkItem id
|
|
40
|
+
* @param phase - Record phase
|
|
41
|
+
* @returns Stable ref
|
|
42
|
+
*/
|
|
43
|
+
export function workRecordRef(workItemId, phase) {
|
|
44
|
+
return `${C.REF_PREFIX}${workItemId}#${phase}`;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Parse a record ref back into its parts.
|
|
48
|
+
*
|
|
49
|
+
* @param ref - `ref:` value
|
|
50
|
+
* @returns Parts, or null when it is not a record ref
|
|
51
|
+
*/
|
|
52
|
+
export function parseWorkRecordRef(ref) {
|
|
53
|
+
if (!ref.startsWith(C.REF_PREFIX))
|
|
54
|
+
return null;
|
|
55
|
+
const rest = ref.slice(C.REF_PREFIX.length);
|
|
56
|
+
const hash = rest.lastIndexOf('#');
|
|
57
|
+
if (hash <= 0)
|
|
58
|
+
return null;
|
|
59
|
+
const phase = rest.slice(hash + 1);
|
|
60
|
+
if (phase !== 'final' && phase !== 'blocked')
|
|
61
|
+
return null;
|
|
62
|
+
return { workItemId: rest.slice(0, hash), phase };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Whether a log entry is a work record.
|
|
66
|
+
*
|
|
67
|
+
* @param entry - Parsed log entry
|
|
68
|
+
* @returns True for `work_record` entries (by source type or ref)
|
|
69
|
+
*/
|
|
70
|
+
export function isWorkRecordEntry(entry) {
|
|
71
|
+
return entry.sourceType === C.SOURCE_TYPE || entry.ref.startsWith(C.REF_PREFIX);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Whether a WorkItem is the verify item of another (`<id>:verify:<id>` or
|
|
75
|
+
* `metadata.verifyOf`). Verify items are folded into the parent's record.
|
|
76
|
+
*
|
|
77
|
+
* @param wi - WorkItem
|
|
78
|
+
* @returns True for a verify item
|
|
79
|
+
*/
|
|
80
|
+
export function isVerifyWorkItem(wi) {
|
|
81
|
+
return wi.id.includes(':verify:') || typeof wi.metadata?.verifyOf === 'string';
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Whether a WorkItem gets a record at all: real work, not a verify item or a
|
|
85
|
+
* bookkeeping type.
|
|
86
|
+
*
|
|
87
|
+
* @param wi - WorkItem
|
|
88
|
+
* @returns True when the item is recordable
|
|
89
|
+
*/
|
|
90
|
+
export function isRecordableWorkItem(wi) {
|
|
91
|
+
if (isVerifyWorkItem(wi))
|
|
92
|
+
return false;
|
|
93
|
+
return !C.SKIPPED_TYPES.includes(wi.type);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The moment an item stopped: `completedAt`, else `statusChangedAt`, else
|
|
97
|
+
* `createdAt`.
|
|
98
|
+
*
|
|
99
|
+
* @param wi - WorkItem
|
|
100
|
+
* @returns ISO time
|
|
101
|
+
*/
|
|
102
|
+
export function workItemEndedAt(wi) {
|
|
103
|
+
return wi.completedAt ?? wi.statusChangedAt ?? wi.createdAt;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Owner-facing text: ticket ids, WorkItem ids and uuids removed, whitespace
|
|
107
|
+
* collapsed, clipped.
|
|
108
|
+
*
|
|
109
|
+
* @param text - Raw text
|
|
110
|
+
* @param prefixes - Ticket prefixes to strip
|
|
111
|
+
* @param max - Character cap
|
|
112
|
+
* @returns Clean one-line text
|
|
113
|
+
*/
|
|
114
|
+
export function cleanOwnerText(text, prefixes = [], max = Number.MAX_SAFE_INTEGER) {
|
|
115
|
+
if (!text)
|
|
116
|
+
return '';
|
|
117
|
+
let out = text.replace(/\r?\n+/g, ' ');
|
|
118
|
+
out = out.replace(WORK_ITEM_WORD_RE, '').replace(UUID_RE, '');
|
|
119
|
+
out = stripTicketIds(out, prefixes);
|
|
120
|
+
out = out
|
|
121
|
+
.replace(/\s+/g, ' ')
|
|
122
|
+
.replace(/\(\s*\)/g, '')
|
|
123
|
+
.replace(/\s+([.,;:!?)])/g, '$1')
|
|
124
|
+
.replace(/^[\s—–:-]+/, '')
|
|
125
|
+
.trim();
|
|
126
|
+
return clip(out, max);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Clip to `max` characters with an ellipsis.
|
|
130
|
+
*
|
|
131
|
+
* @param text - Text
|
|
132
|
+
* @param max - Cap
|
|
133
|
+
* @returns Clipped text
|
|
134
|
+
*/
|
|
135
|
+
export function clip(text, max) {
|
|
136
|
+
return text.length > max ? `${text.slice(0, Math.max(1, max - 1))}…` : text;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Assemble a record from loaded inputs. Pure.
|
|
140
|
+
*
|
|
141
|
+
* @param inputs - WorkItem, links, trace, transcript facts and name resolvers
|
|
142
|
+
* @returns The record
|
|
143
|
+
*
|
|
144
|
+
* @example
|
|
145
|
+
* ```typescript
|
|
146
|
+
* const record = assembleWorkRecord({ workItem, phase: 'final', ticket, children, trace, nameOf, kindOf });
|
|
147
|
+
* const body = renderWorkRecordBody(record);
|
|
148
|
+
* ```
|
|
149
|
+
*/
|
|
150
|
+
export function assembleWorkRecord(inputs) {
|
|
151
|
+
const wi = inputs.workItem;
|
|
152
|
+
const prefixes = collectPrefixes(inputs);
|
|
153
|
+
const clean = (t, max) => cleanOwnerText(t, prefixes, max);
|
|
154
|
+
const doerSession = wi.target ?? '';
|
|
155
|
+
const doneBy = doerSession
|
|
156
|
+
? { kind: inputs.kindOf(doerSession), name: inputs.nameOf(doerSession), session: doerSession }
|
|
157
|
+
: { kind: 'system', name: 'nobody (unassigned)' };
|
|
158
|
+
const origin = (wi.metadata?.origin ?? undefined);
|
|
159
|
+
const { askedBy, askedWhere, forOwner } = resolveAsker(wi, origin, inputs, prefixes);
|
|
160
|
+
const askQuote = clean(firstLine(inputs.ticket?.description) || firstLine(wi.description) || firstLine(inputs.request?.description) || firstLine(wi.briefMarkdown) || wi.title, C.QUOTE_MAX_CHARS);
|
|
161
|
+
const bag = { ...(wi.output ?? {}), ...(wi.result ?? {}) };
|
|
162
|
+
const did = clean(summaryOf(bag) || (wi.status === 'cancelled' ? wi.cancelReason : wi.status === 'blocked' ? wi.blockedReason : wi.error) || '', C.DID_MAX_CHARS);
|
|
163
|
+
const outputs = collectOutputs(wi, bag, inputs, prefixes);
|
|
164
|
+
const errors = collectErrors(wi, inputs, prefixes);
|
|
165
|
+
const helpers = collectHelpers(wi, inputs, prefixes);
|
|
166
|
+
const { outcome, outcomeBy, outcomeNote } = resolveOutcome(wi, inputs, prefixes);
|
|
167
|
+
const endedAt = inputs.phase === 'blocked' ? (wi.statusChangedAt ?? workItemEndedAt(wi)) : workItemEndedAt(wi);
|
|
168
|
+
const startMs = Date.parse(wi.startedAt ?? wi.createdAt);
|
|
169
|
+
const endMs = Date.parse(endedAt);
|
|
170
|
+
const durationMs = Number.isFinite(startMs) && Number.isFinite(endMs) && endMs > startMs ? endMs - startMs : undefined;
|
|
171
|
+
const meta = {
|
|
172
|
+
wi: wi.id,
|
|
173
|
+
phase: inputs.phase,
|
|
174
|
+
status: wi.status,
|
|
175
|
+
asked_by: `${metaToken(askedBy.session ?? askedBy.kind)}:${askedBy.kind}`,
|
|
176
|
+
done_by: metaToken(doneBy.session ?? doneBy.kind),
|
|
177
|
+
outcome,
|
|
178
|
+
};
|
|
179
|
+
if (inputs.ticket)
|
|
180
|
+
meta.ticket = inputs.ticket.id;
|
|
181
|
+
if (wi.requestId)
|
|
182
|
+
meta.request = wi.requestId;
|
|
183
|
+
if (wi.traceId)
|
|
184
|
+
meta.trace = wi.traceId;
|
|
185
|
+
if (typeof wi.metadata?.teamId === 'string')
|
|
186
|
+
meta.team = wi.metadata.teamId;
|
|
187
|
+
if (helpers.length)
|
|
188
|
+
meta.helpers = helpers.map((h) => `${metaToken(h.session ?? h.name)}:${h.how}`).join(',');
|
|
189
|
+
if (outcomeBy)
|
|
190
|
+
meta.outcome_by = metaToken(outcomeBy);
|
|
191
|
+
return {
|
|
192
|
+
workItemId: wi.id,
|
|
193
|
+
phase: inputs.phase,
|
|
194
|
+
title: clean(wi.title, 120) || 'Untitled task',
|
|
195
|
+
askedBy,
|
|
196
|
+
askedWhere,
|
|
197
|
+
forOwner,
|
|
198
|
+
askQuote,
|
|
199
|
+
doneBy,
|
|
200
|
+
did,
|
|
201
|
+
outputs,
|
|
202
|
+
errors,
|
|
203
|
+
helpers,
|
|
204
|
+
outcome,
|
|
205
|
+
outcomeBy,
|
|
206
|
+
outcomeNote,
|
|
207
|
+
endedAt,
|
|
208
|
+
durationMs,
|
|
209
|
+
meta,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Render a record as the log body. Always fits `MAX_CHARS`; the `meta:` line
|
|
214
|
+
* is never cut.
|
|
215
|
+
*
|
|
216
|
+
* @param record - Assembled record
|
|
217
|
+
* @returns Markdown body
|
|
218
|
+
*/
|
|
219
|
+
export function renderWorkRecordBody(record) {
|
|
220
|
+
const asked = [
|
|
221
|
+
`${record.askedBy.name}${partySuffix(record.askedBy)}`,
|
|
222
|
+
record.forOwner ? 'for the owner' : '',
|
|
223
|
+
record.askedWhere ? `(${record.askedWhere})` : '',
|
|
224
|
+
].filter(Boolean).join(' ');
|
|
225
|
+
const lines = [
|
|
226
|
+
`(record) **${record.title}** — ${outcomePhrase(record)}`,
|
|
227
|
+
`asked by: ${asked}${record.askQuote ? ` — "${record.askQuote}"` : ''}`,
|
|
228
|
+
`done by: ${record.doneBy.name}`,
|
|
229
|
+
];
|
|
230
|
+
if (record.did)
|
|
231
|
+
lines.push(`did: ${record.did}`);
|
|
232
|
+
if (record.outputs.length)
|
|
233
|
+
lines.push(`outputs: ${record.outputs.join('; ')}`);
|
|
234
|
+
if (record.errors.length)
|
|
235
|
+
lines.push(`errors: ${record.errors.map((e) => `${e.what} (${e.resolved})`).join('; ')}`);
|
|
236
|
+
if (record.helpers.length)
|
|
237
|
+
lines.push(`helpers: ${record.helpers.map(renderHelper).join('; ')}`);
|
|
238
|
+
lines.push(`outcome: ${outcomePhrase(record)}${record.outcomeNote ? ` — ${record.outcomeNote}` : ''}${record.durationMs ? `, ${humanDuration(record.durationMs)}` : ''}`);
|
|
239
|
+
const metaLine = `meta: ${Object.entries(record.meta).map(([k, v]) => `${k}=${v}`).join(' ')}`;
|
|
240
|
+
let body = lines.join('\n');
|
|
241
|
+
const budget = C.MAX_CHARS - metaLine.length - 1;
|
|
242
|
+
if (body.length > budget)
|
|
243
|
+
body = `${body.slice(0, Math.max(0, budget - 1)).replace(/\n[^\n]*$/, '')}\n…`;
|
|
244
|
+
return `${body}\n${metaLine}`;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Parse a record body written by {@link renderWorkRecordBody}.
|
|
248
|
+
*
|
|
249
|
+
* @param body - Log entry body
|
|
250
|
+
* @returns The fields, or null when the body is not a record
|
|
251
|
+
*/
|
|
252
|
+
export function parseWorkRecordBody(body) {
|
|
253
|
+
const lines = body.split('\n');
|
|
254
|
+
const head = lines[0]?.match(/^\s*\(record\)\s+\*\*(.+?)\*\*(?:\s+—\s+(.*))?$/);
|
|
255
|
+
if (!head)
|
|
256
|
+
return null;
|
|
257
|
+
const fields = new Map();
|
|
258
|
+
for (const line of lines.slice(1)) {
|
|
259
|
+
const m = line.match(/^([a-z ]+):\s?(.*)$/);
|
|
260
|
+
if (m)
|
|
261
|
+
fields.set(m[1], m[2]);
|
|
262
|
+
}
|
|
263
|
+
const meta = {};
|
|
264
|
+
for (const token of (fields.get('meta') ?? '').split(/\s+/)) {
|
|
265
|
+
const eq = token.indexOf('=');
|
|
266
|
+
if (eq > 0)
|
|
267
|
+
meta[token.slice(0, eq)] = token.slice(eq + 1);
|
|
268
|
+
}
|
|
269
|
+
const helpers = [];
|
|
270
|
+
for (const part of splitList(fields.get('helpers'))) {
|
|
271
|
+
const h = part.match(/^(delegated to|asked|reviewed by)\s+(.+?)(?::\s|\s→\s|$)(.*)$/);
|
|
272
|
+
if (!h)
|
|
273
|
+
continue;
|
|
274
|
+
helpers.push({ how: h[1] === 'delegated to' ? 'delegated' : h[1] === 'asked' ? 'asked' : 'reviewed', name: h[2].trim(), rest: h[3].trim() });
|
|
275
|
+
}
|
|
276
|
+
const asked = fields.get('asked by') ?? '';
|
|
277
|
+
return {
|
|
278
|
+
title: head[1].trim(),
|
|
279
|
+
askedBy: asked.split(' — "')[0].trim(),
|
|
280
|
+
doneBy: (fields.get('done by') ?? '').trim(),
|
|
281
|
+
did: (fields.get('did') ?? '').trim(),
|
|
282
|
+
outputs: splitList(fields.get('outputs')),
|
|
283
|
+
errors: splitList(fields.get('errors')),
|
|
284
|
+
helpers,
|
|
285
|
+
outcome: (fields.get('outcome') ?? head[2] ?? '').trim(),
|
|
286
|
+
meta,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* One-line form of a record for the startup briefing and the digest.
|
|
291
|
+
*
|
|
292
|
+
* @param parsed - Parsed record
|
|
293
|
+
* @param max - Character cap
|
|
294
|
+
* @returns `**Title** — asked by X, done by Y → outcome`
|
|
295
|
+
*/
|
|
296
|
+
export function shortWorkRecordLine(parsed, max = C.BRIEFING_LINE_MAX_CHARS) {
|
|
297
|
+
const parts = [`**${clip(parsed.title, 80)}**`, `asked by ${parsed.askedBy || 'unknown'}`, `done by ${parsed.doneBy || 'unknown'}`];
|
|
298
|
+
if (parsed.outputs.length)
|
|
299
|
+
parts.push(`outputs: ${parsed.outputs.slice(0, 2).map((o) => clip(o, 60)).join('; ')}`);
|
|
300
|
+
const tail = ` → ${clip(parsed.outcome, 60)}`;
|
|
301
|
+
return `${clip(parts.join(' — '), Math.max(40, max - tail.length))}${tail}`;
|
|
302
|
+
}
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
// Assembly internals
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
function collectPrefixes(inputs) {
|
|
307
|
+
const out = new Set(inputs.ticketPrefixes ?? []);
|
|
308
|
+
const ticketId = inputs.ticket?.id ?? inputs.workItem.metadata?.projectTicket?.id;
|
|
309
|
+
if (typeof ticketId === 'string') {
|
|
310
|
+
const dash = ticketId.indexOf('-');
|
|
311
|
+
if (dash > 0)
|
|
312
|
+
out.add(ticketId.slice(0, dash));
|
|
313
|
+
}
|
|
314
|
+
out.add(TICKET_CONSTANTS.NUMBER_PREFIX.replace(/-$/, ''));
|
|
315
|
+
return [...out];
|
|
316
|
+
}
|
|
317
|
+
function firstLine(text) {
|
|
318
|
+
if (!text)
|
|
319
|
+
return '';
|
|
320
|
+
for (const raw of text.split('\n')) {
|
|
321
|
+
const line = raw.replace(/^#+\s*/, '').replace(/^\*\*[^*]+\*\*\s*/, '').trim();
|
|
322
|
+
if (line.length > 0)
|
|
323
|
+
return line;
|
|
324
|
+
}
|
|
325
|
+
return '';
|
|
326
|
+
}
|
|
327
|
+
function summaryOf(bag) {
|
|
328
|
+
for (const k of ['summary', 'message', 'text', 'verdict', 'reason']) {
|
|
329
|
+
const v = bag[k];
|
|
330
|
+
if (typeof v === 'string' && v.trim())
|
|
331
|
+
return v;
|
|
332
|
+
}
|
|
333
|
+
return '';
|
|
334
|
+
}
|
|
335
|
+
function resolveAsker(wi, origin, inputs, prefixes) {
|
|
336
|
+
const delegatedBy = typeof wi.metadata?.delegatedBy === 'string' ? wi.metadata.delegatedBy : undefined;
|
|
337
|
+
const forOwner = origin?.kind === 'owner';
|
|
338
|
+
const askedWhere = origin?.kind === 'owner' ? ownerPlace(origin) : undefined;
|
|
339
|
+
if (delegatedBy && delegatedBy !== wi.target) {
|
|
340
|
+
return { askedBy: { kind: inputs.kindOf(delegatedBy), name: inputs.nameOf(delegatedBy), session: delegatedBy }, askedWhere, forOwner };
|
|
341
|
+
}
|
|
342
|
+
if (origin?.kind === 'owner') {
|
|
343
|
+
return { askedBy: { kind: 'owner', name: 'the owner' }, askedWhere, forOwner: false };
|
|
344
|
+
}
|
|
345
|
+
if (origin?.kind === 'trigger') {
|
|
346
|
+
return { askedBy: { kind: 'schedule', name: `schedule "${cleanOwnerText(origin.topic, prefixes, 60)}"` }, forOwner: false };
|
|
347
|
+
}
|
|
348
|
+
const source = inputs.ticket?.source ?? inputs.request?.origin?.author;
|
|
349
|
+
if (typeof source === 'string') {
|
|
350
|
+
const session = source.replace(/^agent:/, '');
|
|
351
|
+
if (source === 'owner' || inputs.request?.origin?.channel === 'slack-dm' || inputs.request?.origin?.channel === 'portal') {
|
|
352
|
+
return { askedBy: { kind: 'owner', name: 'the owner' }, askedWhere: inputs.request?.origin?.channel, forOwner: false };
|
|
353
|
+
}
|
|
354
|
+
if (session && session !== wi.target && /^[a-z0-9][a-z0-9-]*$/i.test(session)) {
|
|
355
|
+
return { askedBy: { kind: inputs.kindOf(session), name: inputs.nameOf(session), session }, forOwner };
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (wi.owner === 'orchestrator')
|
|
359
|
+
return { askedBy: { kind: 'orchestrator', name: 'the orchestrator' }, forOwner };
|
|
360
|
+
if (wi.owner === 'team_lead')
|
|
361
|
+
return { askedBy: { kind: 'lead', name: 'the team lead' }, forOwner };
|
|
362
|
+
return { askedBy: { kind: 'system', name: 'Crewly (system)' }, forOwner };
|
|
363
|
+
}
|
|
364
|
+
function ownerPlace(origin) {
|
|
365
|
+
if (origin.appComment)
|
|
366
|
+
return 'app comment';
|
|
367
|
+
if (origin.slackChannelId && origin.threadTs)
|
|
368
|
+
return 'Slack thread';
|
|
369
|
+
if (origin.slackChannelId)
|
|
370
|
+
return 'Slack DM';
|
|
371
|
+
if (origin.conversationId)
|
|
372
|
+
return 'chat';
|
|
373
|
+
return 'owner message';
|
|
374
|
+
}
|
|
375
|
+
function collectOutputs(wi, bag, inputs, prefixes) {
|
|
376
|
+
const out = [];
|
|
377
|
+
const seen = new Set();
|
|
378
|
+
const push = (s) => {
|
|
379
|
+
const t = cleanOwnerText(s, prefixes, C.OUTPUT_MAX_CHARS);
|
|
380
|
+
if (!t || seen.has(t.toLowerCase()))
|
|
381
|
+
return;
|
|
382
|
+
seen.add(t.toLowerCase());
|
|
383
|
+
out.push(t);
|
|
384
|
+
};
|
|
385
|
+
const summary = summaryOf(bag);
|
|
386
|
+
for (const url of summary.match(URL_RE) ?? [])
|
|
387
|
+
push(url.replace(/[.,;:]+$/, ''));
|
|
388
|
+
const evidence = bag.evidence;
|
|
389
|
+
let checks = 0;
|
|
390
|
+
if (Array.isArray(evidence)) {
|
|
391
|
+
for (const e of evidence) {
|
|
392
|
+
if (e?.type === 'artifact' && typeof e.path === 'string')
|
|
393
|
+
push(e.path);
|
|
394
|
+
else if (e?.type === 'command' && typeof e.exitCode === 'number' && e.exitCode === 0)
|
|
395
|
+
checks++;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (checks > 0)
|
|
399
|
+
push(`${checks} check${checks === 1 ? '' : 's'} passed`);
|
|
400
|
+
for (const k of ['files', 'artifacts', 'paths', 'evidencePaths', 'outputPaths', 'prUrl', 'pr', 'url']) {
|
|
401
|
+
const v = bag[k];
|
|
402
|
+
if (Array.isArray(v))
|
|
403
|
+
v.forEach((x) => typeof x === 'string' && push(x));
|
|
404
|
+
else if (typeof v === 'string')
|
|
405
|
+
push(v);
|
|
406
|
+
}
|
|
407
|
+
const t = inputs.transcript;
|
|
408
|
+
if (t) {
|
|
409
|
+
for (const u of t.urls)
|
|
410
|
+
push(u);
|
|
411
|
+
for (const c of t.commits)
|
|
412
|
+
push(`commit ${c}`);
|
|
413
|
+
for (const f of t.filesWritten)
|
|
414
|
+
push(f);
|
|
415
|
+
}
|
|
416
|
+
const posts = new Map();
|
|
417
|
+
for (const ev of inputs.trace ?? []) {
|
|
418
|
+
if (ev.type !== 'message.outbound' || ev.outcome !== 'ok' || ev.actor.session !== wi.target)
|
|
419
|
+
continue;
|
|
420
|
+
const place = placeFromSummary(ev.summary) ?? String(ev.data?.destination ?? ev.data?.source ?? 'message');
|
|
421
|
+
posts.set(place, (posts.get(place) ?? 0) + 1);
|
|
422
|
+
}
|
|
423
|
+
for (const [place, n] of posts)
|
|
424
|
+
push(`${n} message${n === 1 ? '' : 's'} posted (${place})`);
|
|
425
|
+
if (t && t.posts > 0 && posts.size === 0)
|
|
426
|
+
push(`${t.posts} message${t.posts === 1 ? '' : 's'} posted`);
|
|
427
|
+
return out.slice(0, C.MAX_OUTPUTS);
|
|
428
|
+
}
|
|
429
|
+
function placeFromSummary(summary) {
|
|
430
|
+
const m = summary.match(/→\s+(Slack|chat|app|WhatsApp)\b/i);
|
|
431
|
+
return m ? m[1] : null;
|
|
432
|
+
}
|
|
433
|
+
function collectErrors(wi, inputs, prefixes) {
|
|
434
|
+
const out = [];
|
|
435
|
+
const seen = new Set();
|
|
436
|
+
const push = (what, resolved) => {
|
|
437
|
+
const t = cleanOwnerText(what, prefixes, C.ERROR_MAX_CHARS);
|
|
438
|
+
if (!t)
|
|
439
|
+
return;
|
|
440
|
+
const key = t.toLowerCase();
|
|
441
|
+
if (seen.has(key))
|
|
442
|
+
return;
|
|
443
|
+
seen.add(key);
|
|
444
|
+
out.push({ what: t, resolved });
|
|
445
|
+
};
|
|
446
|
+
for (const e of inputs.transcript?.errors ?? [])
|
|
447
|
+
push(e.what, e.resolved);
|
|
448
|
+
for (const ev of inputs.trace ?? []) {
|
|
449
|
+
const mine = ev.actor.session === wi.target || ev.refs.workItemId === wi.id;
|
|
450
|
+
if (!mine)
|
|
451
|
+
continue;
|
|
452
|
+
if (ev.type === 'turn.error' || ev.type === 'error' || ev.type === 'guard.block')
|
|
453
|
+
push(ev.summary, finishedOk(wi) ? 'fixed' : 'unknown');
|
|
454
|
+
else if (ev.type === 'skill.call' && ev.outcome === 'failed')
|
|
455
|
+
push(`${String(ev.refs.skill ?? 'a skill call')} failed`, finishedOk(wi) ? 'fixed' : 'unknown');
|
|
456
|
+
else if (ev.type === 'message.outbound' && ev.outcome === 'failed')
|
|
457
|
+
push(ev.summary, 'unknown');
|
|
458
|
+
}
|
|
459
|
+
if (wi.retryCount > 0)
|
|
460
|
+
push(`retried ${wi.retryCount} time${wi.retryCount === 1 ? '' : 's'}${typeof wi.metadata?.lastFailureReason === 'string' ? `: ${wi.metadata.lastFailureReason}` : ''}`, finishedOk(wi) ? 'fixed' : 'unresolved');
|
|
461
|
+
if (wi.status === 'failed' && wi.error)
|
|
462
|
+
push(wi.error, 'unresolved');
|
|
463
|
+
return out.slice(0, C.MAX_ERRORS);
|
|
464
|
+
}
|
|
465
|
+
function finishedOk(wi) {
|
|
466
|
+
return wi.status === 'done' || wi.status === 'verified';
|
|
467
|
+
}
|
|
468
|
+
function collectHelpers(wi, inputs, prefixes) {
|
|
469
|
+
const out = [];
|
|
470
|
+
const doer = wi.target;
|
|
471
|
+
const startMs = Date.parse(wi.startedAt ?? wi.createdAt);
|
|
472
|
+
const endMs = Date.parse(workItemEndedAt(wi));
|
|
473
|
+
const verify = (inputs.children ?? []).find((c) => isVerifyWorkItem(c) && (c.metadata?.verifyOf === wi.id || c.id.startsWith(`${wi.id}:verify:`)));
|
|
474
|
+
for (const child of inputs.children ?? []) {
|
|
475
|
+
if (isVerifyWorkItem(child) || child.id === wi.id)
|
|
476
|
+
continue;
|
|
477
|
+
const byParent = child.parentWorkItemId === wi.id;
|
|
478
|
+
const createdMs = Date.parse(child.createdAt);
|
|
479
|
+
const byDoer = !!doer && child.metadata?.delegatedBy === doer && Number.isFinite(createdMs) && createdMs >= startMs - C.TRANSCRIPT_WINDOW_SLACK_MS && (!Number.isFinite(endMs) || createdMs <= endMs + C.TRANSCRIPT_WINDOW_SLACK_MS);
|
|
480
|
+
if (!byParent && !byDoer)
|
|
481
|
+
continue;
|
|
482
|
+
if (!child.target || child.target === doer)
|
|
483
|
+
continue;
|
|
484
|
+
out.push({ how: 'delegated', name: inputs.nameOf(child.target), session: child.target, what: cleanOwnerText(child.title, prefixes, 60), back: child.status });
|
|
485
|
+
}
|
|
486
|
+
const asked = new Map();
|
|
487
|
+
const dispatchers = new Set(['WorkItemDispatch']);
|
|
488
|
+
for (const ev of inputs.trace ?? []) {
|
|
489
|
+
if (ev.type !== 'message.agent' || !doer)
|
|
490
|
+
continue;
|
|
491
|
+
const from = ev.actor.session;
|
|
492
|
+
const to = ev.refs.session;
|
|
493
|
+
if (!from || !to || dispatchers.has(from))
|
|
494
|
+
continue;
|
|
495
|
+
if (from === doer && to !== doer && !out.some((h) => h.session === to)) {
|
|
496
|
+
if (!asked.has(to))
|
|
497
|
+
asked.set(to, { how: 'asked', name: inputs.nameOf(to), session: to, back: 'no answer' });
|
|
498
|
+
}
|
|
499
|
+
else if (to === doer && asked.has(from)) {
|
|
500
|
+
const h = asked.get(from);
|
|
501
|
+
if (h)
|
|
502
|
+
h.back = 'answered';
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
for (const ev of inputs.trace ?? []) {
|
|
506
|
+
if (ev.type !== 'turn.delivered' || !doer)
|
|
507
|
+
continue;
|
|
508
|
+
const sender = typeof ev.data?.sender === 'string' ? ev.data.sender : null;
|
|
509
|
+
if (sender && asked.has(sender) && ev.summary.includes(`delivered to ${doer}`)) {
|
|
510
|
+
const h = asked.get(sender);
|
|
511
|
+
if (h)
|
|
512
|
+
h.back = 'answered';
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
out.push(...asked.values());
|
|
516
|
+
const reviewer = verify?.target ?? (typeof wi.metadata?.reviewedBy === 'string' ? wi.metadata.reviewedBy : undefined);
|
|
517
|
+
if (reviewer && reviewer !== doer)
|
|
518
|
+
out.push({ how: 'reviewed', name: inputs.nameOf(reviewer), session: reviewer });
|
|
519
|
+
return out.slice(0, C.MAX_HELPERS);
|
|
520
|
+
}
|
|
521
|
+
function resolveOutcome(wi, inputs, prefixes) {
|
|
522
|
+
const reviewer = reviewerOf(wi, inputs);
|
|
523
|
+
const reviewerName = reviewer ? inputs.nameOf(reviewer) : undefined;
|
|
524
|
+
if (inputs.phase === 'blocked' || wi.status === 'blocked') {
|
|
525
|
+
return { outcome: 'blocked', outcomeNote: cleanOwnerText(wi.blockedReason, prefixes, 160) || undefined };
|
|
526
|
+
}
|
|
527
|
+
switch (wi.status) {
|
|
528
|
+
case 'cancelled':
|
|
529
|
+
return { outcome: 'cancelled', outcomeNote: cleanOwnerText(wi.cancelReason, prefixes, 160) || undefined };
|
|
530
|
+
case 'failed':
|
|
531
|
+
return { outcome: 'failed', outcomeNote: cleanOwnerText(wi.error, prefixes, 160) || undefined };
|
|
532
|
+
case 'rejected': {
|
|
533
|
+
const bag = { ...(wi.output ?? {}), ...(wi.result ?? {}) };
|
|
534
|
+
const note = typeof bag.rejectionReason === 'string' ? bag.rejectionReason : typeof bag.verdict === 'string' ? bag.verdict : undefined;
|
|
535
|
+
return { outcome: 'rejected', outcomeBy: reviewerName, outcomeNote: cleanOwnerText(note, prefixes, 160) || undefined };
|
|
536
|
+
}
|
|
537
|
+
default:
|
|
538
|
+
break;
|
|
539
|
+
}
|
|
540
|
+
// done / verified: the linked ticket decides whether the owner had a say.
|
|
541
|
+
const ticket = inputs.ticket;
|
|
542
|
+
const request = inputs.request;
|
|
543
|
+
if (request?.acceptedBy === 'silence' || request?.tags?.includes(TICKET_CONSTANTS.REVIEW.AUTO_ACCEPTED_TAG))
|
|
544
|
+
return { outcome: 'silence_accepted' };
|
|
545
|
+
if (request?.acceptedBy === 'owner')
|
|
546
|
+
return { outcome: 'owner_verified', outcomeBy: 'the owner' };
|
|
547
|
+
if (ticket?.ownerReview) {
|
|
548
|
+
if (ticket.status === 'done') {
|
|
549
|
+
if (ticket.labels.includes(TICKET_CONSTANTS.REVIEW.AUTO_ACCEPTED_TAG) || ticketLogSaysSilence(ticket))
|
|
550
|
+
return { outcome: 'silence_accepted' };
|
|
551
|
+
return { outcome: 'owner_verified', outcomeBy: 'the owner' };
|
|
552
|
+
}
|
|
553
|
+
if (ticket.status === 'review')
|
|
554
|
+
return { outcome: 'waiting_owner', outcomeBy: reviewerName };
|
|
555
|
+
}
|
|
556
|
+
if (wi.status === 'verified')
|
|
557
|
+
return { outcome: 'lead_verified', outcomeBy: reviewerName };
|
|
558
|
+
return { outcome: 'done' };
|
|
559
|
+
}
|
|
560
|
+
function reviewerOf(wi, inputs) {
|
|
561
|
+
const verify = (inputs.children ?? []).find((c) => isVerifyWorkItem(c) && (c.metadata?.verifyOf === wi.id || c.id.startsWith(`${wi.id}:verify:`)));
|
|
562
|
+
const r = verify?.target ?? (typeof wi.metadata?.reviewedBy === 'string' ? wi.metadata.reviewedBy : undefined);
|
|
563
|
+
return r && r !== wi.target ? r : undefined;
|
|
564
|
+
}
|
|
565
|
+
function ticketLogSaysSilence(ticket) {
|
|
566
|
+
const last = ticket.log[ticket.log.length - 1] ?? '';
|
|
567
|
+
return /auto[- ]accept|silence|no objection/i.test(last);
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* The outcome phrase used in the headline and the `outcome:` line.
|
|
571
|
+
*
|
|
572
|
+
* @param record - Record
|
|
573
|
+
* @returns Owner-facing phrase
|
|
574
|
+
*/
|
|
575
|
+
export function outcomePhrase(record) {
|
|
576
|
+
switch (record.outcome) {
|
|
577
|
+
case 'owner_verified':
|
|
578
|
+
return 'verified by the owner';
|
|
579
|
+
case 'lead_verified':
|
|
580
|
+
return record.outcomeBy ? `verified by ${record.outcomeBy}` : 'verified';
|
|
581
|
+
case 'silence_accepted':
|
|
582
|
+
return 'accepted (no objection from the owner)';
|
|
583
|
+
case 'done':
|
|
584
|
+
return 'done (no review)';
|
|
585
|
+
case 'rejected':
|
|
586
|
+
return record.outcomeBy ? `rejected by ${record.outcomeBy}` : 'rejected';
|
|
587
|
+
case 'cancelled':
|
|
588
|
+
return 'cancelled';
|
|
589
|
+
case 'failed':
|
|
590
|
+
return 'failed';
|
|
591
|
+
case 'blocked':
|
|
592
|
+
return 'blocked';
|
|
593
|
+
case 'waiting_owner':
|
|
594
|
+
return "finished, waiting for the owner's OK";
|
|
595
|
+
default:
|
|
596
|
+
return String(record.outcome);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
function partySuffix(party) {
|
|
600
|
+
if (party.kind === 'lead')
|
|
601
|
+
return ' (team lead)';
|
|
602
|
+
if (party.kind === 'orchestrator' && party.session)
|
|
603
|
+
return ' (orchestrator)';
|
|
604
|
+
return '';
|
|
605
|
+
}
|
|
606
|
+
function renderHelper(h) {
|
|
607
|
+
const line = h.how === 'delegated'
|
|
608
|
+
? `delegated to ${h.name}: "${h.what ?? ''}" → ${h.back ?? 'open'}`
|
|
609
|
+
: h.how === 'asked'
|
|
610
|
+
? `asked ${h.name} → ${h.back ?? 'no answer'}`
|
|
611
|
+
: `reviewed by ${h.name}`;
|
|
612
|
+
return clip(line, C.HELPER_MAX_CHARS);
|
|
613
|
+
}
|
|
614
|
+
function humanDuration(ms) {
|
|
615
|
+
const minutes = Math.round(ms / 60_000);
|
|
616
|
+
if (minutes < 1)
|
|
617
|
+
return 'under a minute';
|
|
618
|
+
if (minutes < 60)
|
|
619
|
+
return `${minutes} min`;
|
|
620
|
+
const hours = Math.round(minutes / 6) / 10;
|
|
621
|
+
if (hours < 48)
|
|
622
|
+
return `${hours} h`;
|
|
623
|
+
return `${Math.round(hours / 24)} d`;
|
|
624
|
+
}
|
|
625
|
+
function metaToken(value) {
|
|
626
|
+
return value.replace(/\s+/g, '_').replace(/[^A-Za-z0-9_.:-]/g, '');
|
|
627
|
+
}
|
|
628
|
+
function splitList(value) {
|
|
629
|
+
return (value ?? '').split(/;\s+/).map((s) => s.trim()).filter(Boolean);
|
|
630
|
+
}
|
|
631
|
+
//# sourceMappingURL=work-record.js.map
|