@indigoai-us/hq-cli 5.273.0 → 5.274.1
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 +44 -0
- package/README.md +32 -1
- package/dist/command-catalog.generated.d.ts +72 -0
- package/dist/command-catalog.generated.js +92 -0
- package/dist/command-registration-plan.d.ts +12 -0
- package/dist/command-registration-plan.js +2 -0
- package/dist/commands/cloud.js +40 -0
- package/dist/commands/core-checkpoint.js +3 -1
- package/dist/commands/core-command-catalog.d.ts +32 -0
- package/dist/commands/core-command-catalog.js +58 -0
- package/dist/commands/core-ontology.js +9 -4
- package/dist/commands/core.d.ts +4 -0
- package/dist/commands/core.js +79 -75
- package/dist/commands/deploy-comments.d.ts +3 -0
- package/dist/commands/deploy-comments.js +159 -0
- package/dist/commands/lanes-message.js +40 -7
- package/dist/commands/lanes-move.d.ts +12 -0
- package/dist/commands/lanes-move.js +342 -0
- package/dist/commands/lanes-resume.js +3 -1
- package/dist/commands/lanes-shared.d.ts +40 -1
- package/dist/commands/lanes-shared.js +64 -0
- package/dist/commands/lanes.d.ts +1 -1
- package/dist/commands/lanes.js +211 -460
- package/dist/commands/report.js +2 -14
- package/dist/fast-core.js +1 -1
- package/dist/{commands/scaffold-fast.d.ts → fast-scaffold.d.ts} +1 -1
- package/dist/{commands/scaffold-fast.js → fast-scaffold.js} +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -2
- package/dist/lib/checkpoint-ontology/apply.d.ts +8 -0
- package/dist/lib/checkpoint-ontology/apply.js +33 -0
- package/dist/lib/checkpoint-ontology/plan.d.ts +15 -0
- package/dist/lib/checkpoint-ontology/plan.js +15 -0
- package/dist/lib/deploy-api.d.ts +6 -0
- package/dist/lib/deploy-api.js +21 -0
- package/dist/lib/doctor/fix/apply.d.ts +17 -2
- package/dist/lib/doctor/fix/apply.js +111 -57
- package/dist/lib/doctor/fix/json-output.d.ts +9 -1
- package/dist/lib/doctor/fix/json-output.js +11 -0
- package/dist/lib/first-sync-next-step.d.ts +37 -0
- package/dist/lib/first-sync-next-step.js +189 -0
- package/dist/lib/lanes/hire-grant-dispatch.d.ts +2 -1
- package/dist/lib/lanes/hire-grant-dispatch.js +37 -2
- package/dist/lib/lanes/keep-alive.js +2 -2
- package/dist/main.js +4 -1
- package/dist/utils/cli-telemetry.d.ts +7 -1
- package/dist/utils/cli-telemetry.js +84 -8
- package/dist/utils/contribution-table.d.ts +1 -1
- package/dist/utils/scaffold-fast-import-guard.d.ts +3 -0
- package/dist/utils/scaffold-fast-import-guard.js +28 -0
- package/dist/utils/scaffold-packlist-guard.d.ts +7 -0
- package/dist/utils/scaffold-packlist-guard.js +23 -0
- package/dist/utils/scaffold-sibling-references.d.ts +21 -0
- package/dist/utils/scaffold-sibling-references.js +156 -0
- package/package.json +1 -1
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { resolveProcessFlagGate } from "./flag-registry.js";
|
|
6
|
+
import { cognitoSubjectKey, isMachineIdentity, loadCachedTokens, } from "../utils/cognito-session.js";
|
|
7
|
+
export const CLI_FIRST_SYNC_NEXT_STEP_FLAG = "cli.first-sync-next-step";
|
|
8
|
+
export const FIRST_SYNC_NEXT_STEP_COPY = "Save a file in your HQ folder, then run `hq sync push`.";
|
|
9
|
+
export const FIRST_SYNC_CONFIRMATION_COPY = "Your first work sync is complete.";
|
|
10
|
+
const AUTOMATION_ENV_MARKERS = [
|
|
11
|
+
"CI",
|
|
12
|
+
"HQ_E2E",
|
|
13
|
+
"HQ_TEST",
|
|
14
|
+
"HQ_AUTOMATED",
|
|
15
|
+
"HQ_PROBE",
|
|
16
|
+
"HQ_AGENT_DIR",
|
|
17
|
+
];
|
|
18
|
+
const pendingOutcomes = [];
|
|
19
|
+
export function shouldShowFirstSyncNextStep(input) {
|
|
20
|
+
return (input.flagEnabled &&
|
|
21
|
+
input.interactive &&
|
|
22
|
+
!input.alreadyShown &&
|
|
23
|
+
!input.alreadyCompleted);
|
|
24
|
+
}
|
|
25
|
+
export function shouldConfirmFirstWorkBearingSync(input) {
|
|
26
|
+
return (input.flagEnabled &&
|
|
27
|
+
input.interactive &&
|
|
28
|
+
!input.alreadyCompleted &&
|
|
29
|
+
input.workBearing);
|
|
30
|
+
}
|
|
31
|
+
export function isWorkBearingSyncResult(result) {
|
|
32
|
+
if (result.aborted === true)
|
|
33
|
+
return false;
|
|
34
|
+
return [
|
|
35
|
+
result.filesUploaded,
|
|
36
|
+
result.filesDownloaded,
|
|
37
|
+
result.sessionLogUploadedCount,
|
|
38
|
+
result.filesDeleted,
|
|
39
|
+
result.tombstonedCount,
|
|
40
|
+
].some((count) => typeof count === "number" && Number.isFinite(count) && count > 0);
|
|
41
|
+
}
|
|
42
|
+
function isNonAutomatedHumanCliProcess(input) {
|
|
43
|
+
if (input.machineIdentity || input.env.NODE_ENV === "test")
|
|
44
|
+
return false;
|
|
45
|
+
return !AUTOMATION_ENV_MARKERS.some((name) => Boolean(input.env[name]?.trim()));
|
|
46
|
+
}
|
|
47
|
+
export function isHumanInteractiveCliSession(input) {
|
|
48
|
+
if (!input.stdoutIsTTY || !isNonAutomatedHumanCliProcess(input))
|
|
49
|
+
return false;
|
|
50
|
+
if (input.argv.some((arg) => ["--json", "--help", "-h", "--version", "-V"].includes(arg))) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
function markerDirectory(home) {
|
|
56
|
+
return path.join(home, ".hq", "first-sync-next-step");
|
|
57
|
+
}
|
|
58
|
+
export function firstSyncStepMarkerPaths(identity, home = process.env.HOME?.trim() || os.homedir()) {
|
|
59
|
+
const identityHash = createHash("sha256").update(identity).digest("hex");
|
|
60
|
+
const directory = markerDirectory(home);
|
|
61
|
+
return {
|
|
62
|
+
shown: path.join(directory, `${identityHash}.shown`),
|
|
63
|
+
completed: path.join(directory, `${identityHash}.completed`),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function reportMarkerError(action, err) {
|
|
67
|
+
const code = typeof err === "object" && err !== null && "code" in err
|
|
68
|
+
? String(err.code)
|
|
69
|
+
: err instanceof Error
|
|
70
|
+
? err.name
|
|
71
|
+
: "unknown error";
|
|
72
|
+
console.error(`hq: Could not ${action} first-sync state (${code}).`);
|
|
73
|
+
}
|
|
74
|
+
function firstSyncStepMarkerExists(kind, identity) {
|
|
75
|
+
try {
|
|
76
|
+
const paths = firstSyncStepMarkerPaths(identity);
|
|
77
|
+
return fs.existsSync(paths[kind]);
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
reportMarkerError("read", err);
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
export function claimFirstSyncStepMarker(kind, identity, home = process.env.HOME?.trim() || os.homedir()) {
|
|
85
|
+
const paths = firstSyncStepMarkerPaths(identity, home);
|
|
86
|
+
const hqDirectory = path.join(home, ".hq");
|
|
87
|
+
try {
|
|
88
|
+
// Do not create ~/.hq on a signed-out machine. Authenticated CLI sessions
|
|
89
|
+
// already have it, and this state belongs only to the cached person.
|
|
90
|
+
if (!fs.existsSync(hqDirectory))
|
|
91
|
+
return false;
|
|
92
|
+
fs.mkdirSync(path.dirname(paths[kind]), { recursive: true, mode: 0o700 });
|
|
93
|
+
fs.writeFileSync(paths[kind], `${new Date().toISOString()}\n`, {
|
|
94
|
+
flag: "wx",
|
|
95
|
+
mode: 0o600,
|
|
96
|
+
});
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
if (typeof err === "object" && err !== null && "code" in err && err.code === "EEXIST") {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
reportMarkerError("write", err);
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
export function firstSyncStepTelemetryProperties(outcome) {
|
|
108
|
+
return {
|
|
109
|
+
step: "first_sync_next_step",
|
|
110
|
+
outcome,
|
|
111
|
+
flagKey: CLI_FIRST_SYNC_NEXT_STEP_FLAG,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
export function isFirstSyncNextStepEnabled() {
|
|
115
|
+
return resolveProcessFlagGate(CLI_FIRST_SYNC_NEXT_STEP_FLAG, { fallback: false }, () => false);
|
|
116
|
+
}
|
|
117
|
+
function currentPersonIdentity() {
|
|
118
|
+
return cognitoSubjectKey(loadCachedTokens());
|
|
119
|
+
}
|
|
120
|
+
function isCurrentSessionEligible() {
|
|
121
|
+
return isHumanInteractiveCliSession({
|
|
122
|
+
stdoutIsTTY: Boolean(process.stdout.isTTY),
|
|
123
|
+
argv: process.argv,
|
|
124
|
+
env: process.env,
|
|
125
|
+
machineIdentity: isMachineIdentity(),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
function queueOutcome(outcome) {
|
|
129
|
+
if (!pendingOutcomes.includes(outcome))
|
|
130
|
+
pendingOutcomes.push(outcome);
|
|
131
|
+
}
|
|
132
|
+
/** Called once after the first completed CLI action, before process finalization. */
|
|
133
|
+
export function maybeShowFirstSyncNextStep() {
|
|
134
|
+
// Keep the cheap eligibility/gate checks ahead of identity and filesystem
|
|
135
|
+
// reads; neither an automated invocation nor a flag-off session needs state.
|
|
136
|
+
if (!isCurrentSessionEligible() || !isFirstSyncNextStepEnabled())
|
|
137
|
+
return false;
|
|
138
|
+
const identity = currentPersonIdentity();
|
|
139
|
+
if (!identity)
|
|
140
|
+
return false;
|
|
141
|
+
const shouldShow = shouldShowFirstSyncNextStep({
|
|
142
|
+
flagEnabled: true,
|
|
143
|
+
interactive: true,
|
|
144
|
+
alreadyShown: firstSyncStepMarkerExists("shown", identity),
|
|
145
|
+
alreadyCompleted: firstSyncStepMarkerExists("completed", identity),
|
|
146
|
+
});
|
|
147
|
+
if (!shouldShow || !claimFirstSyncStepMarker("shown", identity))
|
|
148
|
+
return false;
|
|
149
|
+
console.log(FIRST_SYNC_NEXT_STEP_COPY);
|
|
150
|
+
queueOutcome("shown");
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
/** Capture the first positive CLI sync result without changing transfer logic. */
|
|
154
|
+
export function recordFirstWorkBearingSync(result) {
|
|
155
|
+
if (!isWorkBearingSyncResult(result))
|
|
156
|
+
return false;
|
|
157
|
+
if (!isNonAutomatedHumanCliProcess({
|
|
158
|
+
env: process.env,
|
|
159
|
+
machineIdentity: isMachineIdentity(),
|
|
160
|
+
}))
|
|
161
|
+
return false;
|
|
162
|
+
const identity = currentPersonIdentity();
|
|
163
|
+
if (!identity)
|
|
164
|
+
return false;
|
|
165
|
+
const alreadyCompleted = firstSyncStepMarkerExists("completed", identity);
|
|
166
|
+
if (alreadyCompleted || !claimFirstSyncStepMarker("completed", identity))
|
|
167
|
+
return false;
|
|
168
|
+
// Persist completion even while the rollout is off so enabling the flag later
|
|
169
|
+
// cannot prompt a person who already completed a work-bearing sync. JSON and
|
|
170
|
+
// other non-interactive human syncs count too; only the message is TTY-gated.
|
|
171
|
+
if (!isCurrentSessionEligible())
|
|
172
|
+
return false;
|
|
173
|
+
if (!shouldConfirmFirstWorkBearingSync({
|
|
174
|
+
flagEnabled: isFirstSyncNextStepEnabled(),
|
|
175
|
+
interactive: true,
|
|
176
|
+
alreadyCompleted: false,
|
|
177
|
+
workBearing: true,
|
|
178
|
+
}))
|
|
179
|
+
return false;
|
|
180
|
+
console.log(FIRST_SYNC_CONFIRMATION_COPY);
|
|
181
|
+
if (firstSyncStepMarkerExists("shown", identity))
|
|
182
|
+
queueOutcome("followed");
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
/** Drain outcomes once so each shown/followed event is sent at most once per run. */
|
|
186
|
+
export function takeFirstSyncStepOutcomes() {
|
|
187
|
+
return pendingOutcomes.splice(0, pendingOutcomes.length);
|
|
188
|
+
}
|
|
189
|
+
//# sourceMappingURL=first-sync-next-step.js.map
|
|
@@ -16,9 +16,10 @@ export interface OutpostHireGrantContext {
|
|
|
16
16
|
expected_senior: SeniorRef;
|
|
17
17
|
hierarchy: HireGrantLaneSnapshot[];
|
|
18
18
|
superseded_message_caller_id?: string;
|
|
19
|
+
superseded_message_session_id?: string;
|
|
19
20
|
}
|
|
20
21
|
/** Encode only the owner-relevant hierarchy from the primary's complete store. */
|
|
21
|
-
export declare function createOutpostHireGrantContext(target: LaneRecord, lanes: readonly LaneRecord[], supersededMessageCallerId?: string): string;
|
|
22
|
+
export declare function createOutpostHireGrantContext(target: LaneRecord, lanes: readonly LaneRecord[], supersededMessageCallerId?: string, supersededMessageSessionId?: string): string;
|
|
22
23
|
/** Decode and validate an owner grant context, binding it to the target lane. */
|
|
23
24
|
export declare function parseOutpostHireGrantContext(encoded: string, targetLaneId: string): OutpostHireGrantContext;
|
|
24
25
|
export declare function sameHireGrantSenior(a: SeniorRef, b: SeniorRef): boolean;
|
|
@@ -30,11 +30,18 @@ function snapshot(lane) {
|
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
32
|
/** Encode only the owner-relevant hierarchy from the primary's complete store. */
|
|
33
|
-
export function createOutpostHireGrantContext(target, lanes, supersededMessageCallerId) {
|
|
33
|
+
export function createOutpostHireGrantContext(target, lanes, supersededMessageCallerId, supersededMessageSessionId) {
|
|
34
34
|
const current = lanes.find((lane) => lane.lane_id === target.lane_id);
|
|
35
35
|
if (!current || !sameSenior(current.senior, target.senior)) {
|
|
36
36
|
throw new LanesError("hire_grant_hierarchy_unavailable", `Cannot build an authoritative hire-grant hierarchy for lane ${target.lane_id}`, { lane_id: target.lane_id });
|
|
37
37
|
}
|
|
38
|
+
if (supersededMessageCallerId !== undefined && supersededMessageSessionId !== undefined) {
|
|
39
|
+
throw contextError("Outpost grant context cannot contain both lane and session message callers");
|
|
40
|
+
}
|
|
41
|
+
if (supersededMessageSessionId !== undefined &&
|
|
42
|
+
(current.senior.kind !== "session" || current.senior.id !== supersededMessageSessionId)) {
|
|
43
|
+
throw new LanesError("hire_grant_message_caller_invalid", `Cannot include an unverified session message caller in lane ${target.lane_id}'s Outpost context`, { lane_id: target.lane_id });
|
|
44
|
+
}
|
|
38
45
|
const relevant = new Map();
|
|
39
46
|
relevant.set(current.lane_id, current);
|
|
40
47
|
if (current.senior.kind === "lane") {
|
|
@@ -65,6 +72,18 @@ export function createOutpostHireGrantContext(target, lanes, supersededMessageCa
|
|
|
65
72
|
}
|
|
66
73
|
}
|
|
67
74
|
}
|
|
75
|
+
if (current.senior.kind === "session") {
|
|
76
|
+
const companyUid = current.company?.uid;
|
|
77
|
+
const projectId = current.project_id;
|
|
78
|
+
if (companyUid) {
|
|
79
|
+
for (const candidate of lanes) {
|
|
80
|
+
if (candidate.company?.uid === companyUid && ((current.worker_profile !== undefined && candidate.worker_profile === current.worker_profile) ||
|
|
81
|
+
(projectId !== undefined && candidate.project_id === projectId && (candidate.hire_grant ?? 0) > 0))) {
|
|
82
|
+
relevant.set(candidate.lane_id, candidate);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
68
87
|
const hierarchy = [...relevant.values()]
|
|
69
88
|
.map(snapshot)
|
|
70
89
|
.sort((a, b) => a.lane_id.localeCompare(b.lane_id));
|
|
@@ -79,6 +98,9 @@ export function createOutpostHireGrantContext(target, lanes, supersededMessageCa
|
|
|
79
98
|
...(supersededMessageCallerId === undefined
|
|
80
99
|
? {}
|
|
81
100
|
: { superseded_message_caller_id: supersededMessageCallerId }),
|
|
101
|
+
...(supersededMessageSessionId === undefined
|
|
102
|
+
? {}
|
|
103
|
+
: { superseded_message_session_id: supersededMessageSessionId }),
|
|
82
104
|
};
|
|
83
105
|
const encoded = Buffer.from(JSON.stringify(context), "utf8").toString("base64url");
|
|
84
106
|
if (Buffer.byteLength(encoded, "utf8") > MAX_CONTEXT_BYTES) {
|
|
@@ -183,7 +205,7 @@ export function parseOutpostHireGrantContext(encoded, targetLaneId) {
|
|
|
183
205
|
throw contextError("Outpost grant context must be an object");
|
|
184
206
|
}
|
|
185
207
|
const record = raw;
|
|
186
|
-
if (Object.keys(record).some((key) => !["schema_version", "enforcement_enabled", "expected_senior", "hierarchy", "superseded_message_caller_id"].includes(key))) {
|
|
208
|
+
if (Object.keys(record).some((key) => !["schema_version", "enforcement_enabled", "expected_senior", "hierarchy", "superseded_message_caller_id", "superseded_message_session_id"].includes(key))) {
|
|
187
209
|
throw contextError("Outpost grant context contains unknown fields");
|
|
188
210
|
}
|
|
189
211
|
if (record.schema_version !== HIRE_GRANT_CONTEXT_SCHEMA ||
|
|
@@ -194,10 +216,18 @@ export function parseOutpostHireGrantContext(encoded, targetLaneId) {
|
|
|
194
216
|
}
|
|
195
217
|
const expectedSenior = parseSenior(record.expected_senior);
|
|
196
218
|
const messageCallerId = record.superseded_message_caller_id;
|
|
219
|
+
const messageSessionId = record.superseded_message_session_id;
|
|
197
220
|
if (messageCallerId !== undefined &&
|
|
198
221
|
(typeof messageCallerId !== "string" || !messageCallerId.trim() || messageCallerId.trim() !== messageCallerId)) {
|
|
199
222
|
throw contextError("Outpost grant context contains an invalid superseded-message caller");
|
|
200
223
|
}
|
|
224
|
+
if (messageSessionId !== undefined &&
|
|
225
|
+
(typeof messageSessionId !== "string" || !messageSessionId.trim() || messageSessionId.trim() !== messageSessionId)) {
|
|
226
|
+
throw contextError("Outpost grant context contains an invalid superseded-message session");
|
|
227
|
+
}
|
|
228
|
+
if (messageCallerId !== undefined && messageSessionId !== undefined) {
|
|
229
|
+
throw contextError("Outpost grant context cannot contain both lane and session message callers");
|
|
230
|
+
}
|
|
201
231
|
const hierarchy = record.hierarchy.map(parseLaneSnapshot);
|
|
202
232
|
if (!expectedSenior || hierarchy.some((lane) => lane === null)) {
|
|
203
233
|
throw contextError("Outpost grant context contains an invalid senior or lane snapshot");
|
|
@@ -211,6 +241,10 @@ export function parseOutpostHireGrantContext(encoded, targetLaneId) {
|
|
|
211
241
|
if (!target || !sameSenior(target.senior, expectedSenior)) {
|
|
212
242
|
throw contextError("Outpost grant context does not match the target's expected senior");
|
|
213
243
|
}
|
|
244
|
+
if (messageSessionId !== undefined &&
|
|
245
|
+
(expectedSenior.kind !== "session" || expectedSenior.id !== messageSessionId)) {
|
|
246
|
+
throw contextError("Outpost grant context session caller does not match the target's session senior");
|
|
247
|
+
}
|
|
214
248
|
if (expectedSenior.kind === "lane" &&
|
|
215
249
|
(!expectedSenior.id || !byId.has(expectedSenior.id))) {
|
|
216
250
|
throw contextError("Outpost grant context is missing the target's senior lane");
|
|
@@ -232,6 +266,7 @@ export function parseOutpostHireGrantContext(encoded, targetLaneId) {
|
|
|
232
266
|
expected_senior: expectedSenior,
|
|
233
267
|
hierarchy: snapshots,
|
|
234
268
|
...(messageCallerId === undefined ? {} : { superseded_message_caller_id: messageCallerId }),
|
|
269
|
+
...(messageSessionId === undefined ? {} : { superseded_message_session_id: messageSessionId }),
|
|
235
270
|
};
|
|
236
271
|
}
|
|
237
272
|
export function sameHireGrantSenior(a, b) {
|
|
@@ -234,8 +234,8 @@ export function keepAliveSupersededBy(lane, lanes, lineageAware = LANE_SUPERSESS
|
|
|
234
234
|
// Any descendant is part of this lead's work tree, not a replacement lead.
|
|
235
235
|
if (laneAncestorsFrom(candidate.senior, lanesById, sessionOwners).has(lane.lane_id))
|
|
236
236
|
return false;
|
|
237
|
-
const sameLaneSenior = lane.senior.kind === "lane" &&
|
|
238
|
-
candidate.senior.kind ===
|
|
237
|
+
const sameLaneSenior = (lane.senior.kind === "lane" || lane.senior.kind === "session") &&
|
|
238
|
+
candidate.senior.kind === lane.senior.kind &&
|
|
239
239
|
candidate.senior.id === lane.senior.id;
|
|
240
240
|
const sameProfile = lane.worker_profile !== undefined &&
|
|
241
241
|
candidate.worker_profile === lane.worker_profile &&
|
package/dist/main.js
CHANGED
|
@@ -43,7 +43,7 @@ import { enforceVersionGate, shouldSkipGate, } from "./utils/version-gate.js";
|
|
|
43
43
|
import { autoUpdateAndReexec } from "./utils/self-update.js";
|
|
44
44
|
import { CLI_VERSION } from "./cli-version.js";
|
|
45
45
|
import { findLazyCommand, registerCommandCatalog } from "./lazy-commands.js";
|
|
46
|
-
import { emitCliSessionStarted } from "./utils/cli-telemetry.js";
|
|
46
|
+
import { emitCliFirstSyncNextStepOutcome, emitCliSessionStarted, takeFirstSyncStepOutcomes, maybeShowFirstSyncNextStep, } from "./utils/cli-telemetry.js";
|
|
47
47
|
import { reportCliClientHealthInvocation } from "./utils/client-health.js";
|
|
48
48
|
import { settleWithin } from "./utils/settle-with-timeout.js";
|
|
49
49
|
import { emitPlanLimitNag } from "./lib/plan-limit-nag.js";
|
|
@@ -275,6 +275,7 @@ export async function runCli() {
|
|
|
275
275
|
return;
|
|
276
276
|
}
|
|
277
277
|
await program.parseAsync();
|
|
278
|
+
maybeShowFirstSyncNextStep();
|
|
278
279
|
}
|
|
279
280
|
catch (err) {
|
|
280
281
|
await handleTopLevelError(err);
|
|
@@ -292,9 +293,11 @@ export async function runCli() {
|
|
|
292
293
|
// lifecycle boundary for this invocation. The budget reserves this 500 ms
|
|
293
294
|
// slice even when the command itself was long-running.
|
|
294
295
|
const releaseHealthTimeoutMs = foregroundNetworkBudget.timeoutFor(FINALIZATION_FOREGROUND_WAIT_MS);
|
|
296
|
+
const firstSyncOutcomeFlushes = takeFirstSyncStepOutcomes().map((outcome) => emitCliFirstSyncNextStepOutcome(outcome, releaseHealthTimeoutMs));
|
|
295
297
|
await settleWithin([
|
|
296
298
|
refreshVersionCache({ timeoutMs: releaseHealthTimeoutMs }),
|
|
297
299
|
Sentry.flush(releaseHealthTimeoutMs),
|
|
300
|
+
...firstSyncOutcomeFlushes,
|
|
298
301
|
], releaseHealthTimeoutMs);
|
|
299
302
|
});
|
|
300
303
|
// Last, so it wins over anything the (skipped) command path would have
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { type FirstSyncStepOutcome } from "../lib/first-sync-next-step.js";
|
|
2
|
+
export { CLI_FIRST_SYNC_NEXT_STEP_FLAG, FIRST_SYNC_CONFIRMATION_COPY, FIRST_SYNC_NEXT_STEP_COPY, claimFirstSyncStepMarker, firstSyncStepMarkerPaths, firstSyncStepTelemetryProperties, isFirstSyncNextStepEnabled, isHumanInteractiveCliSession, isWorkBearingSyncResult, maybeShowFirstSyncNextStep, recordFirstWorkBearingSync, shouldConfirmFirstWorkBearingSync, shouldShowFirstSyncNextStep, takeFirstSyncStepOutcomes, type FirstSyncStepOutcome, } from "../lib/first-sync-next-step.js";
|
|
1
3
|
export declare const TELEMETRY_TIMEOUT_MS = 1200;
|
|
2
4
|
/**
|
|
3
5
|
* Exact stderr line printed once on the first signed-in TTY run that is not
|
|
4
|
-
* `--json`.
|
|
6
|
+
* `--json`. Keep this notice and its marker stable for the default telemetry
|
|
7
|
+
* path; first-sync fields have a separate opt-in disclosure below.
|
|
5
8
|
*/
|
|
6
9
|
export declare const TELEMETRY_DISCLOSURE_LINE = "HQ sends a usage event when you run a signed-in command (CLI version and a timestamp, never command names, file contents, or secrets) to improve the CLI. Set HQ_TELEMETRY=0 to turn this off.";
|
|
10
|
+
export declare const FIRST_SYNC_TELEMETRY_DISCLOSURE_LINE = "HQ sends a usage event when you run a signed-in command (CLI version and a timestamp; when enabled, first-sync guidance shown or followed and the flag key cli.first-sync-next-step; never command names, file contents, or secrets) to improve the CLI. Set HQ_TELEMETRY=0 to turn this off.";
|
|
7
11
|
/**
|
|
8
12
|
* `HQ_TELEMETRY=0` (also `false` and `off`, case-insensitive) disables every
|
|
9
13
|
* CLI POST to `/v1/telemetry/events`. `HQ_TELEMETRY_TIMEOUT_MS` is unchanged.
|
|
@@ -25,4 +29,6 @@ export declare function maybeDiscloseCliTelemetry(stderr?: {
|
|
|
25
29
|
* This deliberately never refreshes a session or opens a browser.
|
|
26
30
|
*/
|
|
27
31
|
export declare function emitCliSessionStarted(timeoutMs?: number): Promise<void>;
|
|
32
|
+
/** Emit one authenticated measurement for a first-sync prompt outcome. */
|
|
33
|
+
export declare function emitCliFirstSyncNextStepOutcome(outcome: FirstSyncStepOutcome, timeoutMs?: number): Promise<void>;
|
|
28
34
|
//# sourceMappingURL=cli-telemetry.d.ts.map
|
|
@@ -4,14 +4,19 @@ import * as path from "node:path";
|
|
|
4
4
|
import { CLI_VERSION } from "../cli-version.js";
|
|
5
5
|
import { isExpiring, isMachineIdentity, loadCachedTokens, } from "./cognito-session.js";
|
|
6
6
|
import { vaultApiFetch } from "./vault-api.js";
|
|
7
|
+
import { firstSyncStepTelemetryProperties, isFirstSyncNextStepEnabled, isHumanInteractiveCliSession, } from "../lib/first-sync-next-step.js";
|
|
8
|
+
export { CLI_FIRST_SYNC_NEXT_STEP_FLAG, FIRST_SYNC_CONFIRMATION_COPY, FIRST_SYNC_NEXT_STEP_COPY, claimFirstSyncStepMarker, firstSyncStepMarkerPaths, firstSyncStepTelemetryProperties, isFirstSyncNextStepEnabled, isHumanInteractiveCliSession, isWorkBearingSyncResult, maybeShowFirstSyncNextStep, recordFirstWorkBearingSync, shouldConfirmFirstWorkBearingSync, shouldShowFirstSyncNextStep, takeFirstSyncStepOutcomes, } from "../lib/first-sync-next-step.js";
|
|
7
9
|
export const TELEMETRY_TIMEOUT_MS = 1_200;
|
|
8
10
|
/**
|
|
9
11
|
* Exact stderr line printed once on the first signed-in TTY run that is not
|
|
10
|
-
* `--json`.
|
|
12
|
+
* `--json`. Keep this notice and its marker stable for the default telemetry
|
|
13
|
+
* path; first-sync fields have a separate opt-in disclosure below.
|
|
11
14
|
*/
|
|
12
15
|
export const TELEMETRY_DISCLOSURE_LINE = "HQ sends a usage event when you run a signed-in command (CLI version and a timestamp, never command names, file contents, or secrets) to improve the CLI. Set HQ_TELEMETRY=0 to turn this off.";
|
|
16
|
+
export const FIRST_SYNC_TELEMETRY_DISCLOSURE_LINE = "HQ sends a usage event when you run a signed-in command (CLI version and a timestamp; when enabled, first-sync guidance shown or followed and the flag key cli.first-sync-next-step; never command names, file contents, or secrets) to improve the CLI. Set HQ_TELEMETRY=0 to turn this off.";
|
|
13
17
|
const TELEMETRY_DISABLED_VALUES = new Set(["0", "false", "off"]);
|
|
14
18
|
const DISCLOSURE_MARKER = "cli-telemetry-disclosed";
|
|
19
|
+
const FIRST_SYNC_DISCLOSURE_MARKER = "cli-telemetry-disclosed-first-sync-v1";
|
|
15
20
|
let cliSessionStartedPromise;
|
|
16
21
|
/**
|
|
17
22
|
* `HQ_TELEMETRY=0` (also `false` and `off`, case-insensitive) disables every
|
|
@@ -30,27 +35,30 @@ function hqConfigDir() {
|
|
|
30
35
|
export function telemetryDisclosureMarkerPath() {
|
|
31
36
|
return path.join(hqConfigDir(), DISCLOSURE_MARKER);
|
|
32
37
|
}
|
|
38
|
+
function firstSyncTelemetryDisclosureMarkerPath() {
|
|
39
|
+
return path.join(hqConfigDir(), FIRST_SYNC_DISCLOSURE_MARKER);
|
|
40
|
+
}
|
|
33
41
|
function wantsJsonOutput(argv = process.argv) {
|
|
34
42
|
return argv.includes("--json");
|
|
35
43
|
}
|
|
36
44
|
function canPrintDisclosure(stdout = process.stdout, argv = process.argv) {
|
|
37
45
|
return Boolean(stdout.isTTY) && !wantsJsonOutput(argv);
|
|
38
46
|
}
|
|
39
|
-
function hasShownDisclosure() {
|
|
47
|
+
function hasShownDisclosure(markerPath) {
|
|
40
48
|
try {
|
|
41
|
-
return fs.existsSync(
|
|
49
|
+
return fs.existsSync(markerPath);
|
|
42
50
|
}
|
|
43
51
|
catch {
|
|
44
52
|
return false;
|
|
45
53
|
}
|
|
46
54
|
}
|
|
47
|
-
function recordDisclosureShown() {
|
|
55
|
+
function recordDisclosureShown(markerPath) {
|
|
48
56
|
try {
|
|
49
57
|
const dir = hqConfigDir();
|
|
50
58
|
// Do not create ~/.hq. Signed-in runs already have it (cached tokens).
|
|
51
59
|
if (!fs.existsSync(dir))
|
|
52
60
|
return;
|
|
53
|
-
fs.writeFileSync(
|
|
61
|
+
fs.writeFileSync(markerPath, `${new Date().toISOString()}\n`);
|
|
54
62
|
}
|
|
55
63
|
catch {
|
|
56
64
|
// Marker write is best effort; a repeat print is better than crashing.
|
|
@@ -62,17 +70,23 @@ function recordDisclosureShown() {
|
|
|
62
70
|
* non-TTY stdout skip the print and do not record the marker.
|
|
63
71
|
*/
|
|
64
72
|
export function maybeDiscloseCliTelemetry(stderr = process.stderr, stdout = process.stdout, argv = process.argv) {
|
|
65
|
-
|
|
73
|
+
return maybeDiscloseTelemetry(TELEMETRY_DISCLOSURE_LINE, telemetryDisclosureMarkerPath(), stderr, stdout, argv);
|
|
74
|
+
}
|
|
75
|
+
function maybeDiscloseFirstSyncTelemetry() {
|
|
76
|
+
return maybeDiscloseTelemetry(FIRST_SYNC_TELEMETRY_DISCLOSURE_LINE, firstSyncTelemetryDisclosureMarkerPath());
|
|
77
|
+
}
|
|
78
|
+
function maybeDiscloseTelemetry(line, markerPath, stderr = process.stderr, stdout = process.stdout, argv = process.argv) {
|
|
79
|
+
if (hasShownDisclosure(markerPath))
|
|
66
80
|
return true;
|
|
67
81
|
if (!canPrintDisclosure(stdout, argv))
|
|
68
82
|
return false;
|
|
69
83
|
try {
|
|
70
|
-
stderr.write(`${
|
|
84
|
+
stderr.write(`${line}\n`);
|
|
71
85
|
}
|
|
72
86
|
catch {
|
|
73
87
|
return false;
|
|
74
88
|
}
|
|
75
|
-
recordDisclosureShown();
|
|
89
|
+
recordDisclosureShown(markerPath);
|
|
76
90
|
return true;
|
|
77
91
|
}
|
|
78
92
|
/**
|
|
@@ -131,4 +145,66 @@ async function emitCachedCliSessionStarted(timeoutMs) {
|
|
|
131
145
|
// Cached-token reads are also best effort (for example, a malformed cache).
|
|
132
146
|
}
|
|
133
147
|
}
|
|
148
|
+
/** Emit one authenticated measurement for a first-sync prompt outcome. */
|
|
149
|
+
export async function emitCliFirstSyncNextStepOutcome(outcome, timeoutMs = TELEMETRY_TIMEOUT_MS) {
|
|
150
|
+
try {
|
|
151
|
+
if (isCliTelemetryDisabled())
|
|
152
|
+
return;
|
|
153
|
+
if (!isFirstSyncNextStepEnabled())
|
|
154
|
+
return;
|
|
155
|
+
const cached = loadCachedTokens();
|
|
156
|
+
const machineIdentity = isMachineIdentity();
|
|
157
|
+
if (!cached ||
|
|
158
|
+
isExpiring(cached, 120) ||
|
|
159
|
+
!isHumanInteractiveCliSession({
|
|
160
|
+
stdoutIsTTY: Boolean(process.stdout.isTTY),
|
|
161
|
+
argv: process.argv,
|
|
162
|
+
env: process.env,
|
|
163
|
+
machineIdentity,
|
|
164
|
+
}))
|
|
165
|
+
return;
|
|
166
|
+
const token = cached.accessToken;
|
|
167
|
+
if (!token)
|
|
168
|
+
return;
|
|
169
|
+
const consentBasis = maybeDiscloseFirstSyncTelemetry() ? "disclosed" : "no-consent";
|
|
170
|
+
const controller = new AbortController();
|
|
171
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
172
|
+
try {
|
|
173
|
+
const response = await vaultApiFetch({
|
|
174
|
+
token,
|
|
175
|
+
path: "/v1/telemetry/events",
|
|
176
|
+
method: "POST",
|
|
177
|
+
body: {
|
|
178
|
+
events: [
|
|
179
|
+
{
|
|
180
|
+
eventName: "cli_session_started",
|
|
181
|
+
app: "hq-cli",
|
|
182
|
+
source: "cli",
|
|
183
|
+
occurredAt: new Date().toISOString(),
|
|
184
|
+
consentBasis,
|
|
185
|
+
schemaVersion: 1,
|
|
186
|
+
properties: {
|
|
187
|
+
version: CLI_VERSION,
|
|
188
|
+
...firstSyncStepTelemetryProperties(outcome),
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
},
|
|
193
|
+
signal: controller.signal,
|
|
194
|
+
});
|
|
195
|
+
if (!response.ok)
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
// Telemetry is best effort and must never affect or follow as an error
|
|
200
|
+
// after the completed CLI command.
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
clearTimeout(timeout);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
// Cached-token reads are also best effort (for example, a malformed cache).
|
|
208
|
+
}
|
|
209
|
+
}
|
|
134
210
|
//# sourceMappingURL=cli-telemetry.js.map
|
|
@@ -99,5 +99,5 @@ export declare const CONTRIBUTION_KEYS: ContributionKey[];
|
|
|
99
99
|
*/
|
|
100
100
|
export declare function payloadFor(key: ContributionKey, item: string): string;
|
|
101
101
|
/** Keys whose contributions are wired by a host symlink (skip `merge` rows). */
|
|
102
|
-
export declare const SYMLINK_KEYS: ("workers" | "mcp" | "
|
|
102
|
+
export declare const SYMLINK_KEYS: ("workers" | "mcp" | "commands" | "skills" | "knowledge" | "hooks" | "policies" | "scripts")[];
|
|
103
103
|
//# sourceMappingURL=contribution-table.d.ts.map
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare function forbiddenFastPathModules(loadedModules: readonly string[], distDirectory: string): string[];
|
|
2
|
+
export declare function assertFastPathStaysCommandGraphFree(loadedModules: readonly string[], distDirectory: string): void;
|
|
3
|
+
//# sourceMappingURL=scaffold-fast-import-guard.d.ts.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
export function forbiddenFastPathModules(loadedModules, distDirectory) {
|
|
4
|
+
const dist = path.resolve(distDirectory);
|
|
5
|
+
const main = path.join(dist, "main.js");
|
|
6
|
+
const commands = `${path.join(dist, "commands")}${path.sep}`;
|
|
7
|
+
return [...new Set(loadedModules.map((loaded) => {
|
|
8
|
+
if (loaded.startsWith("file:")) {
|
|
9
|
+
try {
|
|
10
|
+
return fileURLToPath(loaded);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return loaded;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return path.resolve(loaded);
|
|
17
|
+
}))]
|
|
18
|
+
.filter((loaded) => path.resolve(loaded) === main ||
|
|
19
|
+
path.resolve(loaded).startsWith(commands))
|
|
20
|
+
.sort();
|
|
21
|
+
}
|
|
22
|
+
export function assertFastPathStaysCommandGraphFree(loadedModules, distDirectory) {
|
|
23
|
+
const forbidden = forbiddenFastPathModules(loadedModules, distDirectory);
|
|
24
|
+
if (forbidden.length > 0) {
|
|
25
|
+
throw new Error(`fast core loaded forbidden command modules:\n${forbidden.join("\n")}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=scaffold-fast-import-guard.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type ScaffoldPacklistCheck = {
|
|
2
|
+
missingAssets: string[];
|
|
3
|
+
forbiddenTestData: string[];
|
|
4
|
+
};
|
|
5
|
+
export declare function checkScaffoldPacklist(listedAssets: readonly string[], packedPaths: ReadonlySet<string>, assetPrefix: string): ScaffoldPacklistCheck;
|
|
6
|
+
export declare function assertScaffoldPacklist(listedAssets: readonly string[], packedPaths: ReadonlySet<string>, assetPrefix: string): void;
|
|
7
|
+
//# sourceMappingURL=scaffold-packlist-guard.d.ts.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function checkScaffoldPacklist(listedAssets, packedPaths, assetPrefix) {
|
|
2
|
+
const prefix = assetPrefix.replace(/\/$/, "");
|
|
3
|
+
const missingAssets = listedAssets
|
|
4
|
+
.map((asset) => `${prefix}/${asset}`)
|
|
5
|
+
.filter((assetPath) => !packedPaths.has(assetPath))
|
|
6
|
+
.sort();
|
|
7
|
+
const forbiddenTestData = [...packedPaths]
|
|
8
|
+
.filter((packedPath) => packedPath === "test/differential" ||
|
|
9
|
+
packedPath.startsWith("test/differential/") ||
|
|
10
|
+
packedPath.split("/").some((part) => /corpus/i.test(part)))
|
|
11
|
+
.sort();
|
|
12
|
+
return { missingAssets, forbiddenTestData };
|
|
13
|
+
}
|
|
14
|
+
export function assertScaffoldPacklist(listedAssets, packedPaths, assetPrefix) {
|
|
15
|
+
const check = checkScaffoldPacklist(listedAssets, packedPaths, assetPrefix);
|
|
16
|
+
const failures = [
|
|
17
|
+
...check.missingAssets.map((asset) => `missing listed asset ${asset}`),
|
|
18
|
+
...check.forbiddenTestData.map((file) => `test data leaked into packlist: ${file}`),
|
|
19
|
+
];
|
|
20
|
+
if (failures.length > 0)
|
|
21
|
+
throw new Error(failures.join("\n"));
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=scaffold-packlist-guard.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type SiblingAssetReference = {
|
|
2
|
+
referringAsset: string;
|
|
3
|
+
targetAsset: string;
|
|
4
|
+
};
|
|
5
|
+
export type HqRootAssetReference = {
|
|
6
|
+
referringAsset: string;
|
|
7
|
+
reference: string;
|
|
8
|
+
};
|
|
9
|
+
export type ScaffoldReferenceScan = {
|
|
10
|
+
missing: SiblingAssetReference[];
|
|
11
|
+
allowedHqRoot: HqRootAssetReference[];
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Scan every asset below assets/scaffold for local file loads. Node source is
|
|
15
|
+
* identified by extension or shebang; unknown text assets use shell scanning.
|
|
16
|
+
* `${HQ_ROOT}/...` paths stay rooted in the live HQ tree and are returned as
|
|
17
|
+
* allowed references for review instead of being mistaken for package assets.
|
|
18
|
+
*/
|
|
19
|
+
export declare function scanScaffoldSiblingReferences(scaffoldRoot: string): ScaffoldReferenceScan;
|
|
20
|
+
export declare function assertNoMissingScaffoldSiblings(scan: ScaffoldReferenceScan): void;
|
|
21
|
+
//# sourceMappingURL=scaffold-sibling-references.d.ts.map
|