@indigoai-us/hq-cli 5.217.0 → 5.219.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 +28 -0
- package/dist/command-catalog.generated.d.ts +18 -0
- package/dist/command-catalog.generated.js +23 -0
- package/dist/commands/lanes-checks.d.ts +13 -0
- package/dist/commands/lanes-checks.js +558 -0
- package/dist/commands/lanes.js +160 -44
- package/dist/lib/lanes/config.d.ts +2 -1
- package/dist/lib/lanes/config.js +30 -0
- package/dist/lib/lanes/questions.d.ts +13 -7
- package/dist/lib/lanes/questions.js +78 -18
- package/dist/lib/lanes/reconcile.d.ts +8 -1
- package/dist/lib/lanes/reconcile.js +51 -5
- package/dist/lib/lanes/spawn.js +28 -16
- package/dist/lib/lanes/store.js +15 -1
- package/dist/lib/lanes/tracker-checks.d.ts +77 -0
- package/dist/lib/lanes/tracker-checks.js +134 -0
- package/dist/lib/lanes/types.d.ts +15 -0
- package/dist/lib/lanes/watcher-main.js +17 -1
- package/dist/lib/lanes/watcher.d.ts +4 -1
- package/dist/lib/lanes/watcher.js +30 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.219.0] — 2026-09-26
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `hq lanes checks` reports six company-scoped tracker checks, prints the
|
|
10
|
+
records that need attention, and exits with status 2 when any check fails.
|
|
11
|
+
Input errors return a path-bearing error envelope with status 1. Teams come
|
|
12
|
+
from `juniors.json` mappings or projects with blocker, KPI, OKR, or directive
|
|
13
|
+
tracker records, so unrelated project PRDs are not read. Thresholds can be
|
|
14
|
+
adjusted in `personal/lanes/config.json`.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- `hq lanes` questions now record their company and `delivered_at` when a lane
|
|
19
|
+
receives an answer. Secret-shaped question text and answer notes are rejected
|
|
20
|
+
before storage. Existing questions without a company remain readable.
|
|
21
|
+
|
|
22
|
+
- Legacy personal-store answer notes continue to load from personal storage
|
|
23
|
+
after their lane gains mesh-company attribution.
|
|
24
|
+
|
|
25
|
+
## [5.218.0] — 2026-09-26
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- The reconcile summary now lists opted-in lanes it could not resume and why.
|
|
30
|
+
Stderr provides reason-specific guidance and recommends reapplying opt-in only
|
|
31
|
+
when that can help.
|
|
32
|
+
|
|
5
33
|
## [5.217.0] — 2026-09-26
|
|
6
34
|
|
|
7
35
|
### Fixed
|
|
@@ -7239,6 +7239,24 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
7239
7239
|
}];
|
|
7240
7240
|
readonly subcommands: readonly [];
|
|
7241
7241
|
}];
|
|
7242
|
+
}, {
|
|
7243
|
+
readonly name: "checks";
|
|
7244
|
+
readonly description: "Evaluate tracker and lane health checks";
|
|
7245
|
+
readonly aliases: readonly [];
|
|
7246
|
+
readonly hidden: false;
|
|
7247
|
+
readonly usage: "[options]";
|
|
7248
|
+
readonly arguments: readonly [];
|
|
7249
|
+
readonly options: readonly [{
|
|
7250
|
+
readonly flags: "--company <slug>";
|
|
7251
|
+
readonly description: "Select the company by slug";
|
|
7252
|
+
}, {
|
|
7253
|
+
readonly flags: "--project <slug>";
|
|
7254
|
+
readonly description: "Limit checks to one company project";
|
|
7255
|
+
}, {
|
|
7256
|
+
readonly flags: "--json";
|
|
7257
|
+
readonly description: "JSON on stdout (always)";
|
|
7258
|
+
}];
|
|
7259
|
+
readonly subcommands: readonly [];
|
|
7242
7260
|
}, {
|
|
7243
7261
|
readonly name: "outpost-pruner";
|
|
7244
7262
|
readonly description: "Inspect, install, and run safe outpost worktree pruning";
|
|
@@ -9358,6 +9358,29 @@ export const COMMAND_CATALOG = [
|
|
|
9358
9358
|
}
|
|
9359
9359
|
]
|
|
9360
9360
|
},
|
|
9361
|
+
{
|
|
9362
|
+
"name": "checks",
|
|
9363
|
+
"description": "Evaluate tracker and lane health checks",
|
|
9364
|
+
"aliases": [],
|
|
9365
|
+
"hidden": false,
|
|
9366
|
+
"usage": "[options]",
|
|
9367
|
+
"arguments": [],
|
|
9368
|
+
"options": [
|
|
9369
|
+
{
|
|
9370
|
+
"flags": "--company <slug>",
|
|
9371
|
+
"description": "Select the company by slug"
|
|
9372
|
+
},
|
|
9373
|
+
{
|
|
9374
|
+
"flags": "--project <slug>",
|
|
9375
|
+
"description": "Limit checks to one company project"
|
|
9376
|
+
},
|
|
9377
|
+
{
|
|
9378
|
+
"flags": "--json",
|
|
9379
|
+
"description": "JSON on stdout (always)"
|
|
9380
|
+
}
|
|
9381
|
+
],
|
|
9382
|
+
"subcommands": []
|
|
9383
|
+
},
|
|
9361
9384
|
{
|
|
9362
9385
|
"name": "outpost-pruner",
|
|
9363
9386
|
"description": "Inspect, install, and run safe outpost worktree pruning",
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import type { LaneCompanyRef } from "../lib/lanes/types.js";
|
|
3
|
+
interface LaneChecksDeps {
|
|
4
|
+
hqRoot: () => string;
|
|
5
|
+
resolveCompany: (company?: string) => Promise<LaneCompanyRef>;
|
|
6
|
+
trackerEnabled: (company: LaneCompanyRef) => boolean | Promise<boolean>;
|
|
7
|
+
trackerMeshEnabled: (company: LaneCompanyRef) => boolean | Promise<boolean>;
|
|
8
|
+
now: () => Date;
|
|
9
|
+
}
|
|
10
|
+
type WrapHandler = <Args extends unknown[]>(action: (...args: Args) => Promise<void>) => (...args: Args) => Promise<void>;
|
|
11
|
+
export declare function registerLaneChecksCommand(parent: Command, deps: LaneChecksDeps, wrap: WrapHandler): void;
|
|
12
|
+
export {};
|
|
13
|
+
//# sourceMappingURL=lanes-checks.d.ts.map
|
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { loadLanesConfig } from "../lib/lanes/config.js";
|
|
4
|
+
import { LanesError, isLanesError } from "../lib/lanes/errors.js";
|
|
5
|
+
import { evaluateTrackerChecks } from "../lib/lanes/tracker-checks.js";
|
|
6
|
+
import { laneRecordPath, listLaneIds, listQuestionIds, questionRecordPath, readLane, readQuestion } from "../lib/lanes/store.js";
|
|
7
|
+
import { createLocalTrackerStore } from "../lib/lanes/tracker-store.js";
|
|
8
|
+
import { LANES_TRACKER_FLAG, LANES_TRACKER_MESH_FLAG } from "./lanes-tracker.js";
|
|
9
|
+
function fail(error, message, details = {}) {
|
|
10
|
+
throw new LanesError(error, message, details);
|
|
11
|
+
}
|
|
12
|
+
function isObject(value) {
|
|
13
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
function validSegment(value, field) {
|
|
16
|
+
const normalized = value.trim();
|
|
17
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(normalized) || normalized === "." || normalized === "..") {
|
|
18
|
+
return fail("tracker_input_invalid", `${field} must be a safe path segment`, { field });
|
|
19
|
+
}
|
|
20
|
+
return normalized;
|
|
21
|
+
}
|
|
22
|
+
function parseTimestamp(value, field, file) {
|
|
23
|
+
if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) {
|
|
24
|
+
return fail("tracker_checks_input_invalid", `${field} must be a valid timestamp`, { path: file, field });
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
function readJsonObject(file, missing = "error") {
|
|
29
|
+
let raw;
|
|
30
|
+
try {
|
|
31
|
+
const stat = fs.lstatSync(file);
|
|
32
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
33
|
+
return fail("tracker_checks_input_invalid", "Checks input must be a regular file", { path: file });
|
|
34
|
+
}
|
|
35
|
+
raw = fs.readFileSync(file, "utf8");
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
if (err.code === "ENOENT" && missing === "empty")
|
|
39
|
+
return null;
|
|
40
|
+
if (isLanesError(err))
|
|
41
|
+
throw err;
|
|
42
|
+
return fail("tracker_checks_input_unreadable", `Checks input could not be read: ${err instanceof Error ? err.message : String(err)}`, { path: file });
|
|
43
|
+
}
|
|
44
|
+
let parsed;
|
|
45
|
+
try {
|
|
46
|
+
parsed = JSON.parse(raw);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return fail("tracker_checks_input_malformed", "Checks input contains invalid JSON", { path: file });
|
|
50
|
+
}
|
|
51
|
+
if (!isObject(parsed))
|
|
52
|
+
return fail("tracker_checks_input_invalid", "Checks input must be a JSON object", { path: file });
|
|
53
|
+
return parsed;
|
|
54
|
+
}
|
|
55
|
+
function isWithin(root, candidate) {
|
|
56
|
+
const relative = path.relative(root, candidate);
|
|
57
|
+
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|
|
58
|
+
}
|
|
59
|
+
function checkCompanyPath(root, company, file, detailsPath) {
|
|
60
|
+
if (path.isAbsolute(file))
|
|
61
|
+
return fail("tracker_checks_mapping_invalid", "Mapped path must be relative to the HQ root", { path: detailsPath });
|
|
62
|
+
const absolute = path.resolve(root, file);
|
|
63
|
+
const rootReal = fs.realpathSync(root);
|
|
64
|
+
if (!isWithin(rootReal, absolute))
|
|
65
|
+
return fail("tracker_checks_mapping_invalid", "Mapped path escapes the HQ root", { path: detailsPath });
|
|
66
|
+
const relative = path.relative(rootReal, absolute).split(path.sep);
|
|
67
|
+
if (relative[0] === "companies" && relative[1] !== company) {
|
|
68
|
+
return fail("tracker_checks_mapping_invalid", "Mapped path crosses the active company boundary", { path: detailsPath });
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const real = fs.realpathSync(absolute);
|
|
72
|
+
if (!isWithin(rootReal, real))
|
|
73
|
+
return fail("tracker_checks_mapping_invalid", "Mapped path resolves outside the HQ root", { path: detailsPath });
|
|
74
|
+
const realRelative = path.relative(rootReal, real).split(path.sep);
|
|
75
|
+
if (realRelative[0] === "companies" && realRelative[1] !== company) {
|
|
76
|
+
return fail("tracker_checks_mapping_invalid", "Mapped path resolves into another company", { path: detailsPath });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
if (err.code !== "ENOENT") {
|
|
81
|
+
return fail("tracker_checks_mapping_unreadable", `Mapped path could not be checked: ${err instanceof Error ? err.message : String(err)}`, { path: absolute });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return absolute;
|
|
85
|
+
}
|
|
86
|
+
function projectFromPrdReference(root, company, raw, registryPath) {
|
|
87
|
+
if (typeof raw !== "string" || raw.trim() === "") {
|
|
88
|
+
return fail("tracker_checks_mapping_invalid", "Team mapping needs a PRD path", { path: registryPath });
|
|
89
|
+
}
|
|
90
|
+
if (path.isAbsolute(raw))
|
|
91
|
+
return fail("tracker_checks_mapping_invalid", "Mapped path must be relative to the HQ root", { path: registryPath });
|
|
92
|
+
const candidate = path.resolve(root, raw);
|
|
93
|
+
const segments = path.relative(fs.realpathSync(root), candidate).split(path.sep);
|
|
94
|
+
if (segments[0] === "companies" && segments[1] !== company)
|
|
95
|
+
return null;
|
|
96
|
+
const absolute = checkCompanyPath(root, company, raw, registryPath);
|
|
97
|
+
const relative = path.relative(fs.realpathSync(root), absolute).split(path.sep);
|
|
98
|
+
if (relative[0] === "companies" && relative[1] === company && relative[2] === "projects" && relative.length === 5 && relative[4] === "prd.json") {
|
|
99
|
+
return validSegment(relative[3], "project");
|
|
100
|
+
}
|
|
101
|
+
// Older local lanes may point at a personal PRD. Use only its project slug
|
|
102
|
+
// to join against the active company's own project directory; never read it.
|
|
103
|
+
if (relative[0] === "personal" && relative[1] === "projects" && relative.length === 4 && relative[3] === "prd.json") {
|
|
104
|
+
return validSegment(relative[2], "project");
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
function projectDirectories(root, company) {
|
|
109
|
+
const projectsRoot = path.join(root, "companies", company, "projects");
|
|
110
|
+
let realProjectsRoot;
|
|
111
|
+
try {
|
|
112
|
+
realProjectsRoot = fs.realpathSync(projectsRoot);
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
if (err.code === "ENOENT")
|
|
116
|
+
return [];
|
|
117
|
+
return fail("tracker_checks_input_unreadable", `Company project directory could not be resolved: ${err instanceof Error ? err.message : String(err)}`, { path: projectsRoot });
|
|
118
|
+
}
|
|
119
|
+
const projectsRelative = path.relative(fs.realpathSync(root), realProjectsRoot).split(path.sep);
|
|
120
|
+
if (projectsRelative[0] !== "companies" || projectsRelative[1] !== company || projectsRelative[2] !== "projects") {
|
|
121
|
+
return fail("tracker_checks_mapping_invalid", "Company project directory resolves outside the active tenant", { path: projectsRoot });
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
return fs.readdirSync(projectsRoot, { withFileTypes: true })
|
|
125
|
+
.filter((entry) => entry.isDirectory())
|
|
126
|
+
.map((entry) => entry.name)
|
|
127
|
+
.filter((name) => /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(name));
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
if (err.code === "ENOENT")
|
|
131
|
+
return [];
|
|
132
|
+
return fail("tracker_checks_input_unreadable", `Company project directory could not be read: ${err instanceof Error ? err.message : String(err)}`, { path: projectsRoot });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function projectHasTrackerRecords(root, company, projectId) {
|
|
136
|
+
const projectPath = path.join(root, "companies", company, "projects", projectId);
|
|
137
|
+
const trackerRoot = path.join(projectPath, "tracker");
|
|
138
|
+
let trackerStat;
|
|
139
|
+
try {
|
|
140
|
+
trackerStat = fs.lstatSync(trackerRoot);
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
if (err.code === "ENOENT")
|
|
144
|
+
return false;
|
|
145
|
+
return fail("tracker_checks_input_unreadable", `Project tracker directory could not be inspected: ${err instanceof Error ? err.message : String(err)}`, { path: trackerRoot });
|
|
146
|
+
}
|
|
147
|
+
if (!trackerStat.isDirectory() || trackerStat.isSymbolicLink()) {
|
|
148
|
+
return fail("tracker_checks_input_invalid", "Project tracker path must be a regular directory", { path: trackerRoot });
|
|
149
|
+
}
|
|
150
|
+
for (const kind of ["blocker", "kpi", "okr", "directive"]) {
|
|
151
|
+
const directory = path.join(trackerRoot, kind);
|
|
152
|
+
let stat;
|
|
153
|
+
try {
|
|
154
|
+
stat = fs.lstatSync(directory);
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
if (err.code === "ENOENT")
|
|
158
|
+
continue;
|
|
159
|
+
return fail("tracker_checks_input_unreadable", `Project tracker records could not be inspected: ${err instanceof Error ? err.message : String(err)}`, { path: directory });
|
|
160
|
+
}
|
|
161
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
162
|
+
return fail("tracker_checks_input_invalid", "Project tracker record path must be a regular directory", { path: directory });
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
if (fs.readdirSync(directory).some((name) => name.endsWith(".json")))
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
return fail("tracker_checks_input_unreadable", `Project tracker records could not be listed: ${err instanceof Error ? err.message : String(err)}`, { path: directory });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
function loadProjectMappings(root, company, requestedProject) {
|
|
175
|
+
const registryPath = path.join(root, "workspace", "lane-briefs", "juniors.json");
|
|
176
|
+
const registry = readJsonObject(registryPath, "empty");
|
|
177
|
+
const mappings = new Map();
|
|
178
|
+
if (registry !== null) {
|
|
179
|
+
if (!Array.isArray(registry.juniors))
|
|
180
|
+
return fail("tracker_checks_mapping_invalid", "Team mapping must contain a juniors array", { path: registryPath });
|
|
181
|
+
for (const [index, entry] of registry.juniors.entries()) {
|
|
182
|
+
if (!isObject(entry))
|
|
183
|
+
return fail("tracker_checks_mapping_invalid", "Team mapping row must be an object", { path: registryPath, index });
|
|
184
|
+
const projectId = projectFromPrdReference(root, company, entry.prd, registryPath);
|
|
185
|
+
if (projectId === null)
|
|
186
|
+
continue;
|
|
187
|
+
if (typeof entry.team !== "string" || typeof entry.lane !== "string" || typeof entry.status !== "string") {
|
|
188
|
+
return fail("tracker_checks_mapping_invalid", "Active team mapping needs team, lane, and status paths", { path: registryPath, index });
|
|
189
|
+
}
|
|
190
|
+
const team = validSegment(entry.team, "team");
|
|
191
|
+
const leadLaneId = validSegment(entry.lane, "lead lane id");
|
|
192
|
+
const statusPath = checkCompanyPath(root, company, entry.status, registryPath);
|
|
193
|
+
if (mappings.has(projectId))
|
|
194
|
+
return fail("tracker_checks_mapping_ambiguous", "More than one active team maps to a project", { path: registryPath, project_id: projectId });
|
|
195
|
+
mappings.set(projectId, { projectId, team, leadLaneId, statusPath });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const projectsRoot = path.join(root, "companies", company, "projects");
|
|
199
|
+
const knownProjectIds = new Set(projectDirectories(root, company));
|
|
200
|
+
for (const projectId of knownProjectIds) {
|
|
201
|
+
if (projectHasTrackerRecords(root, company, projectId) && !mappings.has(projectId)) {
|
|
202
|
+
mappings.set(projectId, {
|
|
203
|
+
projectId,
|
|
204
|
+
team: projectId,
|
|
205
|
+
statusPath: path.join(projectsRoot, projectId, "manager-status.json"),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (requestedProject !== undefined) {
|
|
210
|
+
const projectId = validSegment(requestedProject, "project");
|
|
211
|
+
if (!knownProjectIds.has(projectId) && !mappings.has(projectId)) {
|
|
212
|
+
return fail("tracker_project_not_found", "Project directory was not found for this company", { project_id: projectId, path: path.join(projectsRoot, projectId) });
|
|
213
|
+
}
|
|
214
|
+
return new Map([...mappings].filter(([mappedProjectId]) => mappedProjectId === projectId));
|
|
215
|
+
}
|
|
216
|
+
return mappings;
|
|
217
|
+
}
|
|
218
|
+
function readProject(root, company, prdPath) {
|
|
219
|
+
checkCompanyPath(root, company, path.relative(root, prdPath), prdPath);
|
|
220
|
+
const parsed = readJsonObject(prdPath);
|
|
221
|
+
if (parsed === null)
|
|
222
|
+
return fail("tracker_checks_input_invalid", "Project task list must be a JSON object", { path: prdPath });
|
|
223
|
+
const metadata = isObject(parsed.metadata) ? parsed.metadata : undefined;
|
|
224
|
+
const rawCompany = parsed.company ?? metadata?.company;
|
|
225
|
+
const projectCompany = typeof rawCompany === "string"
|
|
226
|
+
? rawCompany
|
|
227
|
+
: isObject(rawCompany) && typeof rawCompany.slug === "string"
|
|
228
|
+
? rawCompany.slug
|
|
229
|
+
: null;
|
|
230
|
+
if (rawCompany !== undefined && rawCompany !== null && projectCompany === null) {
|
|
231
|
+
return fail("tracker_project_invalid", "Project company metadata must name a company slug", { path: prdPath });
|
|
232
|
+
}
|
|
233
|
+
if (projectCompany !== null && projectCompany !== company) {
|
|
234
|
+
return fail("tracker_company_mismatch", "Project company does not match the active HQ company", { active_company: company, project_company: projectCompany, path: prdPath });
|
|
235
|
+
}
|
|
236
|
+
const rawStories = parsed.userStories === undefined ? parsed.stories : parsed.userStories;
|
|
237
|
+
if (!Array.isArray(rawStories))
|
|
238
|
+
return fail("tracker_checks_input_invalid", "Project task list must contain a userStories or stories array", { path: prdPath });
|
|
239
|
+
const stories = rawStories.map((story, index) => {
|
|
240
|
+
if (!isObject(story) || typeof story.id !== "string" || story.id.trim() === "") {
|
|
241
|
+
return fail("tracker_checks_input_invalid", "Project task list contains a story without an id", { path: prdPath, index });
|
|
242
|
+
}
|
|
243
|
+
if (story.prs !== undefined && !Array.isArray(story.prs)) {
|
|
244
|
+
return fail("tracker_checks_input_invalid", "Project story prs must be an array", { path: prdPath, story_id: story.id });
|
|
245
|
+
}
|
|
246
|
+
return story;
|
|
247
|
+
});
|
|
248
|
+
try {
|
|
249
|
+
return { stories, mtime: fs.statSync(prdPath).mtimeMs };
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
return fail("tracker_checks_input_unreadable", `Project task list could not be stat'ed: ${err instanceof Error ? err.message : String(err)}`, { path: prdPath });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
function readStatusUpdatedAt(statusPath) {
|
|
256
|
+
const status = readJsonObject(statusPath, "empty");
|
|
257
|
+
if (status === null)
|
|
258
|
+
return null;
|
|
259
|
+
return parseTimestamp(status.updated_at, "status.updated_at", statusPath);
|
|
260
|
+
}
|
|
261
|
+
function storyIsDone(story) {
|
|
262
|
+
const state = typeof story.state === "string" ? story.state : story.status;
|
|
263
|
+
return story.passes === true || state === "done" || state === "dropped";
|
|
264
|
+
}
|
|
265
|
+
function pullRequestReferences(value, out = new Set(), seen = new Set()) {
|
|
266
|
+
if (typeof value === "string") {
|
|
267
|
+
const matches = value.match(/https?:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/[0-9]+(?:\/)?/g) ?? [];
|
|
268
|
+
for (const match of matches)
|
|
269
|
+
out.add(match.replace(/\/$/, ""));
|
|
270
|
+
}
|
|
271
|
+
else if (value !== null && typeof value === "object" && !seen.has(value)) {
|
|
272
|
+
seen.add(value);
|
|
273
|
+
if (Array.isArray(value)) {
|
|
274
|
+
for (const item of value)
|
|
275
|
+
pullRequestReferences(item, out, seen);
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
const object = value;
|
|
279
|
+
const state = object.state ?? object.status;
|
|
280
|
+
if (object.merged === true || object.closed === true || state === "merged" || state === "closed")
|
|
281
|
+
return [...out].sort();
|
|
282
|
+
for (const nested of Object.values(object))
|
|
283
|
+
pullRequestReferences(nested, out, seen);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return [...out].sort();
|
|
287
|
+
}
|
|
288
|
+
function laneMatchesProject(lane, projectId) {
|
|
289
|
+
return lane.project_id === projectId || (lane.stories ?? []).some((story) => story.project_id === projectId);
|
|
290
|
+
}
|
|
291
|
+
function laneMetadataMatchesScope(record, company, projectIds) {
|
|
292
|
+
const laneCompany = isObject(record.company) ? record.company : null;
|
|
293
|
+
if (laneCompany?.slug !== company.slug || laneCompany.uid !== company.uid)
|
|
294
|
+
return false;
|
|
295
|
+
if (typeof record.project_id === "string" && projectIds.has(record.project_id))
|
|
296
|
+
return true;
|
|
297
|
+
return Array.isArray(record.stories) && record.stories.some((story) => isObject(story) && typeof story.project_id === "string" && projectIds.has(story.project_id));
|
|
298
|
+
}
|
|
299
|
+
function readScopedLanes(root, company, projects) {
|
|
300
|
+
const projectIds = new Set(projects.map((project) => project.projectId));
|
|
301
|
+
if (projectIds.size === 0)
|
|
302
|
+
return [];
|
|
303
|
+
return listLaneIds(root).flatMap((id) => {
|
|
304
|
+
const file = laneRecordPath(root, id);
|
|
305
|
+
const metadata = readJsonObject(file);
|
|
306
|
+
if (metadata === null)
|
|
307
|
+
return fail("tracker_checks_input_unreadable", "Lane record could not be read while checking its scope", { path: file });
|
|
308
|
+
if (!laneMetadataMatchesScope(metadata, company, projectIds))
|
|
309
|
+
return [];
|
|
310
|
+
return [readLane(root, id)];
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
function sameCompanyLane(lane, company) {
|
|
314
|
+
return lane.company?.slug === company.slug && lane.company.uid === company.uid;
|
|
315
|
+
}
|
|
316
|
+
function questionSurfacingState(root, questions) {
|
|
317
|
+
const statePath = path.join(root, "workspace", "lane-briefs", "ops-board-state.json");
|
|
318
|
+
const state = readJsonObject(statePath, "empty");
|
|
319
|
+
const surfaced = new Map();
|
|
320
|
+
if (state === null)
|
|
321
|
+
return surfaced;
|
|
322
|
+
if (state.questions !== undefined && !isObject(state.questions)) {
|
|
323
|
+
return fail("tracker_checks_input_invalid", "Owner question state must contain a questions object", { path: statePath });
|
|
324
|
+
}
|
|
325
|
+
const questionState = isObject(state.questions) ? state.questions : {};
|
|
326
|
+
for (const question of questions) {
|
|
327
|
+
const entry = questionState[question.id];
|
|
328
|
+
if (entry === undefined) {
|
|
329
|
+
surfaced.set(question.id, null);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (!isObject(entry))
|
|
333
|
+
return fail("tracker_checks_input_invalid", "Owner question state must be an object", { path: statePath, question_id: question.id });
|
|
334
|
+
const at = entry.surfaced_at === undefined || entry.surfaced_at === null
|
|
335
|
+
? null
|
|
336
|
+
: parseTimestamp(entry.surfaced_at, "surfaced_at", statePath);
|
|
337
|
+
surfaced.set(question.id, at);
|
|
338
|
+
}
|
|
339
|
+
return surfaced;
|
|
340
|
+
}
|
|
341
|
+
function questionMetadataMatchesScope(record, company, projectId) {
|
|
342
|
+
if (record.company === null)
|
|
343
|
+
return false;
|
|
344
|
+
if (!isObject(record.company))
|
|
345
|
+
return true;
|
|
346
|
+
if (typeof record.company.slug === "string" && record.company.slug !== company.slug)
|
|
347
|
+
return false;
|
|
348
|
+
if (typeof record.company.uid === "string" && record.company.uid !== company.uid)
|
|
349
|
+
return false;
|
|
350
|
+
if (record.company.slug === company.slug && record.company.uid === company.uid && projectId !== undefined &&
|
|
351
|
+
typeof record.project_id === "string" && record.project_id !== projectId)
|
|
352
|
+
return false;
|
|
353
|
+
return true;
|
|
354
|
+
}
|
|
355
|
+
function readCompanyQuestions(root, company, projectId) {
|
|
356
|
+
const records = listQuestionIds(root)
|
|
357
|
+
.flatMap((id) => {
|
|
358
|
+
const file = questionRecordPath(root, id);
|
|
359
|
+
const metadata = readJsonObject(file);
|
|
360
|
+
if (metadata === null)
|
|
361
|
+
return fail("tracker_checks_input_unreadable", "Question record could not be read while checking its scope", { path: file });
|
|
362
|
+
if (!questionMetadataMatchesScope(metadata, company, projectId))
|
|
363
|
+
return [];
|
|
364
|
+
const question = readQuestion(root, id);
|
|
365
|
+
return question.company?.slug === company.slug && question.company.uid === company.uid &&
|
|
366
|
+
(projectId === undefined || question.project_id === projectId) ? [question] : [];
|
|
367
|
+
});
|
|
368
|
+
const surfacedAt = questionSurfacingState(root, records.map((question) => ({
|
|
369
|
+
id: question.question_id,
|
|
370
|
+
status: question.status,
|
|
371
|
+
asked_at: question.asked_at,
|
|
372
|
+
routing_history: question.routing_history.map((entry) => ({ at: entry.at, audience_kind: entry.audience.kind })),
|
|
373
|
+
surfaced_at: null,
|
|
374
|
+
})));
|
|
375
|
+
return records.map((question) => ({
|
|
376
|
+
id: question.question_id,
|
|
377
|
+
status: question.status,
|
|
378
|
+
asked_at: question.asked_at,
|
|
379
|
+
routing_history: question.routing_history.map((entry) => ({ at: entry.at, audience_kind: entry.audience.kind })),
|
|
380
|
+
surfaced_at: surfacedAt.get(question.question_id) ?? null,
|
|
381
|
+
}));
|
|
382
|
+
}
|
|
383
|
+
function readTeamInputs(root, company, projects, lanes) {
|
|
384
|
+
const inputs = [];
|
|
385
|
+
for (const project of projects) {
|
|
386
|
+
const projectPath = path.join(root, "companies", company.slug, "projects", project.projectId);
|
|
387
|
+
const prdPath = path.join(projectPath, "prd.json");
|
|
388
|
+
const { stories, mtime } = readProject(root, company.slug, prdPath);
|
|
389
|
+
const teamLanes = lanes.filter((lane) => sameCompanyLane(lane, company) && laneMatchesProject(lane, project.projectId));
|
|
390
|
+
const lead = project.leadLaneId === undefined
|
|
391
|
+
? undefined
|
|
392
|
+
: teamLanes.find((lane) => lane.lane_id === project.leadLaneId);
|
|
393
|
+
const openStories = stories.filter((story) => !storyIsDone(story));
|
|
394
|
+
const openPrRefs = new Set();
|
|
395
|
+
for (const story of openStories) {
|
|
396
|
+
for (const ref of pullRequestReferences(story.prs ?? []))
|
|
397
|
+
openPrRefs.add(ref);
|
|
398
|
+
}
|
|
399
|
+
for (const lane of teamLanes) {
|
|
400
|
+
for (const ref of pullRequestReferences(lane.last_envelope?.artifacts ?? []))
|
|
401
|
+
openPrRefs.add(ref);
|
|
402
|
+
}
|
|
403
|
+
const runningWorkers = teamLanes
|
|
404
|
+
.filter((lane) => lane.lane_id !== project.leadLaneId && lane.senior.kind === "lane" && lane.state === "running")
|
|
405
|
+
.map((lane) => lane.lane_id);
|
|
406
|
+
inputs.push({
|
|
407
|
+
team: project.team,
|
|
408
|
+
project_id: project.projectId,
|
|
409
|
+
...(lead ? { lead_lane: { id: lead.lane_id, state: lead.state } } : {}),
|
|
410
|
+
open_story_ids: openStories.map((story) => String(story.id)),
|
|
411
|
+
open_pr_refs: [...openPrRefs].sort(),
|
|
412
|
+
running_worker_ids: runningWorkers,
|
|
413
|
+
status_path: project.statusPath,
|
|
414
|
+
status_updated_at: readStatusUpdatedAt(project.statusPath),
|
|
415
|
+
task_list_path: prdPath,
|
|
416
|
+
task_list_updated_at: new Date(mtime).toISOString(),
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
return inputs;
|
|
420
|
+
}
|
|
421
|
+
function pathForTrackerRecord(projectDirectory, kind, id) {
|
|
422
|
+
return path.join(projectDirectory, "tracker", kind, `${id}.json`);
|
|
423
|
+
}
|
|
424
|
+
function recordInvalid(kind, record, projectDirectory, field) {
|
|
425
|
+
return fail("tracker_checks_record_invalid", `Tracker ${kind} record has invalid ${field}`, {
|
|
426
|
+
kind,
|
|
427
|
+
id: record.id,
|
|
428
|
+
field,
|
|
429
|
+
path: pathForTrackerRecord(projectDirectory, kind, record.id),
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
function requiredRecordDate(kind, record, projectDirectory, field) {
|
|
433
|
+
const value = record[field];
|
|
434
|
+
if (typeof value !== "string" || !Number.isFinite(Date.parse(value)))
|
|
435
|
+
return recordInvalid(kind, record, projectDirectory, field);
|
|
436
|
+
return value;
|
|
437
|
+
}
|
|
438
|
+
function listTrackerRecords(projectDirectory, kind) {
|
|
439
|
+
try {
|
|
440
|
+
return createLocalTrackerStore(projectDirectory).list(kind);
|
|
441
|
+
}
|
|
442
|
+
catch (err) {
|
|
443
|
+
if (isLanesError(err) && typeof err.details.path === "string")
|
|
444
|
+
throw err;
|
|
445
|
+
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
|
+
}
|
|
447
|
+
}
|
|
448
|
+
function readTrackerCheckRecords(root, company, projects) {
|
|
449
|
+
const blockers = [];
|
|
450
|
+
const kpis = [];
|
|
451
|
+
const directives = [];
|
|
452
|
+
for (const project of projects) {
|
|
453
|
+
const absoluteDirectory = path.join(root, "companies", company.slug, "projects", project.projectId);
|
|
454
|
+
const records = {
|
|
455
|
+
blocker: listTrackerRecords(absoluteDirectory, "blocker"),
|
|
456
|
+
kpi: listTrackerRecords(absoluteDirectory, "kpi"),
|
|
457
|
+
directive: listTrackerRecords(absoluteDirectory, "directive"),
|
|
458
|
+
};
|
|
459
|
+
for (const record of records.blocker) {
|
|
460
|
+
if (record.company !== company.slug || record.project_id !== project.projectId)
|
|
461
|
+
continue;
|
|
462
|
+
const openedAt = requiredRecordDate("blocker", record, absoluteDirectory, "opened_at");
|
|
463
|
+
const resolvedAt = record.resolved_at;
|
|
464
|
+
if (resolvedAt !== null && (typeof resolvedAt !== "string" || !Number.isFinite(Date.parse(resolvedAt))))
|
|
465
|
+
recordInvalid("blocker", record, absoluteDirectory, "resolved_at");
|
|
466
|
+
const nextStep = record.next_step;
|
|
467
|
+
if (nextStep !== undefined && nextStep !== null && typeof nextStep !== "string")
|
|
468
|
+
recordInvalid("blocker", record, absoluteDirectory, "next_step");
|
|
469
|
+
blockers.push({ id: record.id, opened_at: openedAt, resolved_at: resolvedAt, next_step: typeof nextStep === "string" ? nextStep : null });
|
|
470
|
+
}
|
|
471
|
+
for (const record of records.kpi) {
|
|
472
|
+
if (record.company !== company.slug || record.project_id !== project.projectId)
|
|
473
|
+
continue;
|
|
474
|
+
const createdAt = requiredRecordDate("kpi", record, absoluteDirectory, "created_at");
|
|
475
|
+
if (!Array.isArray(record.values))
|
|
476
|
+
recordInvalid("kpi", record, absoluteDirectory, "values");
|
|
477
|
+
const values = record.values.map((value) => {
|
|
478
|
+
if (!isObject(value))
|
|
479
|
+
recordInvalid("kpi", record, absoluteDirectory, "values");
|
|
480
|
+
return { at: parseTimestamp(value.at, "values.at", pathForTrackerRecord(absoluteDirectory, "kpi", record.id)) };
|
|
481
|
+
});
|
|
482
|
+
kpis.push({ id: record.id, created_at: createdAt, values });
|
|
483
|
+
}
|
|
484
|
+
for (const record of records.directive) {
|
|
485
|
+
if (record.company !== company.slug || record.project_id !== project.projectId)
|
|
486
|
+
continue;
|
|
487
|
+
const createdAt = requiredRecordDate("directive", record, absoluteDirectory, "created_at");
|
|
488
|
+
if (!Array.isArray(record.teams) || record.teams.some((team) => typeof team !== "string"))
|
|
489
|
+
recordInvalid("directive", record, absoluteDirectory, "teams");
|
|
490
|
+
if (!Array.isArray(record.acknowledgements))
|
|
491
|
+
recordInvalid("directive", record, absoluteDirectory, "acknowledgements");
|
|
492
|
+
const acknowledgements = record.acknowledgements.map((ack) => {
|
|
493
|
+
if (!isObject(ack) || typeof ack.team !== "string" || typeof ack.lane_id !== "string")
|
|
494
|
+
recordInvalid("directive", record, absoluteDirectory, "acknowledgements");
|
|
495
|
+
return { team: ack.team, lane_id: ack.lane_id, at: parseTimestamp(ack.at, "acknowledgements.at", pathForTrackerRecord(absoluteDirectory, "directive", record.id)) };
|
|
496
|
+
});
|
|
497
|
+
directives.push({ id: record.id, created_at: createdAt, teams: record.teams, acknowledgements });
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return { blockers, kpis, directives };
|
|
501
|
+
}
|
|
502
|
+
async function resolveTrackerCompany(deps, requested) {
|
|
503
|
+
const company = await deps.resolveCompany(requested);
|
|
504
|
+
if (!company.slug)
|
|
505
|
+
fail("tracker_company_unresolved", "The active HQ company could not be resolved", {});
|
|
506
|
+
if (!await deps.trackerEnabled(company)) {
|
|
507
|
+
fail("tracker_disabled", `hq lanes tracker commands require the ${LANES_TRACKER_FLAG} flag`, { company: company.slug });
|
|
508
|
+
}
|
|
509
|
+
if (await deps.trackerMeshEnabled(company)) {
|
|
510
|
+
fail("tracker_mesh_unavailable", `Work Mesh tracker storage is not available while ${LANES_TRACKER_MESH_FLAG} is enabled`, { company: company.slug });
|
|
511
|
+
}
|
|
512
|
+
return company;
|
|
513
|
+
}
|
|
514
|
+
function checkReport(root, company, projects, directiveProjects, projectFilter, now) {
|
|
515
|
+
const selectedProjects = [...projects.values()];
|
|
516
|
+
const laneRecords = readScopedLanes(root, company, selectedProjects);
|
|
517
|
+
const teams = readTeamInputs(root, company, selectedProjects, laneRecords);
|
|
518
|
+
const questionRecords = readCompanyQuestions(root, company, projectFilter);
|
|
519
|
+
const trackerRecords = readTrackerCheckRecords(root, company, selectedProjects);
|
|
520
|
+
const directiveTeams = [...directiveProjects.values()].map((project) => ({
|
|
521
|
+
team: project.team,
|
|
522
|
+
...(project.leadLaneId === undefined ? {} : { lead_lane_id: project.leadLaneId }),
|
|
523
|
+
}));
|
|
524
|
+
return {
|
|
525
|
+
ok: true,
|
|
526
|
+
company: company.slug,
|
|
527
|
+
generated_at: now.toISOString(),
|
|
528
|
+
checks: evaluateTrackerChecks({
|
|
529
|
+
now,
|
|
530
|
+
thresholds: loadLanesConfig(root).tracker.checks,
|
|
531
|
+
questions: questionRecords,
|
|
532
|
+
teams,
|
|
533
|
+
...trackerRecords,
|
|
534
|
+
directives: trackerRecords.directives,
|
|
535
|
+
directive_teams: directiveTeams,
|
|
536
|
+
}),
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
export function registerLaneChecksCommand(parent, deps, wrap) {
|
|
540
|
+
parent.command("checks")
|
|
541
|
+
.description("Evaluate tracker and lane health checks")
|
|
542
|
+
.option("--company <slug>", "Select the company by slug")
|
|
543
|
+
.option("--project <slug>", "Limit checks to one company project")
|
|
544
|
+
.action(wrap(async (options) => {
|
|
545
|
+
const company = await resolveTrackerCompany(deps, options.company);
|
|
546
|
+
const root = deps.hqRoot();
|
|
547
|
+
const projectFilter = options.project === undefined ? undefined : validSegment(options.project, "project");
|
|
548
|
+
const projects = loadProjectMappings(root, company.slug, projectFilter);
|
|
549
|
+
const directiveProjects = projectFilter === undefined
|
|
550
|
+
? projects
|
|
551
|
+
: loadProjectMappings(root, company.slug);
|
|
552
|
+
const report = checkReport(root, company, projects, directiveProjects, projectFilter, deps.now());
|
|
553
|
+
console.log(JSON.stringify(report, null, 2));
|
|
554
|
+
if (report.checks.some((item) => item.result === "failing"))
|
|
555
|
+
process.exitCode = 2;
|
|
556
|
+
}));
|
|
557
|
+
}
|
|
558
|
+
//# sourceMappingURL=lanes-checks.js.map
|