@indigoai-us/hq-cli 5.345.43 → 5.345.45
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 +18 -0
- package/dist/command-catalog.generated.d.ts +17 -1
- package/dist/command-catalog.generated.js +21 -1
- package/dist/commands/access.js +6 -4
- package/dist/commands/bot.js +1 -1
- package/dist/commands/db-destroy.js +5 -4
- package/dist/commands/db-remote-deps.js +4 -0
- package/dist/commands/lanes-checks.d.ts +1 -0
- package/dist/commands/lanes-checks.js +6 -1
- package/dist/commands/lanes-message.js +84 -51
- package/dist/commands/lanes-report.d.ts +2 -1
- package/dist/commands/lanes-report.js +63 -6
- package/dist/commands/lanes-resume.js +44 -0
- package/dist/commands/lanes-shared.d.ts +25 -0
- package/dist/commands/lanes.d.ts +4 -0
- package/dist/commands/lanes.js +228 -9
- package/dist/commands/meetings.js +4 -1
- package/dist/index.js +43 -7
- package/dist/lib/core-utils/sentry-report.js +8 -1
- package/dist/lib/db/control-plane.d.ts +5 -0
- package/dist/lib/db/control-plane.js +7 -0
- package/dist/lib/flag-snapshot-file.js +33 -25
- package/dist/lib/lanes/flag-keys.d.ts +4 -0
- package/dist/lib/lanes/flag-keys.js +4 -0
- package/dist/lib/lanes/focus-guard.d.ts +65 -0
- package/dist/lib/lanes/focus-guard.js +91 -0
- package/dist/lib/lanes/grants.js +1 -1
- package/dist/lib/lanes/outpost-dispatch.d.ts +1 -0
- package/dist/lib/lanes/outpost-dispatch.js +1 -0
- package/dist/lib/lanes/reconcile.d.ts +2 -0
- package/dist/lib/lanes/reconcile.js +39 -1
- package/dist/lib/lanes/spawn.d.ts +4 -0
- package/dist/lib/lanes/spawn.js +132 -6
- package/dist/lib/lanes/store.js +64 -0
- package/dist/lib/lanes/story-ledger.d.ts +14 -0
- package/dist/lib/lanes/story-ledger.js +46 -0
- package/dist/lib/lanes/tracker-report.d.ts +46 -0
- package/dist/lib/lanes/tracker-report.js +85 -1
- package/dist/lib/lanes/types.d.ts +12 -2
- package/dist/lib/lanes/types.js +5 -3
- package/dist/main.js +5 -1
- package/dist/startup-registration.d.ts +3 -0
- package/dist/startup-registration.js +11 -4
- package/dist/utils/package-use-lease.d.ts +1 -0
- package/dist/utils/package-use-lease.js +29 -0
- package/dist/utils/self-update-backoff.d.ts +17 -0
- package/dist/utils/self-update-backoff.js +73 -0
- package/dist/utils/self-update.d.ts +7 -0
- package/dist/utils/self-update.js +36 -0
- package/dist/utils/vault-api.d.ts +33 -1
- package/dist/utils/vault-api.js +55 -5
- package/dist/utils/version-gate.d.ts +13 -0
- package/dist/utils/version-gate.js +35 -5
- package/package.json +1 -1
package/dist/lib/lanes/store.js
CHANGED
|
@@ -392,6 +392,10 @@ export function parseLaneRecord(raw, filePath) {
|
|
|
392
392
|
path: filePath,
|
|
393
393
|
});
|
|
394
394
|
}
|
|
395
|
+
// Company attribution was added after lane records already existed on disk.
|
|
396
|
+
// Treat its historical absence as the same unbound state as an explicit null.
|
|
397
|
+
if (raw.company === undefined)
|
|
398
|
+
raw.company = null;
|
|
395
399
|
for (const field of REQUIRED_LANE_RECORD_FIELDS) {
|
|
396
400
|
if (!(field in raw)) {
|
|
397
401
|
throw new LaneSchemaError(`Missing required lane record field ${field}`, { path: filePath, field });
|
|
@@ -423,6 +427,31 @@ export function parseLaneRecord(raw, filePath) {
|
|
|
423
427
|
requireInt(raw.version, "version");
|
|
424
428
|
requireInt(raw.depth, "depth");
|
|
425
429
|
requireInt(raw.priority, "priority");
|
|
430
|
+
if (raw.focus_overrides !== undefined) {
|
|
431
|
+
if (!Array.isArray(raw.focus_overrides) || raw.focus_overrides.length > 100) {
|
|
432
|
+
throw new LaneSchemaError("focus_overrides must be an array with at most 100 entries", {
|
|
433
|
+
path: filePath,
|
|
434
|
+
field: "focus_overrides",
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
for (const [index, entry] of raw.focus_overrides.entries()) {
|
|
438
|
+
if (!isPlainObject(entry)) {
|
|
439
|
+
throw new LaneSchemaError("focus_overrides entries must be objects", {
|
|
440
|
+
path: filePath,
|
|
441
|
+
field: `focus_overrides[${index}]`,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
for (const field of ["reason", "by_lane", "story"]) {
|
|
445
|
+
if (typeof entry[field] !== "string" || !entry[field].trim()) {
|
|
446
|
+
throw new LaneSchemaError(`focus_overrides[${index}].${field} must be non-empty`, {
|
|
447
|
+
path: filePath,
|
|
448
|
+
field: `focus_overrides[${index}].${field}`,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
requireTimestamp(entry.at, `focus_overrides[${index}].at`, filePath);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
426
455
|
if (raw.ended_at !== undefined) {
|
|
427
456
|
requireTimestamp(raw.ended_at, "ended_at", filePath);
|
|
428
457
|
}
|
|
@@ -442,6 +471,16 @@ export function parseLaneRecord(raw, filePath) {
|
|
|
442
471
|
value: raw.hire_grant,
|
|
443
472
|
});
|
|
444
473
|
}
|
|
474
|
+
if (raw.released_hire_grant !== undefined &&
|
|
475
|
+
(typeof raw.released_hire_grant !== "number" ||
|
|
476
|
+
!Number.isSafeInteger(raw.released_hire_grant) ||
|
|
477
|
+
raw.released_hire_grant < 0)) {
|
|
478
|
+
throw new LaneSchemaError("Expected non-negative safe integer for released_hire_grant", {
|
|
479
|
+
path: filePath,
|
|
480
|
+
field: "released_hire_grant",
|
|
481
|
+
value: raw.released_hire_grant,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
445
484
|
if (raw.keep_alive !== undefined && typeof raw.keep_alive !== "boolean") {
|
|
446
485
|
throw new LaneSchemaError("keep_alive must be a boolean", {
|
|
447
486
|
path: filePath,
|
|
@@ -1603,6 +1642,31 @@ export function updateLane(hqRoot, laneId, expectedVersion, writer, updater, dep
|
|
|
1603
1642
|
throw new LanesError("lane_writer_unauthorized", `Writer ${writer} cannot write lane field ${key}`, { writer, field: key });
|
|
1604
1643
|
}
|
|
1605
1644
|
}
|
|
1645
|
+
if (!isTerminalLaneState(current.state) && isTerminalLaneState(updated.state)) {
|
|
1646
|
+
const releasedGrant = updated.hire_grant ?? current.hire_grant ?? 0;
|
|
1647
|
+
return {
|
|
1648
|
+
...updated,
|
|
1649
|
+
released_hire_grant: releasedGrant,
|
|
1650
|
+
hire_grant: 0,
|
|
1651
|
+
};
|
|
1652
|
+
}
|
|
1653
|
+
if (isTerminalLaneState(current.state) && !isTerminalLaneState(updated.state) &&
|
|
1654
|
+
current.released_hire_grant !== undefined) {
|
|
1655
|
+
return {
|
|
1656
|
+
...updated,
|
|
1657
|
+
hire_grant: current.released_hire_grant,
|
|
1658
|
+
released_hire_grant: undefined,
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
1661
|
+
if (isTerminalLaneState(updated.state) &&
|
|
1662
|
+
(updated.hire_grant !== current.hire_grant ||
|
|
1663
|
+
updated.released_hire_grant !== current.released_hire_grant)) {
|
|
1664
|
+
return {
|
|
1665
|
+
...updated,
|
|
1666
|
+
hire_grant: 0,
|
|
1667
|
+
released_hire_grant: updated.released_hire_grant ?? updated.hire_grant ?? 0,
|
|
1668
|
+
};
|
|
1669
|
+
}
|
|
1606
1670
|
return updated;
|
|
1607
1671
|
}, deps, {
|
|
1608
1672
|
beforeWrite: (current) => assertLaneStoreWriteAllowed(current, laneId, writer),
|
|
@@ -24,6 +24,20 @@ export interface StoryLedgerQuestionRef {
|
|
|
24
24
|
project_id: string;
|
|
25
25
|
story_id: string;
|
|
26
26
|
}
|
|
27
|
+
export interface ProjectFocusOverride {
|
|
28
|
+
reason: string;
|
|
29
|
+
by_lane: string;
|
|
30
|
+
at: string;
|
|
31
|
+
story: string;
|
|
32
|
+
}
|
|
33
|
+
export declare function readProjectStoryFocusData(hqRoot: string, projectId: string, companySlug?: string): {
|
|
34
|
+
path: string;
|
|
35
|
+
metadata: Record<string, unknown>;
|
|
36
|
+
userStories: Array<Record<string, unknown>>;
|
|
37
|
+
focusOverrides: ProjectFocusOverride[];
|
|
38
|
+
};
|
|
39
|
+
export declare function appendProjectFocusOverride(hqRoot: string, projectId: string, companySlug: string | undefined, override: ProjectFocusOverride): void;
|
|
40
|
+
export declare function removeProjectFocusOverride(hqRoot: string, projectId: string, companySlug: string | undefined, override: ProjectFocusOverride): void;
|
|
27
41
|
export interface LaneStatusSnapshot {
|
|
28
42
|
schema_version: "hq-lanes-status.v1";
|
|
29
43
|
project_id: string;
|
|
@@ -11,6 +11,52 @@ const LOCK_RETRY_MS = 10;
|
|
|
11
11
|
const STORY_STATES = ["todo", "in_progress", "review", "waiting", "blocked", "done", "dropped"];
|
|
12
12
|
const MANAGER_OWNED_STATES = new Set(["review", "done", "dropped"]);
|
|
13
13
|
const TERMINAL_STATES = new Set(["done", "failed", "interrupted", "cancelled", "killed"]);
|
|
14
|
+
function projectFocusOverrides(value) {
|
|
15
|
+
if (value === undefined)
|
|
16
|
+
return [];
|
|
17
|
+
if (!Array.isArray(value) || value.length > 100) {
|
|
18
|
+
throw new LanesError("project_focus_overrides_invalid", "Project focus_overrides must be an array with at most 100 entries", {});
|
|
19
|
+
}
|
|
20
|
+
return value.map((item, index) => {
|
|
21
|
+
if (item === null || typeof item !== "object" || Array.isArray(item)) {
|
|
22
|
+
throw new LanesError("project_focus_overrides_invalid", `Project focus override ${index} must be an object`, {});
|
|
23
|
+
}
|
|
24
|
+
const entry = item;
|
|
25
|
+
if (["reason", "by_lane", "at", "story"].some((key) => typeof entry[key] !== "string" || !entry[key].trim())) {
|
|
26
|
+
throw new LanesError("project_focus_overrides_invalid", `Project focus override ${index} is incomplete`, {});
|
|
27
|
+
}
|
|
28
|
+
if (!Number.isFinite(Date.parse(entry.at))) {
|
|
29
|
+
throw new LanesError("project_focus_overrides_invalid", `Project focus override ${index} has an invalid timestamp`, {});
|
|
30
|
+
}
|
|
31
|
+
return entry;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
export function readProjectStoryFocusData(hqRoot, projectId, companySlug) {
|
|
35
|
+
const ledger = resolveProjectStoryLedger(hqRoot, projectId, companySlug);
|
|
36
|
+
const parsed = readLedger(ledger);
|
|
37
|
+
const metadata = parsed.metadata;
|
|
38
|
+
return {
|
|
39
|
+
path: ledger.path,
|
|
40
|
+
metadata: metadata !== null && typeof metadata === "object" && !Array.isArray(metadata)
|
|
41
|
+
? metadata
|
|
42
|
+
: {},
|
|
43
|
+
userStories: parsed.userStories,
|
|
44
|
+
focusOverrides: projectFocusOverrides(parsed.focus_overrides),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export function appendProjectFocusOverride(hqRoot, projectId, companySlug, override) {
|
|
48
|
+
updateLedgerAtomically(resolveProjectStoryLedger(hqRoot, projectId, companySlug), (current) => {
|
|
49
|
+
const existing = projectFocusOverrides(current.focus_overrides);
|
|
50
|
+
return { ...current, focus_overrides: [...existing, override].slice(-100) };
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
export function removeProjectFocusOverride(hqRoot, projectId, companySlug, override) {
|
|
54
|
+
updateLedgerAtomically(resolveProjectStoryLedger(hqRoot, projectId, companySlug), (current) => {
|
|
55
|
+
const remaining = projectFocusOverrides(current.focus_overrides).filter((entry) => entry.reason !== override.reason || entry.by_lane !== override.by_lane ||
|
|
56
|
+
entry.at !== override.at || entry.story !== override.story);
|
|
57
|
+
return { ...current, focus_overrides: remaining };
|
|
58
|
+
});
|
|
59
|
+
}
|
|
14
60
|
export function laneStoryLedgerEnabled(companyUid) {
|
|
15
61
|
return resolveProcessFlagGate(LANE_STORY_LEDGER_FLAG, { fallback: false }, () => false, companyUid);
|
|
16
62
|
}
|
|
@@ -12,10 +12,20 @@ interface TrackerReportStory {
|
|
|
12
12
|
id: string;
|
|
13
13
|
title: string;
|
|
14
14
|
status: ReportStoryStatus;
|
|
15
|
+
focus_id?: unknown;
|
|
16
|
+
kpi?: unknown;
|
|
17
|
+
owner?: string | null;
|
|
18
|
+
deadline?: string | null;
|
|
15
19
|
references?: string[];
|
|
16
20
|
updated_at?: string;
|
|
17
21
|
source_path: string;
|
|
18
22
|
}
|
|
23
|
+
export interface TrackerReportFocusOverride {
|
|
24
|
+
reason: string;
|
|
25
|
+
by_lane: string;
|
|
26
|
+
at: string;
|
|
27
|
+
story: string;
|
|
28
|
+
}
|
|
19
29
|
export interface TrackerReportIssue {
|
|
20
30
|
path: string;
|
|
21
31
|
field: string;
|
|
@@ -47,6 +57,7 @@ export interface TrackerReportTeamInput {
|
|
|
47
57
|
team: string;
|
|
48
58
|
project_id: string;
|
|
49
59
|
stories: readonly TrackerReportStory[];
|
|
60
|
+
focus_overrides?: readonly TrackerReportFocusOverride[];
|
|
50
61
|
pull_requests: readonly TrackerReportPullRequest[];
|
|
51
62
|
releases: readonly TrackerReportRelease[];
|
|
52
63
|
blockers: readonly TrackerReportRecord[];
|
|
@@ -61,8 +72,42 @@ export interface TrackerReportInput {
|
|
|
61
72
|
generated_at: string;
|
|
62
73
|
since: string;
|
|
63
74
|
since_source: "explicit" | "previous_report" | "first_run";
|
|
75
|
+
focus_dashboard_enabled?: boolean;
|
|
64
76
|
teams: readonly TrackerReportTeamInput[];
|
|
65
77
|
}
|
|
78
|
+
interface TrackerFocusItem {
|
|
79
|
+
team: string;
|
|
80
|
+
project_id: string;
|
|
81
|
+
story_id: string;
|
|
82
|
+
title: string;
|
|
83
|
+
owner: string | null;
|
|
84
|
+
deadline: string | null;
|
|
85
|
+
state: ReportStoryStatus;
|
|
86
|
+
focus_id: unknown;
|
|
87
|
+
kpi: unknown;
|
|
88
|
+
override_reason: string | null;
|
|
89
|
+
}
|
|
90
|
+
interface TrackerTeamFocusShare {
|
|
91
|
+
team: string;
|
|
92
|
+
project_id: string;
|
|
93
|
+
window_start: string;
|
|
94
|
+
window_end: string;
|
|
95
|
+
work_story_count: number;
|
|
96
|
+
focused_story_count: number;
|
|
97
|
+
share_percent: number | null;
|
|
98
|
+
status: "red" | "ok" | "no_work";
|
|
99
|
+
}
|
|
100
|
+
interface TrackerFocusOverride extends TrackerReportFocusOverride {
|
|
101
|
+
team: string;
|
|
102
|
+
project_id: string;
|
|
103
|
+
}
|
|
104
|
+
interface TrackerFocusDashboard {
|
|
105
|
+
window_start: string;
|
|
106
|
+
window_end: string;
|
|
107
|
+
focus_list: TrackerFocusItem[];
|
|
108
|
+
team_focus_shares: TrackerTeamFocusShare[];
|
|
109
|
+
overrides: TrackerFocusOverride[];
|
|
110
|
+
}
|
|
66
111
|
interface ReportTimedStory {
|
|
67
112
|
id: string;
|
|
68
113
|
title: string;
|
|
@@ -140,6 +185,7 @@ export interface TrackerOwnerReport {
|
|
|
140
185
|
generated_at: string;
|
|
141
186
|
since: string;
|
|
142
187
|
since_source: TrackerReportInput["since_source"];
|
|
188
|
+
focus_dashboard?: TrackerFocusDashboard;
|
|
143
189
|
teams: TrackerOwnerTeamReport[];
|
|
144
190
|
}
|
|
145
191
|
interface TrackerTeamKpi {
|
|
@@ -231,6 +231,73 @@ function validatedWindow(input) {
|
|
|
231
231
|
}
|
|
232
232
|
return { generatedAt, since, sinceTime, generatedAtTime };
|
|
233
233
|
}
|
|
234
|
+
function hasFocus(story) {
|
|
235
|
+
const present = (value) => typeof value === "string"
|
|
236
|
+
? value.trim().length > 0
|
|
237
|
+
: value !== undefined && value !== null;
|
|
238
|
+
return present(story.focus_id) || present(story.kpi);
|
|
239
|
+
}
|
|
240
|
+
function recentlyWorked(story, sinceTime, throughTime) {
|
|
241
|
+
if (story.updated_at === undefined)
|
|
242
|
+
return false;
|
|
243
|
+
const at = Date.parse(validTimestamp(story.updated_at, "updated_at", story.source_path, { story_id: story.id }));
|
|
244
|
+
return at >= sinceTime && at <= throughTime;
|
|
245
|
+
}
|
|
246
|
+
function buildFocusDashboard(teams, generatedAt, generatedAtTime) {
|
|
247
|
+
const windowStartTime = generatedAtTime - 24 * 60 * 60 * 1000;
|
|
248
|
+
const windowStart = new Date(windowStartTime).toISOString();
|
|
249
|
+
const focusList = [];
|
|
250
|
+
const teamFocusShares = [];
|
|
251
|
+
const overrides = [];
|
|
252
|
+
for (const team of teams) {
|
|
253
|
+
const teamOverrides = [...(team.focus_overrides ?? [])]
|
|
254
|
+
.filter((override) => Number.isFinite(Date.parse(override.at)) && Date.parse(override.at) <= generatedAtTime)
|
|
255
|
+
.sort((a, b) => a.at.localeCompare(b.at) || a.story.localeCompare(b.story));
|
|
256
|
+
overrides.push(...teamOverrides.map((override) => ({ ...override, team: team.team, project_id: team.project_id })));
|
|
257
|
+
const latestOverrideByStory = new Map();
|
|
258
|
+
for (const override of teamOverrides)
|
|
259
|
+
latestOverrideByStory.set(override.story, override.reason);
|
|
260
|
+
for (const story of team.stories) {
|
|
261
|
+
if (story.status === "dropped")
|
|
262
|
+
continue;
|
|
263
|
+
const activeOrRecentlyWorked = story.status === "in_progress" || recentlyWorked(story, windowStartTime, generatedAtTime);
|
|
264
|
+
const overrideReason = latestOverrideByStory.get(story.id) ?? null;
|
|
265
|
+
if (activeOrRecentlyWorked && (hasFocus(story) || overrideReason !== null)) {
|
|
266
|
+
focusList.push({
|
|
267
|
+
team: team.team,
|
|
268
|
+
project_id: team.project_id,
|
|
269
|
+
story_id: story.id,
|
|
270
|
+
title: story.title,
|
|
271
|
+
owner: story.owner ?? null,
|
|
272
|
+
deadline: story.deadline ?? null,
|
|
273
|
+
state: story.status,
|
|
274
|
+
focus_id: story.focus_id ?? null,
|
|
275
|
+
kpi: story.kpi ?? null,
|
|
276
|
+
override_reason: overrideReason,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const workStories = team.stories.filter((story) => story.status === "in_progress" || (story.status !== "dropped" && recentlyWorked(story, windowStartTime, generatedAtTime)));
|
|
281
|
+
const focusedStoryCount = workStories.filter(hasFocus).length;
|
|
282
|
+
const sharePercent = workStories.length === 0
|
|
283
|
+
? null
|
|
284
|
+
: Math.round((focusedStoryCount / workStories.length) * 1000) / 10;
|
|
285
|
+
teamFocusShares.push({
|
|
286
|
+
team: team.team,
|
|
287
|
+
project_id: team.project_id,
|
|
288
|
+
window_start: windowStart,
|
|
289
|
+
window_end: generatedAt,
|
|
290
|
+
work_story_count: workStories.length,
|
|
291
|
+
focused_story_count: focusedStoryCount,
|
|
292
|
+
share_percent: sharePercent,
|
|
293
|
+
status: sharePercent === null ? "no_work" : focusedStoryCount / workStories.length < 0.8 ? "red" : "ok",
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
focusList.sort((a, b) => (a.deadline ?? "\uffff").localeCompare(b.deadline ?? "\uffff") || a.team.localeCompare(b.team) || a.story_id.localeCompare(b.story_id));
|
|
297
|
+
teamFocusShares.sort((a, b) => a.team.localeCompare(b.team));
|
|
298
|
+
overrides.sort((a, b) => a.at.localeCompare(b.at) || a.team.localeCompare(b.team) || a.story.localeCompare(b.story));
|
|
299
|
+
return { window_start: windowStart, window_end: generatedAt, focus_list: focusList, team_focus_shares: teamFocusShares, overrides };
|
|
300
|
+
}
|
|
234
301
|
function removeInvalidEntity(team, err) {
|
|
235
302
|
if (!(err instanceof LanesError))
|
|
236
303
|
return null;
|
|
@@ -316,7 +383,7 @@ export function buildTrackerOwnerReport(input) {
|
|
|
316
383
|
}
|
|
317
384
|
seen.add(team.team);
|
|
318
385
|
}
|
|
319
|
-
|
|
386
|
+
const report = {
|
|
320
387
|
schema_version: "hq-lanes-owner-report.v1",
|
|
321
388
|
company: input.company,
|
|
322
389
|
generated_at: window.generatedAt,
|
|
@@ -326,6 +393,10 @@ export function buildTrackerOwnerReport(input) {
|
|
|
326
393
|
.map((team) => withRecoverableTeamErrors(team, (safeTeam) => teamOwnerReport(safeTeam, input.company, window.sinceTime, window.generatedAtTime)))
|
|
327
394
|
.sort((a, b) => a.team.localeCompare(b.team)),
|
|
328
395
|
};
|
|
396
|
+
if (input.focus_dashboard_enabled === true) {
|
|
397
|
+
report.focus_dashboard = buildFocusDashboard(eligibleTeams, window.generatedAt, window.generatedAtTime);
|
|
398
|
+
}
|
|
399
|
+
return report;
|
|
329
400
|
}
|
|
330
401
|
function teamKpis(team, company, throughTime) {
|
|
331
402
|
return team.kpis
|
|
@@ -460,6 +531,19 @@ export function renderTrackerOwnerReportText(report) {
|
|
|
460
531
|
`Owner report for ${report.company}`,
|
|
461
532
|
`Reporting window: ${report.since} through ${report.generated_at} (${report.since_source.replaceAll("_", " ")})`,
|
|
462
533
|
];
|
|
534
|
+
if (report.focus_dashboard) {
|
|
535
|
+
lines.push("", `Focus dashboard (last 24h: ${report.focus_dashboard.window_start} through ${report.focus_dashboard.window_end})`, "Focus list:");
|
|
536
|
+
lines.push(...listOrNone(report.focus_dashboard.focus_list, (item) => `${item.team}/${item.story_id} — ${item.title}; owner: ${item.owner ?? "unknown"}; deadline: ${item.deadline ?? "none"}; state: ${item.state}${item.override_reason ? `; override: ${item.override_reason}` : ""}`));
|
|
537
|
+
lines.push("Team focus share:");
|
|
538
|
+
lines.push(...listOrNone(report.focus_dashboard.team_focus_shares, (share) => {
|
|
539
|
+
if (share.share_percent === null)
|
|
540
|
+
return `${share.team}: no in-progress or worked stories`;
|
|
541
|
+
const color = share.status === "red" ? "RED under 80%" : "OK";
|
|
542
|
+
return `${share.team}: ${share.share_percent}% (${share.focused_story_count}/${share.work_story_count}); ${color}`;
|
|
543
|
+
}));
|
|
544
|
+
lines.push("Focus guard overrides:");
|
|
545
|
+
lines.push(...listOrNone(report.focus_dashboard.overrides, (override) => `${override.team}/${override.story} by ${override.by_lane} (${override.at}): ${override.reason}`));
|
|
546
|
+
}
|
|
463
547
|
for (const team of report.teams) {
|
|
464
548
|
lines.push("", `${team.team} (${team.project_id})`, "Progress:", " Stories moved to done:");
|
|
465
549
|
lines.push(...listOrNone(team.progress.stories_moved_to_done, (story) => `${story.id} — ${story.title} (${story.at})${story.references.length ? `; references: ${story.references.join(", ")}` : ""}`));
|
|
@@ -525,6 +525,13 @@ export interface LaneRecord {
|
|
|
525
525
|
story_id: string;
|
|
526
526
|
/** Explicit reason a lane was created without a project story. */
|
|
527
527
|
no_story_reason?: string;
|
|
528
|
+
/** Human-authored focus guard overrides used to launch this lane onto a story. */
|
|
529
|
+
focus_overrides?: Array<{
|
|
530
|
+
reason: string;
|
|
531
|
+
by_lane: string;
|
|
532
|
+
at: string;
|
|
533
|
+
story: string;
|
|
534
|
+
}>;
|
|
528
535
|
/** Last failed story-ledger synchronization, cleared after a successful retry. */
|
|
529
536
|
ledger_sync_error?: string;
|
|
530
537
|
/** Pending question ids owned by this lane. */
|
|
@@ -584,6 +591,8 @@ export interface LaneRecord {
|
|
|
584
591
|
keep_alive_exhausted_at?: string;
|
|
585
592
|
/** Maximum number of direct child lanes this lane may keep alive; old records default to 0. */
|
|
586
593
|
hire_grant?: number;
|
|
594
|
+
/** Hire grant returned to the senior while this terminal lane is inactive. */
|
|
595
|
+
released_hire_grant?: number;
|
|
587
596
|
/** Content hash of the profile last applied to this lane. */
|
|
588
597
|
worker_profile_hash?: string;
|
|
589
598
|
/** Registry persona selected by the profile, pinned at lane creation. */
|
|
@@ -756,8 +765,9 @@ export interface LaneRecord {
|
|
|
756
765
|
/** Last successful lane_progress mesh delivery, preserved across watcher restarts. */
|
|
757
766
|
last_progress_event_at?: string;
|
|
758
767
|
}
|
|
759
|
-
/** The v1 fields that must exist on every durable lane record (spec §3).
|
|
760
|
-
|
|
768
|
+
/** The v1 fields that must exist on every durable lane record (spec §3).
|
|
769
|
+
* `company` is normalized from absence for pre-attribution legacy records. */
|
|
770
|
+
export declare const REQUIRED_LANE_RECORD_FIELDS: readonly ["schema_version", "lane_id", "project_id", "story_id", "depth", "senior", "senior_history", "state", "priority", "provider", "host", "version", "created_at", "updated_at", "attribution_history"];
|
|
761
771
|
export declare const LANE_WRITERS: readonly ["creator", "command", "watcher", "reconcile", "spawn", "story-ledger"];
|
|
762
772
|
export type LaneWriter = (typeof LANE_WRITERS)[number];
|
|
763
773
|
/** Field ownership at the durable-record mutation boundary (spec §10.1). */
|
package/dist/lib/lanes/types.js
CHANGED
|
@@ -215,9 +215,10 @@ export function parseSeniorKind(value) {
|
|
|
215
215
|
return value;
|
|
216
216
|
throw new LanesError("unknown_senior_kind", `Unknown senior kind ${JSON.stringify(value)}. Accepted: ${SENIOR_KINDS.join(", ")}`, { accepted: [...SENIOR_KINDS], value });
|
|
217
217
|
}
|
|
218
|
-
/** The v1 fields that must exist on every durable lane record (spec §3).
|
|
218
|
+
/** The v1 fields that must exist on every durable lane record (spec §3).
|
|
219
|
+
* `company` is normalized from absence for pre-attribution legacy records. */
|
|
219
220
|
export const REQUIRED_LANE_RECORD_FIELDS = [
|
|
220
|
-
"schema_version", "lane_id", "
|
|
221
|
+
"schema_version", "lane_id", "project_id", "story_id", "depth",
|
|
221
222
|
"senior", "senior_history", "state", "priority", "provider", "host",
|
|
222
223
|
"version", "created_at", "updated_at", "attribution_history",
|
|
223
224
|
];
|
|
@@ -225,9 +226,10 @@ export const LANE_WRITERS = ["creator", "command", "watcher", "reconcile", "spaw
|
|
|
225
226
|
/** Field ownership at the durable-record mutation boundary (spec §10.1). */
|
|
226
227
|
export const LANE_FIELD_WRITERS = {
|
|
227
228
|
schema_version: ["creator"], lane_id: ["creator"], written_by_version: [], owner_written_by_version: [], company: ["creator", "command"], company_history: ["command"],
|
|
228
|
-
project_id: ["command"], story_id: ["command"], no_story_reason: ["creator", "command"], ledger_sync_error: ["story-ledger"], pending_question_ids: ["story-ledger"], stories: ["creator", "command", "watcher", "reconcile", "spawn"], thread_id: ["creator", "command", "spawn"], mesh_company: ["creator", "command"],
|
|
229
|
+
project_id: ["command"], story_id: ["command"], no_story_reason: ["creator", "command"], focus_overrides: ["command"], ledger_sync_error: ["story-ledger"], pending_question_ids: ["story-ledger"], stories: ["creator", "command", "watcher", "reconcile", "spawn"], thread_id: ["creator", "command", "spawn"], mesh_company: ["creator", "command"],
|
|
229
230
|
depth: ["command"], senior: ["command"], senior_history: ["command"],
|
|
230
231
|
hire_grant: ["creator", "command"],
|
|
232
|
+
released_hire_grant: ["command"],
|
|
231
233
|
state: ["creator", "command", "watcher", "reconcile", "spawn"], queued_reason: ["creator", "command", "spawn", "watcher", "reconcile"], queued_details: ["command", "reconcile", "spawn", "watcher"], queued_at: ["creator", "command", "spawn", "watcher", "reconcile"], admission_reserved_at: ["creator", "command", "reconcile", "spawn", "watcher"],
|
|
232
234
|
pickup_validation_pending: ["creator", "command", "spawn", "reconcile"],
|
|
233
235
|
pickup_validation_pending_since: ["creator", "command", "spawn", "reconcile"],
|
package/dist/main.js
CHANGED
|
@@ -43,7 +43,7 @@ import { isCompanySelectionError } from "./utils/company-selection-error.js";
|
|
|
43
43
|
import { canOfferTeamUpgrade, formatPlanGateError, offerTeamUpgrade, offerUpgradeLink, planGateErrorFromVaultError, } from "./utils/plan-gate-error.js";
|
|
44
44
|
import { upgradeToTeam } from "./utils/team-upgrade.js";
|
|
45
45
|
import { printCachedVersionNudge, printVersionAndNudge, refreshVersionCache, staleAgainstCachedLatest, } from "./utils/version-check.js";
|
|
46
|
-
import { enforceVersionGate, isLocalDependencyInstall, resolveRunningInstall, shouldSkipGate, } from "./utils/version-gate.js";
|
|
46
|
+
import { enforceVersionGate, getVersionGatePhase, isLocalDependencyInstall, resolveRunningInstall, shouldSkipGate, } from "./utils/version-gate.js";
|
|
47
47
|
import { autoUpdateAndReexec } from "./utils/self-update.js";
|
|
48
48
|
import { CLI_VERSION } from "./cli-version.js";
|
|
49
49
|
import { findLazyCommand, registerCommandCatalog } from "./lazy-commands.js";
|
|
@@ -262,6 +262,10 @@ export async function runCli() {
|
|
|
262
262
|
env: process.env,
|
|
263
263
|
entryPath: RUNNING_ENTRY_PATH,
|
|
264
264
|
stderr: process.stderr,
|
|
265
|
+
deps: {
|
|
266
|
+
resolveInstall: resolveRunningInstall,
|
|
267
|
+
versionGatePhase: getVersionGatePhase,
|
|
268
|
+
},
|
|
265
269
|
});
|
|
266
270
|
if (registration.reexecStatus !== undefined) {
|
|
267
271
|
// The re-exec'd child ran the command on the settled tree; carry its exit
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* style of handleTopLevelError so the whole seam is unit-testable without real
|
|
17
17
|
* spawns, waits, or a real install tree.
|
|
18
18
|
*/
|
|
19
|
+
import type { VersionGatePhase } from "./utils/version-gate.js";
|
|
19
20
|
import { type InstallRewriteSignalArgs, type WaitForInstallTreeSettledArgs, type WaitForInstallTreeSettledResult } from "./utils/install-tree-torn.js";
|
|
20
21
|
/**
|
|
21
22
|
* Set on the re-exec'd child so it can NEVER wait or re-exec again — distinct
|
|
@@ -74,6 +75,8 @@ export interface RegisterRecoveryDeps {
|
|
|
74
75
|
waitForSettled?: (args: WaitForInstallTreeSettledArgs) => Promise<WaitForInstallTreeSettledResult>;
|
|
75
76
|
/** Positive writer-signal probe for the eval-throw gate (defaults to installRewriteInProgress). */
|
|
76
77
|
rewriteInProgress?: (args: InstallRewriteSignalArgs) => boolean;
|
|
78
|
+
/** Current version-gate phase for diagnostics; defaults to unknown for early entry recovery. */
|
|
79
|
+
versionGatePhase?: () => VersionGatePhase;
|
|
77
80
|
/** node flags to forward to the re-exec child (defaults to process.execArgv). */
|
|
78
81
|
execArgv?: readonly string[];
|
|
79
82
|
}
|
|
@@ -17,7 +17,6 @@
|
|
|
17
17
|
* spawns, waits, or a real install tree.
|
|
18
18
|
*/
|
|
19
19
|
import { spawnSync } from "node:child_process";
|
|
20
|
-
import { getVersionGatePhase, resolveRunningInstall } from "./utils/version-gate.js";
|
|
21
20
|
import { stringDerivedPackageRoot } from "./utils/hq-roots.js";
|
|
22
21
|
import { updateLockPath, UPDATE_LOCK_STALE_MS } from "./utils/update-lock.js";
|
|
23
22
|
import { classifyEvalThrow, classifyModuleNotFound, installRewriteInProgress, InstallTreeTornError, waitForInstallTreeSettled, } from "./utils/install-tree-torn.js";
|
|
@@ -122,7 +121,7 @@ export async function registerCommandsWithRecovery(args) {
|
|
|
122
121
|
cause: err,
|
|
123
122
|
classified,
|
|
124
123
|
packageRoot,
|
|
125
|
-
versionGatePhase:
|
|
124
|
+
versionGatePhase: readVersionGatePhase(deps),
|
|
126
125
|
outcome: "guarded",
|
|
127
126
|
treeState: env[INSTALL_TREE_RECOVERY_TREE_STATE_ENV] === "stale" ? "stale" : "in_flight",
|
|
128
127
|
attempt: 2,
|
|
@@ -193,7 +192,7 @@ export async function registerCommandsWithRecovery(args) {
|
|
|
193
192
|
cause: err,
|
|
194
193
|
classified,
|
|
195
194
|
packageRoot,
|
|
196
|
-
versionGatePhase:
|
|
195
|
+
versionGatePhase: readVersionGatePhase(deps),
|
|
197
196
|
outcome: "reexec-failed",
|
|
198
197
|
treeState,
|
|
199
198
|
attempt: 1,
|
|
@@ -225,11 +224,19 @@ export async function registerCommandsWithRecovery(args) {
|
|
|
225
224
|
function resolvePackageRoot(deps) {
|
|
226
225
|
let root;
|
|
227
226
|
try {
|
|
228
|
-
root =
|
|
227
|
+
root = deps.resolveInstall?.().packageRoot ?? null;
|
|
229
228
|
}
|
|
230
229
|
catch {
|
|
231
230
|
root = null;
|
|
232
231
|
}
|
|
233
232
|
return root ?? (deps.deriveInstallRoot ?? stringDerivedPackageRoot)();
|
|
234
233
|
}
|
|
234
|
+
function readVersionGatePhase(deps) {
|
|
235
|
+
try {
|
|
236
|
+
return deps.versionGatePhase?.() ?? "unknown";
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return "unknown";
|
|
240
|
+
}
|
|
241
|
+
}
|
|
235
242
|
//# sourceMappingURL=startup-registration.js.map
|
|
@@ -48,6 +48,7 @@ export declare function currentPackageUseUpdatePending(): boolean;
|
|
|
48
48
|
export declare function currentPackageUseUpdateRequested(): boolean;
|
|
49
49
|
export declare const __test__: {
|
|
50
50
|
waitForUpdateRequest: typeof waitForUpdateRequest;
|
|
51
|
+
setPackageUseLeaseHeartbeatIntervalMs(intervalMs: number): void;
|
|
51
52
|
setCurrentPackageUseLease(lease: PackageUseLease | null): void;
|
|
52
53
|
};
|
|
53
54
|
/**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { utimes } from "node:fs/promises";
|
|
3
4
|
import os from "node:os";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
@@ -8,6 +9,8 @@ const CLI_NAME = "@indigoai-us/hq-cli";
|
|
|
8
9
|
const UPDATE_REQUEST = "update.pending.json";
|
|
9
10
|
export const PACKAGE_USE_LEASE_HANDOFF_ENV = "HQ_CLI_PACKAGE_USE_LEASE_HANDOFF";
|
|
10
11
|
const HANDOFF_TIMEOUT_MS = 2_000;
|
|
12
|
+
const PACKAGE_USE_LEASE_HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000;
|
|
13
|
+
let packageUseLeaseHeartbeatIntervalMs = PACKAGE_USE_LEASE_HEARTBEAT_INTERVAL_MS;
|
|
11
14
|
export const PACKAGE_USE_LEASE_PURPOSES = [
|
|
12
15
|
"command",
|
|
13
16
|
"resident-preload",
|
|
@@ -180,10 +183,32 @@ export function acquirePackageUseLease(prefix, hqVersion, stateDirectory, purpos
|
|
|
180
183
|
const leasePath = path.join(directory, `${record.pid}-${record.start_time_ms}.json`);
|
|
181
184
|
const tempPath = `${leasePath}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
182
185
|
let released = false;
|
|
186
|
+
let heartbeatInFlight = false;
|
|
187
|
+
let heartbeat = null;
|
|
188
|
+
const startHeartbeat = () => {
|
|
189
|
+
heartbeat = setInterval(() => {
|
|
190
|
+
if (released || heartbeatInFlight)
|
|
191
|
+
return;
|
|
192
|
+
heartbeatInFlight = true;
|
|
193
|
+
const now = new Date();
|
|
194
|
+
void utimes(leasePath, now, now)
|
|
195
|
+
.catch((error) => {
|
|
196
|
+
if (!released) {
|
|
197
|
+
process.stderr.write(`hq-cli package-use lease heartbeat failed (${errorLabel(error)})\n`);
|
|
198
|
+
}
|
|
199
|
+
})
|
|
200
|
+
.finally(() => {
|
|
201
|
+
heartbeatInFlight = false;
|
|
202
|
+
});
|
|
203
|
+
}, packageUseLeaseHeartbeatIntervalMs);
|
|
204
|
+
heartbeat.unref();
|
|
205
|
+
};
|
|
183
206
|
const release = () => {
|
|
184
207
|
if (released)
|
|
185
208
|
return;
|
|
186
209
|
released = true;
|
|
210
|
+
if (heartbeat)
|
|
211
|
+
clearInterval(heartbeat);
|
|
187
212
|
process.removeListener("exit", releaseOnExit);
|
|
188
213
|
try {
|
|
189
214
|
rmSync(leasePath, { force: true });
|
|
@@ -204,6 +229,7 @@ export function acquirePackageUseLease(prefix, hqVersion, stateDirectory, purpos
|
|
|
204
229
|
throw new Error(`Unable to publish the HQ CLI package-use lease (${errorLabel(error)})`, { cause: error });
|
|
205
230
|
}
|
|
206
231
|
if (!exists(requestPath)) {
|
|
232
|
+
startHeartbeat();
|
|
207
233
|
process.once("exit", releaseOnExit);
|
|
208
234
|
return { leasePath, release };
|
|
209
235
|
}
|
|
@@ -409,6 +435,9 @@ export function currentPackageUseUpdateRequested() {
|
|
|
409
435
|
}
|
|
410
436
|
export const __test__ = {
|
|
411
437
|
waitForUpdateRequest,
|
|
438
|
+
setPackageUseLeaseHeartbeatIntervalMs(intervalMs) {
|
|
439
|
+
packageUseLeaseHeartbeatIntervalMs = intervalMs;
|
|
440
|
+
},
|
|
412
441
|
setCurrentPackageUseLease(lease) {
|
|
413
442
|
currentPackageUseLease = lease;
|
|
414
443
|
},
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare const SELF_UPDATE_FAILURE_BACKOFF_MS: number;
|
|
2
|
+
export interface SelfUpdateFailureRecord {
|
|
3
|
+
/** Version the failed attempt tried to install. */
|
|
4
|
+
target: string;
|
|
5
|
+
/** Epoch milliseconds of the failed attempt. */
|
|
6
|
+
failedAt: number;
|
|
7
|
+
}
|
|
8
|
+
export interface SelfUpdateBackoff {
|
|
9
|
+
/** The recorded failure when it is still inside the backoff window, else null. */
|
|
10
|
+
active(now?: number): SelfUpdateFailureRecord | null;
|
|
11
|
+
record(target: string, now?: number): void;
|
|
12
|
+
clear(): void;
|
|
13
|
+
}
|
|
14
|
+
export declare function selfUpdateBackoffPath(homeDir?: string): string;
|
|
15
|
+
/** File-backed backoff under `~/.hq`. Every operation is best-effort. */
|
|
16
|
+
export declare function fileSelfUpdateBackoff(homeDir?: string): SelfUpdateBackoff;
|
|
17
|
+
//# sourceMappingURL=self-update-backoff.d.ts.map
|