@indigoai-us/hq-cli 5.220.0 → 5.222.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.
- package/CHANGELOG.md +24 -0
- package/dist/command-catalog.generated.d.ts +55 -0
- package/dist/command-catalog.generated.js +71 -0
- package/dist/commands/core-ontology.d.ts +6 -0
- package/dist/commands/core-ontology.js +61 -5
- package/dist/commands/lanes-checks.d.ts +36 -2
- package/dist/commands/lanes-checks.js +67 -9
- package/dist/commands/lanes-report.d.ts +11 -0
- package/dist/commands/lanes-report.js +330 -0
- package/dist/commands/lanes.d.ts +2 -1
- package/dist/commands/lanes.js +24 -6
- package/dist/lib/checkpoint-ontology/aliases.d.ts +17 -0
- package/dist/lib/checkpoint-ontology/aliases.js +44 -0
- package/dist/lib/checkpoint-ontology/apply.d.ts +10 -0
- package/dist/lib/checkpoint-ontology/apply.js +61 -44
- package/dist/lib/checkpoint-ontology/entity-file.d.ts +2 -0
- package/dist/lib/checkpoint-ontology/entity-file.js +2 -1
- package/dist/lib/checkpoint-ontology/multi-writer.d.ts +106 -0
- package/dist/lib/checkpoint-ontology/multi-writer.js +391 -0
- package/dist/lib/checkpoint-ontology/plan.d.ts +10 -0
- package/dist/lib/checkpoint-ontology/plan.js +23 -5
- package/dist/lib/checkpoint-ontology/tree.d.ts +2 -0
- package/dist/lib/checkpoint-ontology/tree.js +2 -1
- package/dist/lib/lanes/timestamp.d.ts +3 -0
- package/dist/lib/lanes/timestamp.js +26 -0
- package/dist/lib/lanes/tracker-report-state.d.ts +10 -0
- package/dist/lib/lanes/tracker-report-state.js +124 -0
- package/dist/lib/lanes/tracker-report.d.ts +203 -0
- package/dist/lib/lanes/tracker-report.js +522 -0
- package/package.json +1 -1
|
@@ -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
|