@indigoai-us/hq-cli 5.219.0 → 5.221.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,522 @@
1
+ import { LanesError } from "./errors.js";
2
+ import { normalizeIsoTimestamp } from "./timestamp.js";
3
+ function reportError(error, message, details = {}) {
4
+ throw new LanesError(error, message, details);
5
+ }
6
+ function validTimestamp(value, field, sourcePath, details = {}) {
7
+ const normalized = normalizeIsoTimestamp(value);
8
+ if (normalized === null) {
9
+ return reportError("tracker_report_record_invalid", `${field} must be a valid ISO timestamp`, { field, path: sourcePath, ...details });
10
+ }
11
+ return normalized;
12
+ }
13
+ function requireString(record, field) {
14
+ const value = record[field];
15
+ if (typeof value !== "string" || value.trim() === "") {
16
+ return reportError("tracker_report_record_invalid", `${field} must be a non-empty string`, { id: record.id, path: record.source_path, field });
17
+ }
18
+ return value;
19
+ }
20
+ function requireNumber(record, field) {
21
+ const value = record[field];
22
+ if (typeof value !== "number" || !Number.isFinite(value)) {
23
+ return reportError("tracker_report_record_invalid", `${field} must be a finite number`, { id: record.id, path: record.source_path, field });
24
+ }
25
+ return value;
26
+ }
27
+ function recordInProject(record, company, projectId) {
28
+ return record.company === company && record.project_id === projectId;
29
+ }
30
+ function approvalState(record) {
31
+ if (record.status === "approved")
32
+ return "approved";
33
+ if (record.status === "proposed")
34
+ return "proposal";
35
+ return reportError("tracker_report_record_invalid", "status must be proposed or approved", { id: record.id, path: record.source_path, field: "status" });
36
+ }
37
+ function reportQuestion(question) {
38
+ const { record, body } = question;
39
+ if (typeof body.question !== "string" || !body.question.trim()) {
40
+ return reportError("tracker_report_question_invalid", "Question text must be a non-empty string", { question_id: record.question_id, path: question.source_path });
41
+ }
42
+ if (!Array.isArray(body.options) || body.options.some((option) => typeof option !== "string" || !option.trim())) {
43
+ return reportError("tracker_report_question_invalid", "Question options must be non-empty strings", { question_id: record.question_id, path: question.source_path });
44
+ }
45
+ if (body.recommended !== undefined && (typeof body.recommended !== "string" || !body.recommended.trim())) {
46
+ return reportError("tracker_report_question_invalid", "Question recommendation must be a non-empty string", { question_id: record.question_id, path: question.source_path });
47
+ }
48
+ const output = {
49
+ question_id: record.question_id,
50
+ question: body.question,
51
+ asked_at: validTimestamp(record.asked_at, "asked_at", question.source_path),
52
+ audience: question.current_audience,
53
+ options: [...body.options],
54
+ };
55
+ if (body.recommended !== undefined)
56
+ output.recommended = body.recommended;
57
+ if (record.status === "answered") {
58
+ if (!record.answer || typeof record.answer.choice !== "string" || !record.answer.choice.trim() || typeof record.answer.answered_by !== "string" || !record.answer.answered_by.trim()) {
59
+ return reportError("tracker_report_question_invalid", "Answered question is missing its answer", { question_id: record.question_id, path: question.source_path });
60
+ }
61
+ output.answer = {
62
+ choice: record.answer.choice,
63
+ answered_at: validTimestamp(record.answer.answered_at, "answered_at", question.source_path),
64
+ answered_by: record.answer.answered_by,
65
+ };
66
+ }
67
+ return output;
68
+ }
69
+ function projectQuestions(team, company) {
70
+ return team.questions.filter(({ record }) => record.company?.slug === company && record.project_id === team.project_id);
71
+ }
72
+ function openBlockers(team, company, throughTime) {
73
+ return team.blockers
74
+ .filter((record) => recordInProject(record, company, team.project_id))
75
+ .filter((record) => {
76
+ if (record.resolved_at === null)
77
+ return true;
78
+ if (typeof record.resolved_at !== "string") {
79
+ return reportError("tracker_report_record_invalid", "resolved_at must be null or an ISO timestamp", { id: record.id, path: record.source_path, field: "resolved_at" });
80
+ }
81
+ return Date.parse(validTimestamp(record.resolved_at, "resolved_at", record.source_path)) > throughTime;
82
+ })
83
+ .map((record) => ({
84
+ id: record.id,
85
+ story_id: requireString(record, "story_id"),
86
+ what: requireString(record, "what"),
87
+ waiting_on: requireString(record, "waiting_on"),
88
+ next_step: requireString(record, "next_step"),
89
+ opened_at: validTimestamp(record.opened_at, "opened_at", record.source_path),
90
+ }))
91
+ .filter((blocker) => Date.parse(blocker.opened_at) <= throughTime)
92
+ .sort((a, b) => a.opened_at.localeCompare(b.opened_at) || a.id.localeCompare(b.id));
93
+ }
94
+ function kpiValuesSince(record, sinceTime, throughTime) {
95
+ const name = requireString(record, "name");
96
+ const unit = requireString(record, "unit");
97
+ if (!Array.isArray(record.values)) {
98
+ return reportError("tracker_report_record_invalid", "values must be an array", { id: record.id, path: record.source_path, field: "values" });
99
+ }
100
+ return record.values
101
+ .map((raw) => {
102
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
103
+ return reportError("tracker_report_record_invalid", "KPI value must be an object", { id: record.id, path: record.source_path, field: "values" });
104
+ }
105
+ const value = raw;
106
+ const at = validTimestamp(value.at, "values[].at", record.source_path);
107
+ if (typeof value.value !== "number" || !Number.isFinite(value.value) || typeof value.source !== "string") {
108
+ return reportError("tracker_report_record_invalid", "KPI value needs a finite value and source", { id: record.id, path: record.source_path, field: "values" });
109
+ }
110
+ return { at, value: value.value, source: value.source };
111
+ })
112
+ .filter((value) => Date.parse(value.at) >= sinceTime && Date.parse(value.at) <= throughTime)
113
+ .map((value) => ({ kpi_id: record.id, name, at: value.at, value: value.value, unit, source: value.source }))
114
+ .sort((a, b) => a.at.localeCompare(b.at) || a.kpi_id.localeCompare(b.kpi_id));
115
+ }
116
+ function directiveDecisions(team, company, sinceTime, throughTime) {
117
+ return team.directives
118
+ .filter((record) => recordInProject(record, company, team.project_id))
119
+ .flatMap((record) => {
120
+ if (!Array.isArray(record.teams) || record.teams.some((item) => typeof item !== "string")) {
121
+ return reportError("tracker_report_record_invalid", "teams must be an array of team slugs", { id: record.id, path: record.source_path, field: "teams" });
122
+ }
123
+ if (!record.teams.includes(team.team))
124
+ return [];
125
+ if (!Array.isArray(record.acknowledgements)) {
126
+ return reportError("tracker_report_record_invalid", "acknowledgements must be an array", { id: record.id, path: record.source_path, field: "acknowledgements" });
127
+ }
128
+ const acknowledgements = record.acknowledgements.map((raw) => {
129
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
130
+ return reportError("tracker_report_record_invalid", "Directive acknowledgement must be an object", { id: record.id, path: record.source_path, field: "acknowledgements" });
131
+ }
132
+ const ack = raw;
133
+ if (typeof ack.team !== "string" || typeof ack.lane_id !== "string") {
134
+ return reportError("tracker_report_record_invalid", "Directive acknowledgement needs team and lane_id", { id: record.id, path: record.source_path, field: "acknowledgements" });
135
+ }
136
+ return { team: ack.team, lane_id: ack.lane_id, at: validTimestamp(ack.at, "acknowledgements[].at", record.source_path) };
137
+ }).filter((ack) => ack.team === team.team && Date.parse(ack.at) >= sinceTime && Date.parse(ack.at) <= throughTime);
138
+ const date = requireString(record, "date");
139
+ const parsedDate = new Date(`${date}T00:00:00.000Z`);
140
+ if (!/^\d{4}-\d\d-\d\d$/.test(date) || !Number.isFinite(parsedDate.getTime()) || parsedDate.toISOString().slice(0, 10) !== date) {
141
+ return reportError("tracker_report_record_invalid", "date must be a calendar date in YYYY-MM-DD form", { id: record.id, path: record.source_path, field: "date" });
142
+ }
143
+ const createdAt = validTimestamp(record.created_at, "created_at", record.source_path);
144
+ if (Date.parse(createdAt) > throughTime)
145
+ return [];
146
+ if (Date.parse(createdAt) < sinceTime && acknowledgements.length === 0)
147
+ return [];
148
+ return {
149
+ id: record.id,
150
+ text: requireString(record, "text"),
151
+ date,
152
+ acknowledgements: acknowledgements.sort((a, b) => a.at.localeCompare(b.at) || a.lane_id.localeCompare(b.lane_id)),
153
+ };
154
+ })
155
+ .sort((a, b) => a.date.localeCompare(b.date) || a.id.localeCompare(b.id));
156
+ }
157
+ function storyActivity(team, sinceTime, throughTime) {
158
+ return team.stories
159
+ .filter((story) => story.status === "done")
160
+ .map((story) => ({ story, at: validTimestamp(story.updated_at, "updated_at", story.source_path, { story_id: story.id }) }))
161
+ .filter(({ at }) => Date.parse(at) >= sinceTime && Date.parse(at) <= throughTime)
162
+ .map(({ story, at }) => ({ id: story.id, title: story.title, at, references: [...(story.references ?? [])] }))
163
+ .sort((a, b) => a.at.localeCompare(b.at) || a.id.localeCompare(b.id));
164
+ }
165
+ function pullRequestActivity(team, sinceTime, throughTime) {
166
+ return team.pull_requests
167
+ .map((pr) => ({ ...pr, merged_at: validTimestamp(pr.merged_at, "merged_at", pr.source_path) }))
168
+ .filter((pr) => Date.parse(pr.merged_at) >= sinceTime && Date.parse(pr.merged_at) <= throughTime)
169
+ .map(({ number, title, merged_at }) => ({ number, title, merged_at }))
170
+ .sort((a, b) => a.merged_at.localeCompare(b.merged_at) || a.number.localeCompare(b.number));
171
+ }
172
+ function releaseActivity(team, sinceTime, throughTime) {
173
+ return team.releases
174
+ .map((release) => ({ ...release, released_at: validTimestamp(release.released_at, "released_at", release.source_path) }))
175
+ .filter((release) => Date.parse(release.released_at) >= sinceTime && Date.parse(release.released_at) <= throughTime)
176
+ .map(({ version, released_at }) => ({ version, released_at }))
177
+ .sort((a, b) => a.released_at.localeCompare(b.released_at) || a.version.localeCompare(b.version));
178
+ }
179
+ function teamOwnerReport(team, company, sinceTime, throughTime) {
180
+ const questions = projectQuestions(team, company);
181
+ const answeredQuestions = questions
182
+ .filter(({ record }) => record.status === "answered")
183
+ .map(reportQuestion)
184
+ .filter((question) => question.answer !== undefined && Date.parse(question.answer.answered_at) >= sinceTime && Date.parse(question.answer.answered_at) <= throughTime)
185
+ .sort((a, b) => a.answer.answered_at.localeCompare(b.answer.answered_at) || a.question_id.localeCompare(b.question_id));
186
+ const waitingQuestions = questions
187
+ .filter(({ record, current_audience }) => record.status === "pending" && current_audience === "owner")
188
+ .map(reportQuestion)
189
+ .filter((question) => Date.parse(question.asked_at) <= throughTime)
190
+ .sort((a, b) => a.asked_at.localeCompare(b.asked_at) || a.question_id.localeCompare(b.question_id));
191
+ const kpiValues = team.kpis
192
+ .filter((record) => recordInProject(record, company, team.project_id))
193
+ .flatMap((record) => kpiValuesSince(record, sinceTime, throughTime));
194
+ return {
195
+ team: team.team,
196
+ project_id: team.project_id,
197
+ errors: [...(team.errors ?? [])],
198
+ progress: {
199
+ stories_moved_to_done: storyActivity(team, sinceTime, throughTime),
200
+ pull_requests_merged: pullRequestActivity(team, sinceTime, throughTime),
201
+ releases: releaseActivity(team, sinceTime, throughTime),
202
+ kpi_values: kpiValues.sort((a, b) => a.at.localeCompare(b.at) || a.kpi_id.localeCompare(b.kpi_id)),
203
+ },
204
+ questions_waiting_on_owner: waitingQuestions,
205
+ open_blockers: openBlockers(team, company, throughTime),
206
+ decisions: {
207
+ directives: directiveDecisions(team, company, sinceTime, throughTime),
208
+ answered_questions: answeredQuestions,
209
+ },
210
+ };
211
+ }
212
+ function validateTeamIdentity(team) {
213
+ if (!team.team.trim() || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(team.team)) {
214
+ reportError("tracker_report_team_invalid", "Team slug must be a safe path segment", { team: team.team });
215
+ }
216
+ if (!team.project_id.trim() || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(team.project_id)) {
217
+ reportError("tracker_report_team_invalid", "Project slug must be a safe path segment", { team: team.team, project_id: team.project_id });
218
+ }
219
+ }
220
+ function validatedWindow(input) {
221
+ const sourcePath = "<report arguments>";
222
+ const generatedAt = validTimestamp(input.generated_at, "generated_at", sourcePath);
223
+ const since = validTimestamp(input.since, "since", sourcePath);
224
+ const generatedAtTime = Date.parse(generatedAt);
225
+ const sinceTime = Date.parse(since);
226
+ if (sinceTime > generatedAtTime) {
227
+ reportError("tracker_report_window_invalid", "since must not be later than generated_at", { since, generated_at: generatedAt });
228
+ }
229
+ if (!input.company.trim() || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(input.company)) {
230
+ reportError("tracker_report_company_invalid", "Company slug must be a safe path segment", { company: input.company });
231
+ }
232
+ return { generatedAt, since, sinceTime, generatedAtTime };
233
+ }
234
+ function removeInvalidEntity(team, err) {
235
+ if (!(err instanceof LanesError))
236
+ return null;
237
+ const details = err.details;
238
+ const path = typeof details.path === "string" ? details.path : "<unknown tracker record>";
239
+ const field = typeof details.field === "string" ? details.field : "record";
240
+ const message = err.message;
241
+ const storyId = typeof details.story_id === "string" ? details.story_id : undefined;
242
+ const recordId = typeof details.id === "string" ? details.id : undefined;
243
+ const questionId = typeof details.question_id === "string" ? details.question_id : undefined;
244
+ let next = team;
245
+ if (storyId) {
246
+ const stories = team.stories.filter((story) => story.id !== storyId || (path !== "<unknown tracker record>" && story.source_path !== path));
247
+ if (stories.length === team.stories.length)
248
+ return null;
249
+ next = { ...next, stories };
250
+ }
251
+ else if (questionId) {
252
+ const questions = team.questions.filter(({ record, source_path }) => record.question_id !== questionId || source_path !== path);
253
+ if (questions.length === team.questions.length)
254
+ return null;
255
+ next = { ...next, questions };
256
+ }
257
+ else if (recordId) {
258
+ const remove = (records) => records.filter((record) => record.id !== recordId || record.source_path !== path);
259
+ const blockers = remove(team.blockers);
260
+ const kpis = remove(team.kpis);
261
+ const okrs = remove(team.okrs);
262
+ const directives = remove(team.directives);
263
+ if ([blockers, kpis, okrs, directives].every((records, index) => records.length === [team.blockers, team.kpis, team.okrs, team.directives][index].length))
264
+ return null;
265
+ next = { ...next, blockers, kpis, okrs, directives };
266
+ }
267
+ else if (err.error === "tracker_report_record_invalid" && path !== "<unknown tracker record>") {
268
+ const pullRequests = team.pull_requests.filter((record) => record.source_path !== path);
269
+ const releases = team.releases.filter((record) => record.source_path !== path);
270
+ if (pullRequests.length === team.pull_requests.length && releases.length === team.releases.length)
271
+ return null;
272
+ next = { ...next, pull_requests: pullRequests, releases };
273
+ }
274
+ else {
275
+ return null;
276
+ }
277
+ return {
278
+ team: next,
279
+ issue: {
280
+ path,
281
+ field,
282
+ message,
283
+ ...(storyId ? { story_id: storyId } : {}),
284
+ ...(recordId ? { record_id: recordId } : {}),
285
+ ...(questionId ? { question_id: questionId } : {}),
286
+ },
287
+ };
288
+ }
289
+ function withRecoverableTeamErrors(team, project) {
290
+ let current = team;
291
+ const errors = [...(team.errors ?? [])];
292
+ const attempts = team.stories.length + team.questions.length + team.blockers.length + team.kpis.length + team.okrs.length + team.directives.length + team.pull_requests.length + team.releases.length + 1;
293
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
294
+ try {
295
+ return project({ ...current, errors });
296
+ }
297
+ catch (err) {
298
+ const recovery = removeInvalidEntity(current, err);
299
+ if (!recovery)
300
+ throw err;
301
+ errors.push(recovery.issue);
302
+ current = recovery.team;
303
+ }
304
+ }
305
+ return reportError("tracker_report_recovery_exhausted", "Could not isolate malformed tracker records", { path: "<team report>", field: "record" });
306
+ }
307
+ /** Pure report projection. The caller owns loading, tenant checks, body reads and report-time persistence. */
308
+ export function buildTrackerOwnerReport(input) {
309
+ const window = validatedWindow(input);
310
+ const eligibleTeams = input.teams.filter((team) => team.company === input.company);
311
+ const seen = new Set();
312
+ for (const team of eligibleTeams) {
313
+ validateTeamIdentity(team);
314
+ if (seen.has(team.team)) {
315
+ reportError("tracker_report_team_ambiguous", "More than one report input maps to the same team", { team: team.team });
316
+ }
317
+ seen.add(team.team);
318
+ }
319
+ return {
320
+ schema_version: "hq-lanes-owner-report.v1",
321
+ company: input.company,
322
+ generated_at: window.generatedAt,
323
+ since: window.since,
324
+ since_source: input.since_source,
325
+ teams: eligibleTeams
326
+ .map((team) => withRecoverableTeamErrors(team, (safeTeam) => teamOwnerReport(safeTeam, input.company, window.sinceTime, window.generatedAtTime)))
327
+ .sort((a, b) => a.team.localeCompare(b.team)),
328
+ };
329
+ }
330
+ function teamKpis(team, company, throughTime) {
331
+ return team.kpis
332
+ .filter((record) => recordInProject(record, company, team.project_id))
333
+ .map((record) => {
334
+ const name = requireString(record, "name");
335
+ const definition = requireString(record, "definition");
336
+ const unit = requireString(record, "unit");
337
+ const target = requireNumber(record, "target");
338
+ if (record.direction !== "up" && record.direction !== "down") {
339
+ return reportError("tracker_report_record_invalid", "direction must be up or down", { id: record.id, path: record.source_path, field: "direction" });
340
+ }
341
+ const direction = record.direction;
342
+ if (!Array.isArray(record.values)) {
343
+ return reportError("tracker_report_record_invalid", "values must be an array", { id: record.id, path: record.source_path, field: "values" });
344
+ }
345
+ const values = record.values.map((raw) => {
346
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
347
+ return reportError("tracker_report_record_invalid", "KPI value must be an object", { id: record.id, path: record.source_path, field: "values" });
348
+ }
349
+ const value = raw;
350
+ if (typeof value.value !== "number" || !Number.isFinite(value.value) || typeof value.source !== "string") {
351
+ return reportError("tracker_report_record_invalid", "KPI value needs a finite value and source", { id: record.id, path: record.source_path, field: "values" });
352
+ }
353
+ return { at: validTimestamp(value.at, "values[].at", record.source_path), value: value.value, source: value.source };
354
+ }).filter((value) => Date.parse(value.at) <= throughTime).sort((a, b) => a.at.localeCompare(b.at));
355
+ const recent = values.slice(-3);
356
+ const previous = recent.length > 1 ? recent[recent.length - 2].value : null;
357
+ const latest = recent[recent.length - 1] ?? null;
358
+ const change = latest && previous !== null ? latest.value - previous : null;
359
+ const progress = change === null ? null : direction === "up" ? change : -change;
360
+ const trend = progress === null ? "insufficient_data" : progress > 0 ? "improving" : progress < 0 ? "regressing" : "flat";
361
+ return {
362
+ id: record.id,
363
+ name,
364
+ definition,
365
+ unit,
366
+ target,
367
+ direction,
368
+ status: approvalState(record),
369
+ latest_value: latest,
370
+ recent_values: recent,
371
+ trend,
372
+ };
373
+ })
374
+ .sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
375
+ }
376
+ function teamOkrs(team, company) {
377
+ return team.okrs
378
+ .filter((record) => recordInProject(record, company, team.project_id))
379
+ .map((record) => {
380
+ const objective = requireString(record, "objective");
381
+ const period = requireString(record, "period");
382
+ if (!Array.isArray(record.key_results)) {
383
+ return reportError("tracker_report_record_invalid", "key_results must be an array", { id: record.id, path: record.source_path, field: "key_results" });
384
+ }
385
+ const keyResults = record.key_results.map((raw) => {
386
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
387
+ return reportError("tracker_report_record_invalid", "OKR key result must be an object", { id: record.id, path: record.source_path, field: "key_results" });
388
+ }
389
+ const result = raw;
390
+ if (typeof result.text !== "string" || typeof result.kpi_id !== "string" || typeof result.target !== "number" || !Number.isFinite(result.target)) {
391
+ return reportError("tracker_report_record_invalid", "OKR key result needs text, kpi_id and finite target", { id: record.id, path: record.source_path, field: "key_results" });
392
+ }
393
+ return { text: result.text, kpi_id: result.kpi_id, target: result.target };
394
+ });
395
+ return { id: record.id, objective, period, status: approvalState(record), key_results: keyResults };
396
+ })
397
+ .sort((a, b) => a.period.localeCompare(b.period) || a.id.localeCompare(b.id));
398
+ }
399
+ function pendingTeamQuestions(team, company, throughTime) {
400
+ return projectQuestions(team, company)
401
+ .filter(({ record }) => record.status === "pending")
402
+ .map(reportQuestion)
403
+ .filter((question) => Date.parse(question.asked_at) <= throughTime)
404
+ .sort((a, b) => a.asked_at.localeCompare(b.asked_at) || a.question_id.localeCompare(b.question_id));
405
+ }
406
+ /** Pure team page projection for already loaded, company-checked records. */
407
+ export function buildTrackerTeamPage(team, company, sinceValue, generatedAtValue) {
408
+ const since = validTimestamp(sinceValue, "since", "<report arguments>");
409
+ const generatedAt = validTimestamp(generatedAtValue, "generated_at", "<report arguments>");
410
+ const sinceTime = Date.parse(since);
411
+ const generatedAtTime = Date.parse(generatedAt);
412
+ if (sinceTime > generatedAtTime) {
413
+ reportError("tracker_report_window_invalid", "since must not be later than generated_at", { since, generated_at: generatedAt });
414
+ }
415
+ return withRecoverableTeamErrors(team, (safeTeam) => {
416
+ if (safeTeam.company !== company) {
417
+ reportError("tracker_company_mismatch", "Team page data does not belong to the resolved company", { active_company: company, record_company: safeTeam.company });
418
+ }
419
+ validateTeamIdentity(safeTeam);
420
+ const workInProgress = safeTeam.stories
421
+ .filter((story) => story.status !== "done" && story.status !== "dropped")
422
+ .map((story) => {
423
+ if (!["queued", "in_progress", "review"].includes(story.status)) {
424
+ return reportError("tracker_report_story_invalid", "Story status is not in the tracker vocabulary", { story_id: story.id, path: story.source_path, field: "status", status: story.status });
425
+ }
426
+ return {
427
+ id: story.id,
428
+ title: story.title,
429
+ status: story.status,
430
+ references: [...(story.references ?? [])],
431
+ updated_at: story.updated_at,
432
+ source_path: story.source_path,
433
+ };
434
+ })
435
+ .filter((story) => story.updated_at === undefined || Date.parse(validTimestamp(story.updated_at, "updated_at", story.source_path, { story_id: story.id })) <= generatedAtTime)
436
+ .map(({ id, title, status, references }) => ({ id, title, status, references }))
437
+ .sort((a, b) => a.id.localeCompare(b.id));
438
+ return {
439
+ schema_version: "hq-lanes-team-page.v1",
440
+ company,
441
+ team: safeTeam.team,
442
+ project_id: safeTeam.project_id,
443
+ since,
444
+ errors: [...(safeTeam.errors ?? [])],
445
+ okrs: teamOkrs(safeTeam, company),
446
+ kpis: teamKpis(safeTeam, company, generatedAtTime),
447
+ work_in_progress: workInProgress,
448
+ work_done_since: storyActivity(safeTeam, sinceTime, generatedAtTime),
449
+ blockers: openBlockers(safeTeam, company, generatedAtTime),
450
+ questions: pendingTeamQuestions(safeTeam, company, generatedAtTime),
451
+ };
452
+ });
453
+ }
454
+ function listOrNone(items, render) {
455
+ return items.length ? items.map((item) => ` - ${render(item)}`) : [" - none"];
456
+ }
457
+ /** Human-readable owner report, rendered without editing after the command runs. */
458
+ export function renderTrackerOwnerReportText(report) {
459
+ const lines = [
460
+ `Owner report for ${report.company}`,
461
+ `Reporting window: ${report.since} through ${report.generated_at} (${report.since_source.replaceAll("_", " ")})`,
462
+ ];
463
+ for (const team of report.teams) {
464
+ lines.push("", `${team.team} (${team.project_id})`, "Progress:", " Stories moved to done:");
465
+ lines.push(...listOrNone(team.progress.stories_moved_to_done, (story) => `${story.id} — ${story.title} (${story.at})${story.references.length ? `; references: ${story.references.join(", ")}` : ""}`));
466
+ lines.push(" Pull requests merged:");
467
+ lines.push(...listOrNone(team.progress.pull_requests_merged, (pr) => `#${pr.number} — ${pr.title} (${pr.merged_at})`));
468
+ lines.push(" Releases:");
469
+ lines.push(...listOrNone(team.progress.releases, (release) => `${release.version} (${release.released_at})`));
470
+ lines.push(" KPI values recorded:");
471
+ lines.push(...listOrNone(team.progress.kpi_values, (value) => `${value.name}: ${value.value} ${value.unit} (${value.at}; source: ${value.source})`));
472
+ lines.push("Questions waiting on the owner:");
473
+ lines.push(...listOrNone(team.questions_waiting_on_owner, (question) => {
474
+ const recommendation = question.recommended ? `; recommendation: ${question.recommended}` : "";
475
+ return `${question.question_id} — ${question.question}; options: ${question.options.join(" | ")}${recommendation}`;
476
+ }));
477
+ lines.push("Open blockers:");
478
+ lines.push(...listOrNone(team.open_blockers, (blocker) => `${blocker.id} — ${blocker.what}; waiting on ${blocker.waiting_on}; next: ${blocker.next_step}`));
479
+ lines.push("Decisions taken:");
480
+ const decisions = [
481
+ ...team.decisions.directives.map((directive) => {
482
+ const acknowledgements = directive.acknowledgements.length
483
+ ? directive.acknowledgements.map((ack) => `${ack.team}/${ack.lane_id} (${ack.at})`).join(", ")
484
+ : "none";
485
+ return `Directive ${directive.id} (${directive.date}): ${directive.text}; acknowledgements: ${acknowledgements}`;
486
+ }),
487
+ ...team.decisions.answered_questions.map((question) => `Question ${question.question_id} answered ${question.answer.choice} by ${question.answer.answered_by} (${question.answer.answered_at}): ${question.question}`),
488
+ ];
489
+ lines.push(...(decisions.length ? decisions.map((decision) => ` - ${decision}`) : [" - none"]));
490
+ lines.push("Errors:");
491
+ lines.push(...listOrNone(team.errors, (issue) => `${issue.path} — ${issue.story_id ? `story ${issue.story_id}` : issue.record_id ? `record ${issue.record_id}` : issue.question_id ? `question ${issue.question_id}` : "record"}; ${issue.field}: ${issue.message}`));
492
+ }
493
+ return `${lines.join("\n")}\n`;
494
+ }
495
+ /** Human-readable team page, with proposal status visible beside each objective and KPI. */
496
+ export function renderTrackerTeamPageText(page) {
497
+ const lines = [`Team page for ${page.team}`, `Company: ${page.company}`, `Work completed since: ${page.since}`, "OKRs:"];
498
+ lines.push(...listOrNone(page.okrs, (okr) => {
499
+ const label = okr.status === "proposal" ? "Proposal: " : "";
500
+ const results = okr.key_results.map((result) => `${result.text} (target ${result.target}; KPI ${result.kpi_id})`).join("; ");
501
+ return `${label}${okr.objective} [${okr.period}]${results ? ` — ${results}` : ""}`;
502
+ }));
503
+ lines.push("KPIs:");
504
+ lines.push(...listOrNone(page.kpis, (kpi) => {
505
+ const label = kpi.status === "proposal" ? "Proposal: " : "";
506
+ const latest = kpi.latest_value ? `${kpi.latest_value.value} ${kpi.unit}` : "no values";
507
+ const values = kpi.recent_values.map((value) => `${value.value} (${value.at})`).join(" → ") || "none";
508
+ return `${label}${kpi.name}: ${latest} / target ${kpi.target} ${kpi.unit}; trend ${kpi.trend} (${values})`;
509
+ }));
510
+ lines.push("Work in progress:");
511
+ lines.push(...listOrNone(page.work_in_progress, (story) => `${story.id} — ${story.title} [${story.status}]${story.references.length ? `; references: ${story.references.join(", ")}` : ""}`));
512
+ lines.push("Work done since the last report:");
513
+ lines.push(...listOrNone(page.work_done_since, (story) => `${story.id} — ${story.title} (${story.at})${story.references.length ? `; references: ${story.references.join(", ")}` : ""}`));
514
+ lines.push("Open blockers:");
515
+ lines.push(...listOrNone(page.blockers, (blocker) => `${blocker.id} — ${blocker.what}; next: ${blocker.next_step}`));
516
+ lines.push("Open questions:");
517
+ lines.push(...listOrNone(page.questions, (question) => `${question.question_id} — ${question.question} [${question.audience}]`));
518
+ lines.push("Errors:");
519
+ lines.push(...listOrNone(page.errors, (issue) => `${issue.path} — ${issue.story_id ? `story ${issue.story_id}` : issue.record_id ? `record ${issue.record_id}` : issue.question_id ? `question ${issue.question_id}` : "record"}; ${issue.field}: ${issue.message}`));
520
+ return `${lines.join("\n")}\n`;
521
+ }
522
+ //# sourceMappingURL=tracker-report.js.map
@@ -34,7 +34,7 @@
34
34
  import * as fs from "node:fs";
35
35
  import * as path from "node:path";
36
36
  import { LanesError } from "./errors.js";
37
- import { questionRecordRefusalCode, raiseEnvelopeQuestions } from "./questions.js";
37
+ import { questionRecordRefusalCode, raiseEnvelopeQuestions, resolveQuestionCompanyWithFallback, } from "./questions.js";
38
38
  import { isTerminalLaneState, ENVELOPE_INVALID_REASON, parseLaneEnvelope, STORY_STATUSES, } from "./types.js";
39
39
  import { laneStories, storyStatusForEnvelope } from "./stories.js";
40
40
  import { readLane, retryUpdateLane } from "./store.js";
@@ -1070,12 +1070,18 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
1070
1070
  }
1071
1071
  }
1072
1072
  const terminalLane = readLane(hqRoot, laneId);
1073
- const activeQuestionCompany = envelope?.decision === "ask" &&
1074
- (envelope.questions?.length ?? 0) > 0 &&
1075
- terminalLane.company === null &&
1076
- terminalLane.mesh_company === undefined
1077
- ? await deps.resolveQuestionActiveCompany?.()
1078
- : undefined;
1073
+ let activeQuestionCompany;
1074
+ if (envelope?.decision === "ask" && (envelope.questions?.length ?? 0) > 0) {
1075
+ try {
1076
+ activeQuestionCompany = await resolveQuestionCompanyWithFallback(hqRoot, terminalLane, deps.resolveQuestionActiveCompany);
1077
+ }
1078
+ catch (err) {
1079
+ if (questionRecordRefusalCode(err) !== "question_company_unavailable")
1080
+ throw err;
1081
+ // applyTerminalToLaneWithResult repeats the synchronous lane and
1082
+ // project checks, then terminalizes this ask with the named refusal.
1083
+ }
1084
+ }
1079
1085
  const terminalWrite = applyTerminalToLaneWithResult(hqRoot, laneId, classification, {
1080
1086
  envelope,
1081
1087
  envelopeRef: latest?.ref,
package/dist/sentry.js CHANGED
@@ -26,6 +26,9 @@ export function epipeAwareBeforeSend(event, hint) {
26
26
  return null;
27
27
  event.level = "warning";
28
28
  event.tags = { ...event.tags, worker: "flag-registry" };
29
+ const missingFile = flagRegistryWorkerMissingFileTags(hint?.originalException, event);
30
+ if (missingFile)
31
+ event.tags = { ...event.tags, ...missingFile };
29
32
  }
30
33
  if (!flagRegistryWorkerEvent && isEpipe(hint?.originalException))
31
34
  return null;
@@ -123,6 +126,44 @@ function isExpectedFlagRegistryWorkerStartupFailure(error) {
123
126
  return missingModule.replaceAll("\\", "/").endsWith("/flag-registry-worker.js") ||
124
127
  missingModule === "flag-registry-worker.js";
125
128
  }
129
+ function flagRegistryWorkerMissingFileTags(error, event) {
130
+ if (typeof error !== "object" || error === null)
131
+ return null;
132
+ const details = error;
133
+ if (details.code !== "ENOENT" || !["open", "lstat"].includes(String(details.syscall))) {
134
+ return null;
135
+ }
136
+ let missingPath = typeof details.path === "string" ? details.path : "";
137
+ if (!missingPath && typeof details.message === "string") {
138
+ missingPath = /\b(?:open|lstat)\s+['"]([^'"]+)['"]/.exec(details.message)?.[1] ?? "";
139
+ }
140
+ const basename = missingPath.replaceAll("\\", "/").split("/").at(-1) ?? "";
141
+ if (!basename)
142
+ return null;
143
+ const normalizedPath = missingPath.replaceAll("\\", "/");
144
+ const role = basename === "flag-registry-worker.js"
145
+ ? "worker_entry"
146
+ : normalizedPath.includes("/node_modules/")
147
+ ? "dependency"
148
+ : "other";
149
+ const eventText = [
150
+ event.message,
151
+ ...(event.exception?.values?.map((value) => value.value) ?? []),
152
+ ].filter((value) => typeof value === "string").join("\n");
153
+ const pathVisibility = eventText.includes(missingPath)
154
+ ? "full_path"
155
+ : eventText.includes(basename)
156
+ ? "basename_only"
157
+ : "omitted";
158
+ return {
159
+ flag_worker_load_failure: "1",
160
+ flag_worker_missing_basename: basename,
161
+ flag_worker_missing_file_role: role,
162
+ flag_worker_path_in_event: pathVisibility,
163
+ // The HQ beforeSend scrubber redacts credentials but does not strip paths.
164
+ flag_worker_path_scrubbed_by_hq_cli: "no",
165
+ };
166
+ }
126
167
  /**
127
168
  * Tests substitute only the transport so the real SDK lifecycle can be
128
169
  * exercised without an ingest request. Production callers pass nothing.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.219.0",
3
+ "version": "5.221.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -22,12 +22,14 @@
22
22
  "scripts/mesh-daemon-if-stale.mjs"
23
23
  ],
24
24
  "scripts": {
25
- "build": "node scripts/generate-dsn.mjs && tsc && node scripts/generate-command-catalog.mjs --check && node scripts/copy-schemas.mjs && node scripts/chmod-bins.mjs",
25
+ "build": "node scripts/generate-dsn.mjs && node scripts/build-flag-registry-worker.mjs && tsc && node scripts/generate-command-catalog.mjs --check && node scripts/copy-schemas.mjs && node scripts/chmod-bins.mjs",
26
26
  "prepublishOnly": "npm run build",
27
+ "pretypecheck": "node scripts/generate-dsn.mjs && node scripts/build-flag-registry-worker.mjs",
27
28
  "typecheck": "tsc --noEmit",
28
29
  "gen:scan-golden": "node scripts/generate-scan-packages-table.mjs > src/utils/__fixtures__/scan-packages.generated-block.sh",
29
30
  "gen:command-catalog": "tsc && node scripts/generate-command-catalog.mjs",
30
31
  "lint": "eslint .",
32
+ "pretest": "node scripts/generate-dsn.mjs && node scripts/build-flag-registry-worker.mjs",
31
33
  "test": "vitest run",
32
34
  "test:db": "vitest run src/lib/db test/commands/db.test.ts test/commands/db-tenant-isolation.test.ts",
33
35
  "test:e2e": "vitest run --config vitest.e2e.config.ts",
@@ -66,6 +68,7 @@
66
68
  "@vitest/coverage-v8": "4.1.6",
67
69
  "aedes": "1.1.2",
68
70
  "aws-sdk-client-mock": "^4.1.0",
71
+ "esbuild": "0.27.0",
69
72
  "eslint": "^10.5.0",
70
73
  "husky": "^9.1.7",
71
74
  "lint-staged": "^15.5.2",