@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.
- package/CHANGELOG.md +25 -0
- package/dist/command-catalog.generated.d.ts +37 -0
- package/dist/command-catalog.generated.js +48 -0
- 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 +82 -57
- package/dist/lib/flag-registry-worker-mode-cache.d.ts +13 -0
- package/dist/lib/flag-registry-worker-mode-cache.js +146 -0
- package/dist/lib/flag-registry-worker-source.generated.d.ts +2 -0
- package/dist/lib/flag-registry-worker-source.generated.js +3 -0
- package/dist/lib/flag-registry.d.ts +4 -1
- package/dist/lib/flag-registry.js +100 -9
- package/dist/lib/lanes/questions.d.ts +9 -1
- package/dist/lib/lanes/questions.js +84 -5
- package/dist/lib/lanes/reconcile.js +2 -4
- 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/dist/lib/lanes/watcher.js +13 -7
- package/dist/sentry.js +41 -0
- package/package.json +5 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.221.0] — 2026-09-26
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `hq lanes report` prints the company-scoped owner report and `hq lanes team
|
|
10
|
+
<slug>` shows OKRs, KPI trends, work, blockers, and questions. Successful
|
|
11
|
+
reports save their per-company report time for the next report and team page.
|
|
12
|
+
PR, release, and deployment references on stories may be strings, integers,
|
|
13
|
+
or objects. Only timestamped objects count as activity. Invalid rows appear
|
|
14
|
+
under their team's errors, set exit status 2, and leave the saved report time
|
|
15
|
+
unchanged.
|
|
16
|
+
|
|
17
|
+
## [5.220.0] — 2026-09-26
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- Lane questions now resolve their company from the lane's project before using
|
|
22
|
+
the active HQ company. Project ownership is used only when unique; otherwise
|
|
23
|
+
active-company selection applies without choosing a project owner. Legacy
|
|
24
|
+
questions can be answered with `--company <slug>` when lane or project
|
|
25
|
+
ownership cannot be resolved.
|
|
26
|
+
|
|
27
|
+
- CLI updates no longer interrupt background flag refreshes when the
|
|
28
|
+
self-contained worker rollout is enabled.
|
|
29
|
+
|
|
5
30
|
## [5.219.0] — 2026-09-26
|
|
6
31
|
|
|
7
32
|
### Added
|
|
@@ -7257,6 +7257,43 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
7257
7257
|
readonly description: "JSON on stdout (always)";
|
|
7258
7258
|
}];
|
|
7259
7259
|
readonly subcommands: readonly [];
|
|
7260
|
+
}, {
|
|
7261
|
+
readonly name: "report";
|
|
7262
|
+
readonly description: "Generate the owner report for tracker teams";
|
|
7263
|
+
readonly aliases: readonly [];
|
|
7264
|
+
readonly hidden: false;
|
|
7265
|
+
readonly usage: "[options]";
|
|
7266
|
+
readonly arguments: readonly [];
|
|
7267
|
+
readonly options: readonly [{
|
|
7268
|
+
readonly flags: "--company <slug>";
|
|
7269
|
+
readonly description: "Select the company by slug";
|
|
7270
|
+
}, {
|
|
7271
|
+
readonly flags: "--since <iso>";
|
|
7272
|
+
readonly description: "Report activity from this ISO timestamp";
|
|
7273
|
+
}, {
|
|
7274
|
+
readonly flags: "--json";
|
|
7275
|
+
readonly description: "Emit JSON instead of the owner report text";
|
|
7276
|
+
}];
|
|
7277
|
+
readonly subcommands: readonly [];
|
|
7278
|
+
}, {
|
|
7279
|
+
readonly name: "team";
|
|
7280
|
+
readonly description: "Show a team's OKRs, KPIs, work, blockers, and questions";
|
|
7281
|
+
readonly aliases: readonly [];
|
|
7282
|
+
readonly hidden: false;
|
|
7283
|
+
readonly usage: "[options] <slug>";
|
|
7284
|
+
readonly arguments: readonly [{
|
|
7285
|
+
readonly name: "slug";
|
|
7286
|
+
readonly required: true;
|
|
7287
|
+
readonly variadic: false;
|
|
7288
|
+
}];
|
|
7289
|
+
readonly options: readonly [{
|
|
7290
|
+
readonly flags: "--company <slug>";
|
|
7291
|
+
readonly description: "Select the company by slug";
|
|
7292
|
+
}, {
|
|
7293
|
+
readonly flags: "--json";
|
|
7294
|
+
readonly description: "Emit JSON instead of the team page text";
|
|
7295
|
+
}];
|
|
7296
|
+
readonly subcommands: readonly [];
|
|
7260
7297
|
}, {
|
|
7261
7298
|
readonly name: "outpost-pruner";
|
|
7262
7299
|
readonly description: "Inspect, install, and run safe outpost worktree pruning";
|
|
@@ -9381,6 +9381,54 @@ export const COMMAND_CATALOG = [
|
|
|
9381
9381
|
],
|
|
9382
9382
|
"subcommands": []
|
|
9383
9383
|
},
|
|
9384
|
+
{
|
|
9385
|
+
"name": "report",
|
|
9386
|
+
"description": "Generate the owner report for tracker teams",
|
|
9387
|
+
"aliases": [],
|
|
9388
|
+
"hidden": false,
|
|
9389
|
+
"usage": "[options]",
|
|
9390
|
+
"arguments": [],
|
|
9391
|
+
"options": [
|
|
9392
|
+
{
|
|
9393
|
+
"flags": "--company <slug>",
|
|
9394
|
+
"description": "Select the company by slug"
|
|
9395
|
+
},
|
|
9396
|
+
{
|
|
9397
|
+
"flags": "--since <iso>",
|
|
9398
|
+
"description": "Report activity from this ISO timestamp"
|
|
9399
|
+
},
|
|
9400
|
+
{
|
|
9401
|
+
"flags": "--json",
|
|
9402
|
+
"description": "Emit JSON instead of the owner report text"
|
|
9403
|
+
}
|
|
9404
|
+
],
|
|
9405
|
+
"subcommands": []
|
|
9406
|
+
},
|
|
9407
|
+
{
|
|
9408
|
+
"name": "team",
|
|
9409
|
+
"description": "Show a team's OKRs, KPIs, work, blockers, and questions",
|
|
9410
|
+
"aliases": [],
|
|
9411
|
+
"hidden": false,
|
|
9412
|
+
"usage": "[options] <slug>",
|
|
9413
|
+
"arguments": [
|
|
9414
|
+
{
|
|
9415
|
+
"name": "slug",
|
|
9416
|
+
"required": true,
|
|
9417
|
+
"variadic": false
|
|
9418
|
+
}
|
|
9419
|
+
],
|
|
9420
|
+
"options": [
|
|
9421
|
+
{
|
|
9422
|
+
"flags": "--company <slug>",
|
|
9423
|
+
"description": "Select the company by slug"
|
|
9424
|
+
},
|
|
9425
|
+
{
|
|
9426
|
+
"flags": "--json",
|
|
9427
|
+
"description": "Emit JSON instead of the team page text"
|
|
9428
|
+
}
|
|
9429
|
+
],
|
|
9430
|
+
"subcommands": []
|
|
9431
|
+
},
|
|
9384
9432
|
{
|
|
9385
9433
|
"name": "outpost-pruner",
|
|
9386
9434
|
"description": "Inspect, install, and run safe outpost worktree pruning",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Command } from "commander";
|
|
2
|
-
import type
|
|
3
|
-
|
|
2
|
+
import { type TrackerKind, type TrackerRecord } from "../lib/lanes/tracker-store.js";
|
|
3
|
+
import type { LaneCompanyRef, QuestionRecord } from "../lib/lanes/types.js";
|
|
4
|
+
export interface LaneChecksDeps {
|
|
4
5
|
hqRoot: () => string;
|
|
5
6
|
resolveCompany: (company?: string) => Promise<LaneCompanyRef>;
|
|
6
7
|
trackerEnabled: (company: LaneCompanyRef) => boolean | Promise<boolean>;
|
|
@@ -8,6 +9,39 @@ interface LaneChecksDeps {
|
|
|
8
9
|
now: () => Date;
|
|
9
10
|
}
|
|
10
11
|
type WrapHandler = <Args extends unknown[]>(action: (...args: Args) => Promise<void>) => (...args: Args) => Promise<void>;
|
|
12
|
+
export interface ProjectMapping {
|
|
13
|
+
projectId: string;
|
|
14
|
+
team: string;
|
|
15
|
+
leadLaneId?: string;
|
|
16
|
+
statusPath: string;
|
|
17
|
+
}
|
|
18
|
+
export interface ProjectReadResult {
|
|
19
|
+
stories: Array<Record<string, unknown>>;
|
|
20
|
+
mtime: number;
|
|
21
|
+
errors?: Array<{
|
|
22
|
+
path: string;
|
|
23
|
+
field: string;
|
|
24
|
+
message: string;
|
|
25
|
+
story_id?: string;
|
|
26
|
+
}>;
|
|
27
|
+
}
|
|
28
|
+
export declare function loadProjectMappings(root: string, company: string, requestedProject?: string): Map<string, ProjectMapping>;
|
|
29
|
+
export declare function readProject(root: string, company: string, prdPath: string, tolerantStories?: boolean): ProjectReadResult;
|
|
30
|
+
export declare function loadCompanyQuestionRecords(root: string, company: LaneCompanyRef, projectId?: string): QuestionRecord[];
|
|
31
|
+
export declare function pathForTrackerRecord(projectDirectory: string, kind: TrackerKind, id: string): string;
|
|
32
|
+
export declare function listTrackerRecords(projectDirectory: string, kind: TrackerKind): TrackerRecord[];
|
|
33
|
+
export interface TolerantTrackerReadIssue {
|
|
34
|
+
path: string;
|
|
35
|
+
field: string;
|
|
36
|
+
message: string;
|
|
37
|
+
record_id?: string;
|
|
38
|
+
}
|
|
39
|
+
/** Report loader that preserves valid records while making every unreadable file visible. */
|
|
40
|
+
export declare function listTrackerRecordsTolerant(projectDirectory: string, kind: TrackerKind): {
|
|
41
|
+
records: TrackerRecord[];
|
|
42
|
+
errors: TolerantTrackerReadIssue[];
|
|
43
|
+
};
|
|
44
|
+
export declare function resolveTrackerCompany(deps: LaneChecksDeps, requested?: string): Promise<LaneCompanyRef>;
|
|
11
45
|
export declare function registerLaneChecksCommand(parent: Command, deps: LaneChecksDeps, wrap: WrapHandler): void;
|
|
12
46
|
export {};
|
|
13
47
|
//# sourceMappingURL=lanes-checks.d.ts.map
|
|
@@ -171,7 +171,7 @@ function projectHasTrackerRecords(root, company, projectId) {
|
|
|
171
171
|
}
|
|
172
172
|
return false;
|
|
173
173
|
}
|
|
174
|
-
function loadProjectMappings(root, company, requestedProject) {
|
|
174
|
+
export function loadProjectMappings(root, company, requestedProject) {
|
|
175
175
|
const registryPath = path.join(root, "workspace", "lane-briefs", "juniors.json");
|
|
176
176
|
const registry = readJsonObject(registryPath, "empty");
|
|
177
177
|
const mappings = new Map();
|
|
@@ -215,7 +215,7 @@ function loadProjectMappings(root, company, requestedProject) {
|
|
|
215
215
|
}
|
|
216
216
|
return mappings;
|
|
217
217
|
}
|
|
218
|
-
function readProject(root, company, prdPath) {
|
|
218
|
+
export function readProject(root, company, prdPath, tolerantStories = false) {
|
|
219
219
|
checkCompanyPath(root, company, path.relative(root, prdPath), prdPath);
|
|
220
220
|
const parsed = readJsonObject(prdPath);
|
|
221
221
|
if (parsed === null)
|
|
@@ -236,17 +236,26 @@ function readProject(root, company, prdPath) {
|
|
|
236
236
|
const rawStories = parsed.userStories === undefined ? parsed.stories : parsed.userStories;
|
|
237
237
|
if (!Array.isArray(rawStories))
|
|
238
238
|
return fail("tracker_checks_input_invalid", "Project task list must contain a userStories or stories array", { path: prdPath });
|
|
239
|
-
const
|
|
239
|
+
const errors = [];
|
|
240
|
+
const stories = rawStories.flatMap((story, index) => {
|
|
240
241
|
if (!isObject(story) || typeof story.id !== "string" || story.id.trim() === "") {
|
|
242
|
+
if (tolerantStories) {
|
|
243
|
+
errors.push({ path: prdPath, field: `userStories[${index}]`, message: "Project task list contains a story without an id" });
|
|
244
|
+
return [];
|
|
245
|
+
}
|
|
241
246
|
return fail("tracker_checks_input_invalid", "Project task list contains a story without an id", { path: prdPath, index });
|
|
242
247
|
}
|
|
243
248
|
if (story.prs !== undefined && !Array.isArray(story.prs)) {
|
|
249
|
+
if (tolerantStories) {
|
|
250
|
+
errors.push({ path: prdPath, field: "prs", message: "Project story prs must be an array", story_id: story.id });
|
|
251
|
+
return [{ ...story, prs: [] }];
|
|
252
|
+
}
|
|
244
253
|
return fail("tracker_checks_input_invalid", "Project story prs must be an array", { path: prdPath, story_id: story.id });
|
|
245
254
|
}
|
|
246
|
-
return story;
|
|
255
|
+
return [story];
|
|
247
256
|
});
|
|
248
257
|
try {
|
|
249
|
-
return { stories, mtime: fs.statSync(prdPath).mtimeMs };
|
|
258
|
+
return { stories, mtime: fs.statSync(prdPath).mtimeMs, ...(tolerantStories ? { errors } : {}) };
|
|
250
259
|
}
|
|
251
260
|
catch (err) {
|
|
252
261
|
return fail("tracker_checks_input_unreadable", `Project task list could not be stat'ed: ${err instanceof Error ? err.message : String(err)}`, { path: prdPath });
|
|
@@ -352,7 +361,7 @@ function questionMetadataMatchesScope(record, company, projectId) {
|
|
|
352
361
|
return false;
|
|
353
362
|
return true;
|
|
354
363
|
}
|
|
355
|
-
function
|
|
364
|
+
export function loadCompanyQuestionRecords(root, company, projectId) {
|
|
356
365
|
const records = listQuestionIds(root)
|
|
357
366
|
.flatMap((id) => {
|
|
358
367
|
const file = questionRecordPath(root, id);
|
|
@@ -365,6 +374,10 @@ function readCompanyQuestions(root, company, projectId) {
|
|
|
365
374
|
return question.company?.slug === company.slug && question.company.uid === company.uid &&
|
|
366
375
|
(projectId === undefined || question.project_id === projectId) ? [question] : [];
|
|
367
376
|
});
|
|
377
|
+
return records;
|
|
378
|
+
}
|
|
379
|
+
function readCompanyQuestions(root, company, projectId) {
|
|
380
|
+
const records = loadCompanyQuestionRecords(root, company, projectId);
|
|
368
381
|
const surfacedAt = questionSurfacingState(root, records.map((question) => ({
|
|
369
382
|
id: question.question_id,
|
|
370
383
|
status: question.status,
|
|
@@ -418,7 +431,7 @@ function readTeamInputs(root, company, projects, lanes) {
|
|
|
418
431
|
}
|
|
419
432
|
return inputs;
|
|
420
433
|
}
|
|
421
|
-
function pathForTrackerRecord(projectDirectory, kind, id) {
|
|
434
|
+
export function pathForTrackerRecord(projectDirectory, kind, id) {
|
|
422
435
|
return path.join(projectDirectory, "tracker", kind, `${id}.json`);
|
|
423
436
|
}
|
|
424
437
|
function recordInvalid(kind, record, projectDirectory, field) {
|
|
@@ -435,7 +448,7 @@ function requiredRecordDate(kind, record, projectDirectory, field) {
|
|
|
435
448
|
return recordInvalid(kind, record, projectDirectory, field);
|
|
436
449
|
return value;
|
|
437
450
|
}
|
|
438
|
-
function listTrackerRecords(projectDirectory, kind) {
|
|
451
|
+
export function listTrackerRecords(projectDirectory, kind) {
|
|
439
452
|
try {
|
|
440
453
|
return createLocalTrackerStore(projectDirectory).list(kind);
|
|
441
454
|
}
|
|
@@ -445,6 +458,51 @@ function listTrackerRecords(projectDirectory, kind) {
|
|
|
445
458
|
return fail("tracker_checks_store_unreadable", `Tracker ${kind} records could not be read: ${err instanceof Error ? err.message : String(err)}`, { path: path.join(projectDirectory, "tracker", kind) });
|
|
446
459
|
}
|
|
447
460
|
}
|
|
461
|
+
/** Report loader that preserves valid records while making every unreadable file visible. */
|
|
462
|
+
export function listTrackerRecordsTolerant(projectDirectory, kind) {
|
|
463
|
+
const root = path.join(projectDirectory, "tracker");
|
|
464
|
+
const directory = path.join(projectDirectory, "tracker", kind);
|
|
465
|
+
let names;
|
|
466
|
+
try {
|
|
467
|
+
const rootStat = fs.lstatSync(root);
|
|
468
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
|
|
469
|
+
return { records: [], errors: [{ path: root, field: "directory", message: "Tracker storage path must be a regular directory" }] };
|
|
470
|
+
}
|
|
471
|
+
const stat = fs.lstatSync(directory);
|
|
472
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
473
|
+
return { records: [], errors: [{ path: directory, field: "directory", message: "Tracker record path must be a regular directory" }] };
|
|
474
|
+
}
|
|
475
|
+
names = fs.readdirSync(directory).filter((name) => name.endsWith(".json")).sort();
|
|
476
|
+
}
|
|
477
|
+
catch (err) {
|
|
478
|
+
if (err.code === "ENOENT")
|
|
479
|
+
return { records: [], errors: [] };
|
|
480
|
+
return { records: [], errors: [{ path: directory, field: "directory", message: `Tracker records could not be listed: ${err instanceof Error ? err.message : String(err)}` }] };
|
|
481
|
+
}
|
|
482
|
+
const store = createLocalTrackerStore(projectDirectory);
|
|
483
|
+
const records = [];
|
|
484
|
+
const errors = [];
|
|
485
|
+
for (const name of names) {
|
|
486
|
+
const id = name.slice(0, -5);
|
|
487
|
+
const file = path.join(directory, name);
|
|
488
|
+
try {
|
|
489
|
+
const record = store.read(kind, id);
|
|
490
|
+
if (record !== null)
|
|
491
|
+
records.push(record);
|
|
492
|
+
}
|
|
493
|
+
catch (err) {
|
|
494
|
+
const details = isLanesError(err) ? err.details : {};
|
|
495
|
+
errors.push({
|
|
496
|
+
path: typeof details.path === "string" ? details.path : file,
|
|
497
|
+
field: typeof details.field === "string" ? details.field : "record",
|
|
498
|
+
message: err instanceof Error ? err.message : String(err),
|
|
499
|
+
record_id: typeof details.id === "string" ? details.id : id,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
records.sort((a, b) => b.updated_at.localeCompare(a.updated_at) || a.id.localeCompare(b.id));
|
|
504
|
+
return { records, errors };
|
|
505
|
+
}
|
|
448
506
|
function readTrackerCheckRecords(root, company, projects) {
|
|
449
507
|
const blockers = [];
|
|
450
508
|
const kpis = [];
|
|
@@ -499,7 +557,7 @@ function readTrackerCheckRecords(root, company, projects) {
|
|
|
499
557
|
}
|
|
500
558
|
return { blockers, kpis, directives };
|
|
501
559
|
}
|
|
502
|
-
async function resolveTrackerCompany(deps, requested) {
|
|
560
|
+
export async function resolveTrackerCompany(deps, requested) {
|
|
503
561
|
const company = await deps.resolveCompany(requested);
|
|
504
562
|
if (!company.slug)
|
|
505
563
|
fail("tracker_company_unresolved", "The active HQ company could not be resolved", {});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { type QuestionBody } from "../lib/lanes/questions.js";
|
|
3
|
+
import type { QuestionRecord } from "../lib/lanes/types.js";
|
|
4
|
+
import { type LaneChecksDeps } from "./lanes-checks.js";
|
|
5
|
+
type WrapHandler = <Args extends unknown[]>(action: (...args: Args) => Promise<void>) => (...args: Args) => Promise<void>;
|
|
6
|
+
export interface LaneReportsDeps extends LaneChecksDeps {
|
|
7
|
+
readQuestionBody: (root: string, question: QuestionRecord) => QuestionBody;
|
|
8
|
+
}
|
|
9
|
+
export declare function registerLaneReportCommands(parent: Command, deps: LaneReportsDeps, wrap: WrapHandler): void;
|
|
10
|
+
export {};
|
|
11
|
+
//# sourceMappingURL=lanes-report.d.ts.map
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { LanesError } from "../lib/lanes/errors.js";
|
|
3
|
+
import { assertQuestionBodySecretSafe } from "../lib/lanes/questions.js";
|
|
4
|
+
import { questionRecordPath } from "../lib/lanes/store.js";
|
|
5
|
+
import { normalizeIsoTimestamp } from "../lib/lanes/timestamp.js";
|
|
6
|
+
import { mapSourceStoryStatus } from "../lib/mesh/story-status.js";
|
|
7
|
+
import { buildTrackerOwnerReport, buildTrackerTeamPage, renderTrackerOwnerReportText, renderTrackerTeamPageText, } from "../lib/lanes/tracker-report.js";
|
|
8
|
+
import { FIRST_TRACKER_REPORT_SINCE, readTrackerReportState, writeTrackerReportState, } from "../lib/lanes/tracker-report-state.js";
|
|
9
|
+
import { listTrackerRecordsTolerant, loadCompanyQuestionRecords, loadProjectMappings, pathForTrackerRecord, readProject, resolveTrackerCompany, } from "./lanes-checks.js";
|
|
10
|
+
function fail(error, message, details = {}) {
|
|
11
|
+
throw new LanesError(error, message, details);
|
|
12
|
+
}
|
|
13
|
+
function canonicalStoryStatus(story, sourcePath, storyId) {
|
|
14
|
+
try {
|
|
15
|
+
const rawStatus = story.state ?? story.status;
|
|
16
|
+
if (rawStatus === "dropped")
|
|
17
|
+
return "dropped";
|
|
18
|
+
const status = mapSourceStoryStatus(story, `${sourcePath}#${storyId}`);
|
|
19
|
+
if (status !== undefined)
|
|
20
|
+
return status;
|
|
21
|
+
return story.passes === true ? "done" : "queued";
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
return fail("tracker_report_story_invalid", err instanceof Error ? err.message : String(err), { path: sourcePath, story_id: storyId, field: "state" });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function recordsArray(record, field, sourcePath, storyId, errors) {
|
|
28
|
+
const value = record[field];
|
|
29
|
+
if (value === undefined)
|
|
30
|
+
return [];
|
|
31
|
+
if (!Array.isArray(value)) {
|
|
32
|
+
errors.push({ path: sourcePath, field, story_id: storyId, message: `${field} must be an array` });
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
function timestampOrIssue(value, field, sourcePath, storyId, errors) {
|
|
38
|
+
const timestamp = normalizeIsoTimestamp(value);
|
|
39
|
+
if (timestamp === null) {
|
|
40
|
+
errors.push({ path: sourcePath, field, story_id: storyId, message: `${field} must be a valid timestamp` });
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
return timestamp;
|
|
44
|
+
}
|
|
45
|
+
function numericPr(value, url) {
|
|
46
|
+
if (typeof value === "string" && /^[1-9]\d*$/.test(value))
|
|
47
|
+
return value;
|
|
48
|
+
if (typeof value === "number" && Number.isSafeInteger(value) && value > 0)
|
|
49
|
+
return String(value);
|
|
50
|
+
return url.match(/\/pull\/(\d+)(?:\/|$)/)?.[1] ?? null;
|
|
51
|
+
}
|
|
52
|
+
function storyReferences(story, sourcePath, storyId, errors) {
|
|
53
|
+
const references = new Set();
|
|
54
|
+
const pullRequests = [];
|
|
55
|
+
const releases = [];
|
|
56
|
+
const addPullRequest = (value, field) => {
|
|
57
|
+
if (typeof value === "string" && value.trim()) {
|
|
58
|
+
references.add(value.trim());
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (typeof value === "number" && Number.isSafeInteger(value) && value > 0) {
|
|
62
|
+
references.add(`#${value}`);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
66
|
+
errors.push({ path: sourcePath, field, story_id: storyId, message: "Pull request entry must be a reference or object" });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const record = value;
|
|
70
|
+
const url = typeof record.url === "string" ? record.url : typeof record.html_url === "string" ? record.html_url : "";
|
|
71
|
+
const number = numericPr(record.number, url);
|
|
72
|
+
const reference = typeof record.ref === "string" ? record.ref : url || (number ? `#${number}` : "");
|
|
73
|
+
if (reference)
|
|
74
|
+
references.add(reference);
|
|
75
|
+
else
|
|
76
|
+
errors.push({ path: sourcePath, field, story_id: storyId, message: "Pull request object needs a number, URL, or ref" });
|
|
77
|
+
const mergedAt = record.merged_at ?? record.mergedAt ?? record.at;
|
|
78
|
+
if (mergedAt === undefined)
|
|
79
|
+
return;
|
|
80
|
+
if (!number) {
|
|
81
|
+
errors.push({ path: sourcePath, field, story_id: storyId, message: "Timed pull request object needs a number or pull URL" });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const at = timestampOrIssue(mergedAt, "merged_at", sourcePath, storyId, errors);
|
|
85
|
+
if (at)
|
|
86
|
+
pullRequests.push({ number, title: typeof record.title === "string" && record.title.trim() ? record.title : `Pull request #${number}`, merged_at: at, source_path: sourcePath });
|
|
87
|
+
};
|
|
88
|
+
const addRelease = (value, field) => {
|
|
89
|
+
if (typeof value === "string" && value.trim()) {
|
|
90
|
+
references.add(value.trim());
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (typeof value === "number" && Number.isSafeInteger(value)) {
|
|
94
|
+
references.add(String(value));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
98
|
+
errors.push({ path: sourcePath, field, story_id: storyId, message: "Release or deployment entry must be a reference or object" });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const record = value;
|
|
102
|
+
const version = typeof record.version === "string" ? record.version : typeof record.tag === "string" ? record.tag : "";
|
|
103
|
+
const reference = typeof record.ref === "string" ? record.ref : typeof record.url === "string" ? record.url : version;
|
|
104
|
+
if (reference)
|
|
105
|
+
references.add(reference);
|
|
106
|
+
else
|
|
107
|
+
errors.push({ path: sourcePath, field, story_id: storyId, message: "Release or deployment object needs version, tag, ref, or URL" });
|
|
108
|
+
const releasedAt = record.released_at ?? record.releasedAt ?? record.deployed_at ?? record.deployedAt ?? record.at;
|
|
109
|
+
if (releasedAt === undefined)
|
|
110
|
+
return;
|
|
111
|
+
if (!version) {
|
|
112
|
+
errors.push({ path: sourcePath, field, story_id: storyId, message: "Timed release or deployment object needs a version or tag" });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const at = timestampOrIssue(releasedAt, "released_at", sourcePath, storyId, errors);
|
|
116
|
+
if (at)
|
|
117
|
+
releases.push({ version, released_at: at, source_path: sourcePath });
|
|
118
|
+
};
|
|
119
|
+
for (const field of ["prs", "pull_requests"]) {
|
|
120
|
+
for (const value of recordsArray(story, field, sourcePath, storyId, errors))
|
|
121
|
+
addPullRequest(value, field);
|
|
122
|
+
}
|
|
123
|
+
for (const field of ["releases", "deploys"]) {
|
|
124
|
+
for (const value of recordsArray(story, field, sourcePath, storyId, errors))
|
|
125
|
+
addRelease(value, field);
|
|
126
|
+
}
|
|
127
|
+
return { references: [...references].sort(), pullRequests, releases };
|
|
128
|
+
}
|
|
129
|
+
function currentAudience(question, sourcePath) {
|
|
130
|
+
const latest = question.routing_history.at(-1);
|
|
131
|
+
if (!latest || typeof latest !== "object" || Array.isArray(latest)) {
|
|
132
|
+
return fail("tracker_report_question_route_invalid", "Question has no valid routing history for its current audience", { question_id: question.question_id, path: sourcePath });
|
|
133
|
+
}
|
|
134
|
+
const audience = latest.audience;
|
|
135
|
+
if (!audience || typeof audience !== "object" || Array.isArray(audience) || typeof audience.kind !== "string") {
|
|
136
|
+
return fail("tracker_report_question_route_invalid", "Question current audience is malformed", { question_id: question.question_id, path: sourcePath });
|
|
137
|
+
}
|
|
138
|
+
switch (audience.kind) {
|
|
139
|
+
case "user": return "owner";
|
|
140
|
+
case "session": return "operator";
|
|
141
|
+
case "lane": return "lane";
|
|
142
|
+
default: return fail("tracker_report_question_route_invalid", "Question current audience is unsupported", { question_id: question.question_id, path: sourcePath });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function trackerRecords(root, company, project, kind, errors) {
|
|
146
|
+
const projectDirectory = path.join(root, "companies", company.slug, "projects", project.projectId);
|
|
147
|
+
const loaded = listTrackerRecordsTolerant(projectDirectory, kind);
|
|
148
|
+
for (const issue of loaded.errors) {
|
|
149
|
+
errors.push({ path: issue.path, field: issue.field, message: issue.message, ...(issue.record_id ? { record_id: issue.record_id } : {}) });
|
|
150
|
+
}
|
|
151
|
+
return loaded.records
|
|
152
|
+
.filter((record) => record.company === company.slug && record.project_id === project.projectId)
|
|
153
|
+
.map((record) => ({ ...record, source_path: pathForTrackerRecord(projectDirectory, kind, record.id) }));
|
|
154
|
+
}
|
|
155
|
+
function issueFromError(err, fallbackPath, fallbackField, ids = {}) {
|
|
156
|
+
const details = err instanceof LanesError ? err.details : {};
|
|
157
|
+
return {
|
|
158
|
+
path: typeof details.path === "string" ? details.path : fallbackPath,
|
|
159
|
+
field: typeof details.field === "string" ? details.field : fallbackField,
|
|
160
|
+
message: err instanceof Error ? err.message : String(err),
|
|
161
|
+
...(typeof details.story_id === "string" ? { story_id: details.story_id } : ids.story_id ? { story_id: ids.story_id } : {}),
|
|
162
|
+
...(typeof details.id === "string" ? { record_id: details.id } : ids.record_id ? { record_id: ids.record_id } : {}),
|
|
163
|
+
...(typeof details.question_id === "string" ? { question_id: details.question_id } : ids.question_id ? { question_id: ids.question_id } : {}),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
function teamInput(root, company, project, questions, deps, errors = []) {
|
|
167
|
+
const projectDirectory = path.join(root, "companies", company.slug, "projects", project.projectId);
|
|
168
|
+
const prdPath = path.join(projectDirectory, "prd.json");
|
|
169
|
+
let prd = { stories: [], mtime: 0 };
|
|
170
|
+
try {
|
|
171
|
+
prd = readProject(root, company.slug, prdPath, true);
|
|
172
|
+
for (const issue of prd.errors ?? [])
|
|
173
|
+
errors.push(issue);
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
errors.push(issueFromError(err, prdPath, "project"));
|
|
177
|
+
}
|
|
178
|
+
const pullRequests = [];
|
|
179
|
+
const releases = [];
|
|
180
|
+
const stories = [];
|
|
181
|
+
for (const story of prd.stories) {
|
|
182
|
+
const id = typeof story.id === "string" ? story.id : "";
|
|
183
|
+
if (!id.trim()) {
|
|
184
|
+
errors.push({ path: prdPath, field: "id", message: "Story id must be a non-empty string" });
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const title = typeof story.title === "string" ? story.title : "";
|
|
188
|
+
if (!title.trim()) {
|
|
189
|
+
errors.push({ path: prdPath, field: "title", story_id: id, message: "Story title must be a non-empty string" });
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
let status;
|
|
193
|
+
try {
|
|
194
|
+
status = canonicalStoryStatus(story, prdPath, id);
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
errors.push(issueFromError(err, prdPath, "state", { story_id: id }));
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const updatedAt = story.updated_at ?? story.updatedAt;
|
|
201
|
+
if (status === "done" && updatedAt === undefined) {
|
|
202
|
+
errors.push({ path: prdPath, field: "updated_at", story_id: id, message: "Done story is missing updated_at" });
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
let normalizedUpdatedAt;
|
|
206
|
+
if (updatedAt !== undefined) {
|
|
207
|
+
const normalized = normalizeIsoTimestamp(updatedAt);
|
|
208
|
+
if (normalized === null) {
|
|
209
|
+
errors.push({ path: prdPath, field: "updated_at", story_id: id, message: "updated_at must be a valid timestamp" });
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
normalizedUpdatedAt = normalized;
|
|
213
|
+
}
|
|
214
|
+
const refs = storyReferences(story, prdPath, id, errors);
|
|
215
|
+
pullRequests.push(...refs.pullRequests);
|
|
216
|
+
releases.push(...refs.releases);
|
|
217
|
+
stories.push({ id, title, status, references: refs.references, ...(normalizedUpdatedAt ? { updated_at: normalizedUpdatedAt } : {}), source_path: prdPath });
|
|
218
|
+
}
|
|
219
|
+
const teamQuestions = [];
|
|
220
|
+
for (const record of questions.filter((question) => question.project_id === project.projectId)) {
|
|
221
|
+
const sourcePath = questionRecordPath(root, record.question_id);
|
|
222
|
+
try {
|
|
223
|
+
const body = deps.readQuestionBody(root, record);
|
|
224
|
+
assertQuestionBodySecretSafe(body);
|
|
225
|
+
teamQuestions.push({ record, body, current_audience: currentAudience(record, sourcePath), source_path: sourcePath });
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
errors.push(issueFromError(err, sourcePath, "body", { question_id: record.question_id }));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
company: company.slug,
|
|
233
|
+
team: project.team,
|
|
234
|
+
project_id: project.projectId,
|
|
235
|
+
stories,
|
|
236
|
+
pull_requests: pullRequests,
|
|
237
|
+
releases,
|
|
238
|
+
blockers: trackerRecords(root, company, project, "blocker", errors),
|
|
239
|
+
kpis: trackerRecords(root, company, project, "kpi", errors),
|
|
240
|
+
okrs: trackerRecords(root, company, project, "okr", errors),
|
|
241
|
+
directives: trackerRecords(root, company, project, "directive", errors),
|
|
242
|
+
questions: teamQuestions,
|
|
243
|
+
errors,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
function allTeamInputs(root, company, deps) {
|
|
247
|
+
const mappings = loadProjectMappings(root, company.slug);
|
|
248
|
+
return [...mappings.values()]
|
|
249
|
+
.sort((a, b) => a.team.localeCompare(b.team) || a.projectId.localeCompare(b.projectId))
|
|
250
|
+
.map((project) => {
|
|
251
|
+
const errors = [];
|
|
252
|
+
let questions = [];
|
|
253
|
+
try {
|
|
254
|
+
questions = loadCompanyQuestionRecords(root, company, project.projectId);
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
errors.push(issueFromError(err, path.join(root, "companies", company.slug, "projects", project.projectId), "questions"));
|
|
258
|
+
}
|
|
259
|
+
return teamInput(root, company, project, questions, deps, errors);
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
function sinceForTeam(root, company) {
|
|
263
|
+
return readTrackerReportState(root, company)?.last_report_at ?? FIRST_TRACKER_REPORT_SINCE;
|
|
264
|
+
}
|
|
265
|
+
function validateTeamSlug(team) {
|
|
266
|
+
const value = team.trim();
|
|
267
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(value) || value === "." || value === "..") {
|
|
268
|
+
return fail("tracker_team_invalid", "Team slug must be a safe path segment", { team });
|
|
269
|
+
}
|
|
270
|
+
return value;
|
|
271
|
+
}
|
|
272
|
+
export function registerLaneReportCommands(parent, deps, wrap) {
|
|
273
|
+
parent.command("report")
|
|
274
|
+
.description("Generate the owner report for tracker teams")
|
|
275
|
+
.option("--company <slug>", "Select the company by slug")
|
|
276
|
+
.option("--since <iso>", "Report activity from this ISO timestamp")
|
|
277
|
+
.option("--json", "Emit JSON instead of the owner report text")
|
|
278
|
+
.action(wrap(async (options) => {
|
|
279
|
+
const company = await resolveTrackerCompany(deps, options.company);
|
|
280
|
+
const root = deps.hqRoot();
|
|
281
|
+
const generatedAt = deps.now().toISOString();
|
|
282
|
+
const previous = options.since === undefined ? readTrackerReportState(root, company.slug) : null;
|
|
283
|
+
const since = options.since ?? previous?.last_report_at ?? FIRST_TRACKER_REPORT_SINCE;
|
|
284
|
+
const input = {
|
|
285
|
+
company: company.slug,
|
|
286
|
+
generated_at: generatedAt,
|
|
287
|
+
since,
|
|
288
|
+
since_source: options.since !== undefined ? "explicit" : previous ? "previous_report" : "first_run",
|
|
289
|
+
teams: allTeamInputs(root, company, deps),
|
|
290
|
+
};
|
|
291
|
+
const report = buildTrackerOwnerReport(input);
|
|
292
|
+
const hasErrors = report.teams.some((team) => team.errors.length > 0);
|
|
293
|
+
if (hasErrors)
|
|
294
|
+
process.exitCode = 2;
|
|
295
|
+
else
|
|
296
|
+
writeTrackerReportState(root, company.slug, generatedAt);
|
|
297
|
+
console.log(options.json ? JSON.stringify(report, null, 2) : renderTrackerOwnerReportText(report));
|
|
298
|
+
}));
|
|
299
|
+
parent.command("team")
|
|
300
|
+
.description("Show a team's OKRs, KPIs, work, blockers, and questions")
|
|
301
|
+
.argument("<slug>", "Team slug")
|
|
302
|
+
.option("--company <slug>", "Select the company by slug")
|
|
303
|
+
.option("--json", "Emit JSON instead of the team page text")
|
|
304
|
+
.action(wrap(async (slug, options) => {
|
|
305
|
+
const team = validateTeamSlug(slug);
|
|
306
|
+
const company = await resolveTrackerCompany(deps, options.company);
|
|
307
|
+
const root = deps.hqRoot();
|
|
308
|
+
const since = sinceForTeam(root, company.slug);
|
|
309
|
+
const candidates = [...loadProjectMappings(root, company.slug).values()].filter((project) => project.team === team);
|
|
310
|
+
if (candidates.length === 0)
|
|
311
|
+
fail("tracker_team_not_found", "Team was not found for this company", { team, company: company.slug });
|
|
312
|
+
if (candidates.length > 1)
|
|
313
|
+
fail("tracker_team_ambiguous", "More than one project maps to this team", { team, company: company.slug, project_ids: candidates.map((item) => item.projectId) });
|
|
314
|
+
const generatedAt = deps.now().toISOString();
|
|
315
|
+
const errors = [];
|
|
316
|
+
let questions = [];
|
|
317
|
+
try {
|
|
318
|
+
questions = loadCompanyQuestionRecords(root, company, candidates[0].projectId);
|
|
319
|
+
}
|
|
320
|
+
catch (err) {
|
|
321
|
+
errors.push(issueFromError(err, path.join(root, "companies", company.slug, "projects", candidates[0].projectId), "questions"));
|
|
322
|
+
}
|
|
323
|
+
const input = teamInput(root, company, candidates[0], questions, deps, errors);
|
|
324
|
+
const page = buildTrackerTeamPage(input, company.slug, since, generatedAt);
|
|
325
|
+
if (page.errors.length > 0)
|
|
326
|
+
process.exitCode = 2;
|
|
327
|
+
console.log(options.json ? JSON.stringify(page, null, 2) : renderTrackerTeamPageText(page));
|
|
328
|
+
}));
|
|
329
|
+
}
|
|
330
|
+
//# sourceMappingURL=lanes-report.js.map
|