@bli-cockpit/cli 0.2.158 → 0.2.160
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/dist/adapters/common.js +2 -2
- package/dist/adapters/git-state.js +2 -2
- package/dist/adapters/risk-flags.js +9 -25
- package/dist/commands/careers.js +10 -1
- package/dist/commands/cli-io.js +40 -5
- package/dist/commands/collection-roots.js +2 -2
- package/dist/commands/install-update.js +29 -10
- package/dist/commands/local-args-collector-sources.js +27 -0
- package/dist/commands/local-args-collector.js +2 -1
- package/dist/commands/local-args-tower-careers.js +27 -3
- package/dist/commands/local-args.js +3 -1
- package/dist/commands/local-auth.js +3 -2
- package/dist/commands/local-help-commands-tower.js +3 -1
- package/dist/commands/local-help-commands.js +14 -0
- package/dist/commands/local-help.js +3 -1
- package/dist/commands/local.js +3 -0
- package/dist/commands/notes-writes.js +3 -3
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/settings.js +3 -2
- package/dist/commands/sources-check.js +135 -0
- package/dist/commands/sources-discovery.js +232 -0
- package/dist/commands/sources-verdict.js +128 -0
- package/dist/evidence-transcripts-client.js +118 -0
- package/dist/onboarding-roots.js +6 -6
- package/dist/yes-no.js +28 -0
- package/package.json +4 -4
package/dist/adapters/common.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SourceScanResultSchema, parseTicketIdFromText, } from "@bli-cockpit/telemetry-core";
|
|
1
|
+
import { SourceScanResultSchema, parseTicketIdFromBranch, parseTicketIdFromText, } from "@bli-cockpit/telemetry-core";
|
|
2
2
|
import { LOCAL_COLLECTOR_VERSION } from "../local-state.js";
|
|
3
3
|
export function makeSourceAdapterIdentity(captureSource, adapterName) {
|
|
4
4
|
return {
|
|
@@ -34,4 +34,4 @@ export function makeSourceScan(context, captureSource, adapterName, status, reas
|
|
|
34
34
|
diagnostic_labels: [reasonLabel],
|
|
35
35
|
});
|
|
36
36
|
}
|
|
37
|
-
export { parseTicketIdFromText };
|
|
37
|
+
export { parseTicketIdFromBranch, parseTicketIdFromText };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { SourceScanResultSchema, } from "@bli-cockpit/telemetry-core";
|
|
2
2
|
import { execFile } from "node:child_process";
|
|
3
3
|
import { promisify } from "node:util";
|
|
4
|
-
import { makeSourceAdapterIdentity,
|
|
4
|
+
import { makeSourceAdapterIdentity, parseTicketIdFromBranch, } from "./common.js";
|
|
5
5
|
const execFileAsync = promisify(execFile);
|
|
6
6
|
export async function collectGitState(context, runner = defaultGitCommandRunner()) {
|
|
7
7
|
const startedAt = context.now.toISOString();
|
|
@@ -22,7 +22,7 @@ export async function collectGitState(context, runner = defaultGitCommandRunner(
|
|
|
22
22
|
...fileStats.map((file) => file.path),
|
|
23
23
|
]),
|
|
24
24
|
].sort();
|
|
25
|
-
const ticketIdFromBranch =
|
|
25
|
+
const ticketIdFromBranch = parseTicketIdFromBranch(branch);
|
|
26
26
|
const ticketBindingCandidates = ticketIdFromBranch
|
|
27
27
|
? [
|
|
28
28
|
{
|
|
@@ -5,7 +5,7 @@ export function generateDumbRiskFlags(input) {
|
|
|
5
5
|
const flags = [];
|
|
6
6
|
const git = input.git ?? null;
|
|
7
7
|
const changedLines = (git?.added_lines ?? 0) + (git?.deleted_lines ?? 0);
|
|
8
|
-
if (git && changedLines >= 800 &&
|
|
8
|
+
if (git && changedLines >= 800 && input.hasPlanSignal === false) {
|
|
9
9
|
flags.push(makeRiskFlag(input, "no_plan_signal_before_large_edit", [
|
|
10
10
|
"missing_plan_signal",
|
|
11
11
|
"large_edit",
|
|
@@ -17,27 +17,13 @@ export function generateDumbRiskFlags(input) {
|
|
|
17
17
|
git.deleted_lines >= 200) {
|
|
18
18
|
flags.push(makeRiskFlag(input, "many_add_revert_loops", ["add_revert_loop"]));
|
|
19
19
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if ((input.repeatedFailureCount ?? 0) >= 3) {
|
|
28
|
-
flags.push(makeRiskFlag(input, "repeated_same_failing_command", [
|
|
29
|
-
"same_failing_command",
|
|
30
|
-
]));
|
|
31
|
-
}
|
|
32
|
-
if ((input.permissionDeniedCount ?? 0) >= 2) {
|
|
33
|
-
flags.push(makeRiskFlag(input, "permission_denied_spike", ["permission_denied"]));
|
|
34
|
-
}
|
|
35
|
-
if ((input.tokenCount ?? 0) >= 1_000_000 && !input.hasPullRequest) {
|
|
36
|
-
flags.push(makeRiskFlag(input, "high_token_or_time_burn_no_pr", [
|
|
37
|
-
"token_pressure",
|
|
38
|
-
"no_pr_after_work",
|
|
39
|
-
]));
|
|
40
|
-
}
|
|
20
|
+
// BLI-12189: p1_or_p2_review_finding, repeated_same_failing_command,
|
|
21
|
+
// permission_denied_spike and high_token_or_time_burn_no_pr were removed.
|
|
22
|
+
// Their inputs (review priorities, failure and denial counts, token count,
|
|
23
|
+
// PR presence) were never passed by the one caller (local-sources.ts), so the
|
|
24
|
+
// flags could not fire. The risk_type values stay in the telemetry schema
|
|
25
|
+
// for a producer that really measures them; wire the input first, then add
|
|
26
|
+
// the branch back with a test that drives it from a real fixture.
|
|
41
27
|
const branchTicket = git?.ticket_id_from_branch;
|
|
42
28
|
if (branchTicket &&
|
|
43
29
|
input.binding.selected_ticket_id &&
|
|
@@ -56,9 +42,7 @@ function makeRiskFlag(input, riskType, evidenceLabels) {
|
|
|
56
42
|
.digest("hex")
|
|
57
43
|
.slice(0, 16)}`,
|
|
58
44
|
risk_type: riskType,
|
|
59
|
-
severity: riskType === "
|
|
60
|
-
? "high"
|
|
61
|
-
: "warning",
|
|
45
|
+
severity: riskType === "branch_ticket_mismatch" ? "high" : "warning",
|
|
62
46
|
observed_at: input.context.now.toISOString(),
|
|
63
47
|
evidence_labels: evidenceLabels,
|
|
64
48
|
evidence_pointer_ids: [],
|
package/dist/commands/careers.js
CHANGED
|
@@ -52,6 +52,9 @@ async function planCareersRequest(command) {
|
|
|
52
52
|
// The grader itself runs on Railway, because it needs a clone and yt-dlp.
|
|
53
53
|
// This door re-queues the row; `--now` is passed through and the answer
|
|
54
54
|
// names where the run actually happens rather than implying it ran here.
|
|
55
|
+
// BLI-12183: `{}` sends the second-chance email now; `--record-sent-at`
|
|
56
|
+
// records one sent outside Tower and sends nothing.
|
|
57
|
+
case 'followup': return { path: `${application}/followup`, method: 'POST', body: command.recordSentAt ? { record_sent_at: command.recordSentAt } : {} };
|
|
55
58
|
case 'grade': return { path: `${application}/grade`, method: 'POST', body: { now: command.now === true } };
|
|
56
59
|
case 'decide': return { path: `${application}/decide`, method: 'POST', body: { decision: command.decision } };
|
|
57
60
|
// `resume` never arrives here: `runCareers` hands it to `careers-resume.ts`
|
|
@@ -101,8 +104,14 @@ function settingsBody(command) {
|
|
|
101
104
|
body.booking_link = command.bookingLink;
|
|
102
105
|
if (command.graderModel !== undefined)
|
|
103
106
|
body.grader_model = command.graderModel;
|
|
107
|
+
if (command.followupEnabled !== undefined)
|
|
108
|
+
body.followup_enabled = command.followupEnabled;
|
|
109
|
+
if (command.followupAfterDays !== undefined)
|
|
110
|
+
body.followup_after_days = command.followupAfterDays;
|
|
111
|
+
if (command.takehomeWindowHours !== undefined)
|
|
112
|
+
body.takehome_window_hours = command.takehomeWindowHours;
|
|
104
113
|
if (Object.keys(body).length === 0) {
|
|
105
|
-
throw new Error('careers settings set needs at least one of --enabled, --auto-invite-min-score, --attention-min-score, --account, --booking-link or --
|
|
114
|
+
throw new Error('careers settings set needs at least one of --enabled, --auto-invite-min-score, --attention-min-score, --account, --booking-link, --grader-model, --followup-enabled, --followup-after-days or --takehome-window-hours.');
|
|
106
115
|
}
|
|
107
116
|
return body;
|
|
108
117
|
}
|
package/dist/commands/cli-io.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseYesNo } from "../yes-no.js";
|
|
1
2
|
import { createCapturedExecRunner, createInteractiveExecRunner, } from "../process-runner.js";
|
|
2
3
|
export function writeLine(stream, text) {
|
|
3
4
|
stream.write(`${text}\n`);
|
|
@@ -96,6 +97,10 @@ export async function readPipedText(stream, options) {
|
|
|
96
97
|
}
|
|
97
98
|
return stripLeadingBom(text);
|
|
98
99
|
}
|
|
100
|
+
// Lines that arrived in the same chunk as an earlier answer (a paste, type-ahead,
|
|
101
|
+
// or a slow event loop that let the stream coalesce its buffer). Per stream, so
|
|
102
|
+
// the next prompt reads them instead of waiting for input that already came.
|
|
103
|
+
const queuedLines = new WeakMap();
|
|
99
104
|
/**
|
|
100
105
|
* Reads one line from stdin after writing a prompt. Shared by the onboard email
|
|
101
106
|
* prompt and the autostart prompt; callers gate on `isInteractiveStdin` first so
|
|
@@ -103,21 +108,51 @@ export async function readPipedText(stream, options) {
|
|
|
103
108
|
*/
|
|
104
109
|
export async function readLine(io, prompt) {
|
|
105
110
|
io.stdout.write(prompt);
|
|
111
|
+
const queue = queuedLines.get(io.stdin) ?? [];
|
|
112
|
+
queuedLines.set(io.stdin, queue);
|
|
113
|
+
const queued = queue.shift();
|
|
114
|
+
if (queued !== undefined)
|
|
115
|
+
return queued;
|
|
106
116
|
io.stdin.setEncoding("utf8");
|
|
107
117
|
return new Promise((resolve) => {
|
|
108
118
|
const onData = (chunk) => {
|
|
109
119
|
io.stdin.removeListener("data", onData);
|
|
110
120
|
io.stdin.pause();
|
|
111
|
-
|
|
121
|
+
const lines = chunk.match(/[^\n]*\n|[^\n]+$/gu) ?? [chunk];
|
|
122
|
+
const [first = chunk, ...rest] = lines;
|
|
123
|
+
queue.push(...rest);
|
|
124
|
+
if (rest.length > 0) {
|
|
125
|
+
console.error(`[cli-io] stdin chunk held ${lines.length} lines {"queued_lines":${rest.length}}`);
|
|
126
|
+
}
|
|
127
|
+
resolve(first);
|
|
112
128
|
};
|
|
113
129
|
io.stdin.resume();
|
|
114
130
|
io.stdin.on("data", onData);
|
|
115
131
|
});
|
|
116
132
|
}
|
|
117
|
-
/**
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
133
|
+
/**
|
|
134
|
+
* Settles a typed answer to a yes/no prompt (BLI-12189). `unclear` asks once
|
|
135
|
+
* more when `reask` is set, then declines; a destructive prompt passes
|
|
136
|
+
* `emptyIsYes: false` and no `reask`, so anything unclear is a No.
|
|
137
|
+
*/
|
|
138
|
+
export async function settleYesNo(io, firstAnswer, prompt, options) {
|
|
139
|
+
let answer = firstAnswer;
|
|
140
|
+
const attempts = options.reask ? 2 : 1;
|
|
141
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
142
|
+
const verdict = parseYesNo(answer, options);
|
|
143
|
+
if (verdict !== "unclear") {
|
|
144
|
+
console.error(`[cli-io] yes/no settled ${JSON.stringify({ verdict, attempt })}`);
|
|
145
|
+
return verdict === "yes";
|
|
146
|
+
}
|
|
147
|
+
console.error(`[cli-io] yes/no unclear ${JSON.stringify({ attempt, will_reask: attempt < attempts })}`);
|
|
148
|
+
if (attempt < attempts)
|
|
149
|
+
answer = await readLine(io, `Please answer y or n. ${prompt}`);
|
|
150
|
+
}
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
/** Reads one answer and settles it. Non-destructive: Enter is yes, unclear asks once more. */
|
|
154
|
+
export async function askYesNo(io, prompt, options = { emptyIsYes: true, reask: true }) {
|
|
155
|
+
return settleYesNo(io, await readLine(io, prompt), prompt, options);
|
|
121
156
|
}
|
|
122
157
|
/** Collects what a nested command writes instead of letting it reach the user. */
|
|
123
158
|
export function bufferedWritable(chunks) {
|
|
@@ -11,14 +11,14 @@ import os from "node:os";
|
|
|
11
11
|
import { scanWorkDirectories } from "./local-discovery.js";
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
import { stat } from "node:fs/promises";
|
|
14
|
-
import { isInteractiveStdin, readLine, writeLine,
|
|
14
|
+
import { isInteractiveStdin, readLine, writeLine, askYesNo } from "./cli-io.js";
|
|
15
15
|
import { CollectionRootRequiredError, resolveOnboardingRoots, } from "../onboarding-roots.js";
|
|
16
16
|
import { getCollectorRuntimePaths, installLocalCollector, readLocalCollectorConfig, } from "../local-state.js";
|
|
17
17
|
import { collectionRootPathAliases } from "../repo-identity.js";
|
|
18
18
|
import { normalizeCollectionRoots } from "../root-normalization.js";
|
|
19
19
|
export function onboardingRootPrompt(io) {
|
|
20
20
|
return {
|
|
21
|
-
confirm: async (message) =>
|
|
21
|
+
confirm: async (message) => askYesNo(io, message),
|
|
22
22
|
input: (message) => readLine(io, message),
|
|
23
23
|
message: (message) => writeLine(io.stdout, message),
|
|
24
24
|
};
|
|
@@ -14,7 +14,7 @@ import { defaultExec, defaultInteractiveExec, writeExecOutput, writeLine } from
|
|
|
14
14
|
import { assertCollectionRootPersisted } from "./collection-roots.js";
|
|
15
15
|
import { addInstallEvent, reportInstallEventsBestEffort } from "./install-receipts.js";
|
|
16
16
|
import { installLocalCollector, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
|
|
17
|
-
import {
|
|
17
|
+
import { CollectionRootRequiredError, missingCollectionRootMessage, normalizeRootsDetailed, rootRejectionExplanation, } from "../onboarding-roots.js";
|
|
18
18
|
export class SelfUpdateError extends Error {
|
|
19
19
|
result;
|
|
20
20
|
eacces;
|
|
@@ -192,16 +192,35 @@ function updateOnboardArgs(command) {
|
|
|
192
192
|
function isNpmEaccesFailure(stderr) {
|
|
193
193
|
return /EACCES|permission denied/i.test(stderr);
|
|
194
194
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
195
|
+
/**
|
|
196
|
+
* The reason a failed onboard rerun carries (BLI-12189). Never scraped from
|
|
197
|
+
* prose: every rerun prints "pairing skipped", so matching words labelled any
|
|
198
|
+
* failed update `pairing_timeout`. A `--json` rerun hands back the typed
|
|
199
|
+
* `blocker` the onboard step itself computed; otherwise the exit code is the
|
|
200
|
+
* only honest fact, and it is the label.
|
|
201
|
+
*/
|
|
202
|
+
export function updateOnboardFailureCode(result) {
|
|
203
|
+
const blocker = typedOnboardBlocker(result.stdout);
|
|
204
|
+
const label = blocker ?? `onboard_exit_${result.code}`;
|
|
205
|
+
console.error(`[install-update] onboard rerun failed ${JSON.stringify({
|
|
206
|
+
exit_code: result.code,
|
|
207
|
+
reason: label,
|
|
208
|
+
source: blocker ? "typed_blocker" : "exit_code",
|
|
209
|
+
})}`);
|
|
210
|
+
return label;
|
|
211
|
+
}
|
|
212
|
+
function typedOnboardBlocker(stdout) {
|
|
213
|
+
const text = stdout.trim();
|
|
214
|
+
if (!text.startsWith("{"))
|
|
215
|
+
return null;
|
|
216
|
+
try {
|
|
217
|
+
const parsed = JSON.parse(text);
|
|
218
|
+
const blocker = parsed?.blocker;
|
|
219
|
+
return typeof blocker === "string" && blocker.length > 0 ? blocker : null;
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return null;
|
|
203
223
|
}
|
|
204
|
-
return "onboard_rerun_failed";
|
|
205
224
|
}
|
|
206
225
|
function updateCollectionRoots(command) {
|
|
207
226
|
const roots = command.collectionRoots?.length
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit sources check [--older-than <days>] [--json]` (BLI-11122).
|
|
3
|
+
*
|
|
4
|
+
* One verb, read-only. There is deliberately no `sources clean` and no
|
|
5
|
+
* `--apply`: Tower never deletes a person's raw transcripts; deleting one is
|
|
6
|
+
* the person's own act, outside Tower. A path is never accepted: the roots are
|
|
7
|
+
* the ones the collector reads (`sources-discovery.ts`).
|
|
8
|
+
*/
|
|
9
|
+
import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs, } from "./local-arg-values.js";
|
|
10
|
+
export function parseSourcesArgs(args) {
|
|
11
|
+
const values = parseNamedArgs(args, {
|
|
12
|
+
allowedFlags: ["--older-than", "--home", "--dashboard-url", "--json"],
|
|
13
|
+
valueFlags: ["--older-than", "--home", "--dashboard-url"],
|
|
14
|
+
});
|
|
15
|
+
const action = values.positionals[0] ?? "check";
|
|
16
|
+
if (action !== "check" || values.positionals.length > 1) {
|
|
17
|
+
throw new Error("sources has one verb: `cockpit sources check [--older-than <days>] [--json]`.");
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
kind: "sources",
|
|
21
|
+
action: "check",
|
|
22
|
+
olderThanDays: optionalPositiveInteger(values.flags.get("--older-than"), "--older-than") ?? null,
|
|
23
|
+
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
24
|
+
dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
|
|
25
|
+
json: values.booleans.has("--json"),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -26,4 +26,5 @@
|
|
|
26
26
|
*/
|
|
27
27
|
export { parseOnboardArgs, parseUpdateArgs, parseDoctorArgs, parseInstallArgs, parseReleaseArgs, parseLoginArgs, parseLogoutArgs, } from "./local-args-collector-setup.js";
|
|
28
28
|
export { parseStartArgs, parseSyncArgs, parseAnalyzeArgs, parseBackfillArgs, } from "./local-args-collector-work.js";
|
|
29
|
-
export { parseStatusArgs, parseSessionsArgs, parseServeArgs, parseAutostartArgs, parseAgentRulesArgs, parseMemoryArgs, parseCleanArgs, } from "./local-args-collector-status.js";
|
|
29
|
+
export { parseStatusArgs, parseSessionsArgs, parseServeArgs, parseAutostartArgs, parseAgentRulesArgs, parseMemoryArgs, parseCleanArgs, } from "./local-args-collector-status.js";
|
|
30
|
+
export { parseSourcesArgs } from "./local-args-collector-sources.js";
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { optionalNonEmpty, optionalUrl, parseNamedArgs } from './local-arg-values.js';
|
|
2
|
-
const ACTIONS = ['list', 'show', 'rescreen', 'invite', 'decide', 'grade', 'resume', 'takehome-show', 'takehome-set', 'settings-show', 'settings-set'];
|
|
2
|
+
const ACTIONS = ['list', 'show', 'rescreen', 'invite', 'decide', 'followup', 'grade', 'resume', 'takehome-show', 'takehome-set', 'settings-show', 'settings-set'];
|
|
3
3
|
/** Typed word → the one action it means. The left side is what a person reads on the button. */
|
|
4
4
|
const ALSO_SPELLED = { 'send-takehome': 'invite' };
|
|
5
5
|
const DECISIONS = ['passed_on', 'archived', 'reopen'];
|
|
6
6
|
/** Verbs that name an application id, a role slug, or nothing at all. */
|
|
7
|
-
const NEEDS_ID = ['show', 'rescreen', 'invite', 'decide', 'grade', 'resume'];
|
|
7
|
+
const NEEDS_ID = ['show', 'rescreen', 'invite', 'decide', 'followup', 'grade', 'resume'];
|
|
8
8
|
const NEEDS_ROLE = ['takehome-show', 'takehome-set'];
|
|
9
9
|
export function parseCareersArgs(args) {
|
|
10
|
-
const valueFlags = ['--role', '--min-score', '--since', '--home', '--dashboard-url', '--decision', '--stage', '--subject', '--body-file', '--link', '--pass-score', '--account', '--auto-invite-min-score', '--attention-min-score', '--enabled', '--booking-link', '--grader-model', '--out'];
|
|
10
|
+
const valueFlags = ['--role', '--min-score', '--since', '--home', '--dashboard-url', '--decision', '--stage', '--subject', '--body-file', '--link', '--pass-score', '--account', '--auto-invite-min-score', '--attention-min-score', '--enabled', '--booking-link', '--grader-model', '--out', '--record-sent-at', '--followup-enabled', '--followup-after-days', '--takehome-window-hours'];
|
|
11
11
|
const values = parseNamedArgs(args, { allowedFlags: [...valueFlags, '--json', '--now', '--force'], valueFlags });
|
|
12
12
|
const positionals = [...values.positionals];
|
|
13
13
|
// `takehome show` and `settings set` are two words a person types and one
|
|
@@ -48,6 +48,19 @@ export function parseCareersArgs(args) {
|
|
|
48
48
|
const enabledRaw = optionalNonEmpty(values.flags.get('--enabled'));
|
|
49
49
|
if (enabledRaw !== undefined && enabledRaw !== 'true' && enabledRaw !== 'false')
|
|
50
50
|
throw new Error('--enabled must be true or false.');
|
|
51
|
+
// BLI-12183: the second-chance follow-up. `--record-sent-at` records one sent
|
|
52
|
+
// outside Tower and sends nothing; the three settings flags are whole numbers
|
|
53
|
+
// and a boolean, refused here as well as on the door.
|
|
54
|
+
const recordSentAt = optionalNonEmpty(values.flags.get('--record-sent-at'));
|
|
55
|
+
if (recordSentAt !== undefined && verb !== 'followup')
|
|
56
|
+
throw new Error('--record-sent-at belongs to `cockpit careers followup <id>`.');
|
|
57
|
+
if (recordSentAt !== undefined && !Number.isFinite(Date.parse(recordSentAt)))
|
|
58
|
+
throw new Error('--record-sent-at must be an ISO date-time, e.g. 2026-10-03T08:00:00Z.');
|
|
59
|
+
const followupEnabledRaw = optionalNonEmpty(values.flags.get('--followup-enabled'));
|
|
60
|
+
if (followupEnabledRaw !== undefined && followupEnabledRaw !== 'true' && followupEnabledRaw !== 'false')
|
|
61
|
+
throw new Error('--followup-enabled must be true or false.');
|
|
62
|
+
const followupAfterDays = wholeFlag(values.flags.get('--followup-after-days'), '--followup-after-days', 1, 60);
|
|
63
|
+
const takehomeWindowHours = wholeFlag(values.flags.get('--takehome-window-hours'), '--takehome-window-hours', 1, 720);
|
|
51
64
|
// The resume is the only thing this noun writes to disk, so both file flags
|
|
52
65
|
// belong to it alone and say so rather than being accepted and ignored.
|
|
53
66
|
const outPath = optionalNonEmpty(values.flags.get('--out'));
|
|
@@ -71,6 +84,9 @@ export function parseCareersArgs(args) {
|
|
|
71
84
|
graderModel: optionalNonEmpty(values.flags.get('--grader-model')),
|
|
72
85
|
now: values.booleans.has('--now'),
|
|
73
86
|
outPath, force,
|
|
87
|
+
recordSentAt: recordSentAt ? new Date(recordSentAt).toISOString() : undefined,
|
|
88
|
+
followupEnabled: followupEnabledRaw === undefined ? undefined : followupEnabledRaw === 'true',
|
|
89
|
+
followupAfterDays, takehomeWindowHours,
|
|
74
90
|
autoInviteMinScore, attentionMinScore,
|
|
75
91
|
autoInviteEnabled: enabledRaw === undefined ? undefined : enabledRaw === 'true',
|
|
76
92
|
homeDir: optionalNonEmpty(values.flags.get('--home')), dashboardUrl: optionalUrl(values.flags.get('--dashboard-url')), json: values.booleans.has('--json'),
|
|
@@ -96,6 +112,14 @@ function httpsFlag(raw) {
|
|
|
96
112
|
throw new Error('--booking-link must be an https URL.');
|
|
97
113
|
return value;
|
|
98
114
|
}
|
|
115
|
+
function wholeFlag(raw, flag, min, max) {
|
|
116
|
+
if (raw === undefined)
|
|
117
|
+
return undefined;
|
|
118
|
+
const value = Number(raw);
|
|
119
|
+
if (!Number.isInteger(value) || value < min || value > max)
|
|
120
|
+
throw new Error(`${flag} must be a whole number, ${min} to ${max}.`);
|
|
121
|
+
return value;
|
|
122
|
+
}
|
|
99
123
|
function numberFlag(raw, flag, min, max) {
|
|
100
124
|
if (raw === undefined)
|
|
101
125
|
return undefined;
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { parseRootsArgs } from "./roots.js";
|
|
19
19
|
import { SURFACE_ALIASES } from "./local-help.js";
|
|
20
|
-
import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseCleanArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
|
|
20
|
+
import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseCleanArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseSourcesArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
|
|
21
21
|
import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseCalArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseModelsArgs, parseScoutArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, parseUsageArgs, parseCareersArgs, } from "./local-args-tower.js";
|
|
22
22
|
// `normalizeUrl` has always been part of this module's surface — `local.ts` and
|
|
23
23
|
// `local-auth.ts` import it from here — so it stays exported from this address
|
|
@@ -107,6 +107,8 @@ export function parseLocalArgs(argv) {
|
|
|
107
107
|
return parseMemoryArgs(argv.slice(1));
|
|
108
108
|
case "clean":
|
|
109
109
|
return parseCleanArgs(argv.slice(1));
|
|
110
|
+
case "sources":
|
|
111
|
+
return parseSourcesArgs(argv.slice(1));
|
|
110
112
|
case "docs":
|
|
111
113
|
return parseDocsArgs(argv.slice(1));
|
|
112
114
|
case "msg":
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* onboard. Split out of commands/local.ts (BLI-3104); moved verbatim.
|
|
9
9
|
*/
|
|
10
10
|
import { execFile } from "node:child_process";
|
|
11
|
-
import { errorMessage, isInteractiveStdin, readLine, writeLine,
|
|
11
|
+
import { errorMessage, isInteractiveStdin, readLine, writeLine, settleYesNo, } from "./cli-io.js";
|
|
12
12
|
import { normalizeUrl } from "./local-args.js";
|
|
13
13
|
import { getCollectorRuntimePaths, pairLocalCollector, readLocalCollectorSessionFile, readLocalSessionReference, } from "../local-state.js";
|
|
14
14
|
const OTP_CODE_PROMPT = [
|
|
@@ -63,7 +63,8 @@ export async function resolveOnboardEmail(command, roots, config, io) {
|
|
|
63
63
|
const typedEmail = answer.split(/\s+/u).find((part) => part.includes("@"));
|
|
64
64
|
if (typedEmail)
|
|
65
65
|
return typedEmail;
|
|
66
|
-
|
|
66
|
+
const pairPrompt = `Use ${inferred.email} for Tower pairing? [Y/n] `;
|
|
67
|
+
if (await settleYesNo(io, answer, pairPrompt, { emptyIsYes: true, reask: true })) {
|
|
67
68
|
return inferred.email;
|
|
68
69
|
}
|
|
69
70
|
return promptOnboardEmail(io);
|
|
@@ -25,12 +25,14 @@ export const TOWER_COMMAND_HELP = [
|
|
|
25
25
|
[
|
|
26
26
|
"careers",
|
|
27
27
|
[
|
|
28
|
-
"Usage: cockpit careers [list|show <id>|rescreen <id>|send-takehome <id>|decide <id> --decision <d>|grade <id>|resume <id>|takehome show <role>|takehome set <role>|settings show|settings set] [flags]",
|
|
28
|
+
"Usage: cockpit careers [list|show <id>|rescreen <id>|send-takehome <id>|decide <id> --decision <d>|followup <id>|grade <id>|resume <id>|takehome show <role>|takehome set <role>|settings show|settings set] [flags]",
|
|
29
29
|
"",
|
|
30
30
|
"Super-admin application review and pipeline. Lists at most 100 matches with total and has_more; use filters to narrow.",
|
|
31
31
|
"list [--role <slug>] [--min-score <n>] [--since <date>] [--stage <screened|invited|submitted|graded|booking_sent|passed_on|archived>] — the board, newest first, with the two thresholds in force.",
|
|
32
32
|
"send-takehome <id> (also `invite <id>`): sends that role's take-home from the configured mailbox, whatever the screening scored. The same act as Tower's Send take-home button, and only a row still at `screened`.",
|
|
33
33
|
"decide <id> --decision <passed_on|archived|reopen> — your own call on a row; `reopen` puts it back to `screened`.",
|
|
34
|
+
"followup <id> [--record-sent-at <iso>]: sends the one second-chance email now, in the take-home thread (the Send follow-up button), or with --record-sent-at records one you sent by hand and sends nothing (Already followed up). A second follow-up is refused. list and show carry each row's deadline label: overdue, second_chance, no_submission or incomplete.",
|
|
35
|
+
"settings set also takes --followup-enabled <true|false> (default false), --followup-after-days <1-60> (default 7) and --takehome-window-hours <1-720> (default 72).",
|
|
34
36
|
"grade <id> [--now]: puts one submission back in the take-home grader's queue. The grader runs on Railway every 5 minutes; --now says so rather than pretending the run happened here.",
|
|
35
37
|
"resume <id> [--out <path>] [--force]: the candidate's resume, the same file the drawer's Open resume button shows. Bare, it prints a signed link that dies in 60 seconds; --out downloads the file to that path and refuses to overwrite one already there unless you pass --force.",
|
|
36
38
|
"takehome show <role> — the subject, body, link and the reviewer's rubric for that role.",
|
|
@@ -366,6 +366,20 @@ export function localSubcommandHelp(command) {
|
|
|
366
366
|
"laptop that has been off.",
|
|
367
367
|
],
|
|
368
368
|
],
|
|
369
|
+
[
|
|
370
|
+
"sources",
|
|
371
|
+
[
|
|
372
|
+
"Usage: cockpit sources check [--older-than <days>] [--dashboard-url <url>] [--json]",
|
|
373
|
+
"",
|
|
374
|
+
"Answers, for every Codex and Claude transcript on this machine, whether Tower",
|
|
375
|
+
"holds it: stored in full, stored in part, or not stored, with the reason.",
|
|
376
|
+
"--older-than <days> checks only files not modified in that many days.",
|
|
377
|
+
"--json lists every file with its path, verdict and reason.",
|
|
378
|
+
"Read-only. Tower never deletes these files; deleting one is your own act.",
|
|
379
|
+
"Before deleting any transcript by hand, run this and delete only files it",
|
|
380
|
+
"answers \"stored in full\". See docs/runbooks/freeing-disk-on-a-collector-machine.md.",
|
|
381
|
+
],
|
|
382
|
+
],
|
|
369
383
|
[
|
|
370
384
|
"release",
|
|
371
385
|
[
|
|
@@ -65,6 +65,7 @@ export const rootCommandNames = new Set([
|
|
|
65
65
|
"agent-rules",
|
|
66
66
|
"memory",
|
|
67
67
|
"clean",
|
|
68
|
+
"sources",
|
|
68
69
|
"docs",
|
|
69
70
|
"msg",
|
|
70
71
|
"issue",
|
|
@@ -117,6 +118,7 @@ export function localCommandHelp(command) {
|
|
|
117
118
|
" cockpit agent-rules [install|uninstall|status] [--host codex|claude|all] [--workspace <path>] [--json]",
|
|
118
119
|
" cockpit memory [search \"<question>\"|save|update <id>|forget <id>|profile|install|status|log <win|loss|noise> --store <bli|supermemory|both> \"<reason>\"] [--container <tag>] [--limit <n>] [--no-recent] [--custom-id <id>] [--extract] [--content-stdin] [--reason <label>] [--file <path>] [--dashboard-url <url>] [--dry-run] [--json]",
|
|
119
120
|
" cockpit clean [--dry-run] [--all-committed] [--reconcile] [--dashboard-url <url>] [--json]",
|
|
121
|
+
" cockpit sources check [--older-than <days>] [--dashboard-url <url>] [--json]",
|
|
120
122
|
" cockpit docs [list|tree|read <id|slug>|create --title <t>|update <id>|delete <id|slug>] [--parent <id>|root|--clear-parent] [--query <text> [--word]] [--limit <n>] [--visibility org|private] [--file <path>|--body-stdin] [--allow-empty] [--dashboard-url <url>] [--json]",
|
|
121
123
|
" cockpit msg [channels|create <name>|dm <email>|read <channel>|send <channel>|thread <id> --channel <channel>] [--private] [--members a@x,b@y] [--description <text>] [--thread <id>] [--limit <n>] [--dashboard-url <url>] [--json]",
|
|
122
124
|
" cockpit issue [list|show <BLI-id>|create --title <t>|update <BLI-id>|move <BLI-id> <state>|comment <BLI-id>|history <BLI-id>] [--state <s>] [--assignee me|unassigned|<uuid>] [--project <name|id>] [--limit <n>] [--priority 0-4] [--size S|M|L] [--parent <BLI-id>] [--file <path>|--body-stdin] [--dashboard-url <url>] [--json]",
|
|
@@ -125,7 +127,7 @@ export function localCommandHelp(command) {
|
|
|
125
127
|
" cockpit project [list] [--archived] [--dashboard-url <url>] [--json]",
|
|
126
128
|
` cockpit search "<words>" [--kind ${SEARCH_KINDS.join(",")}] [--limit <n>] [--dashboard-url <url>] [--json]`,
|
|
127
129
|
" cockpit release [--dry-run] [--skip-checks] [--no-floor] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
|
|
128
|
-
" cockpit careers [list|show <id>|rescreen <id>|send-takehome <id>|decide <id> --decision <passed_on|archived|reopen>|grade <id> [--now]|resume <id> [--out <path>]|takehome show <role>|takehome set <role> --body-file <path>|settings show|settings set --enabled <true|false>] [--role <slug>] [--min-score <n>] [--since <date>] [--stage <s>] [--force] [--json]",
|
|
130
|
+
" cockpit careers [list|show <id>|rescreen <id>|send-takehome <id>|decide <id> --decision <passed_on|archived|reopen>|followup <id> [--record-sent-at <iso>]|grade <id> [--now]|resume <id> [--out <path>]|takehome show <role>|takehome set <role> --body-file <path>|settings show|settings set --enabled <true|false> --followup-enabled <true|false> --followup-after-days <n> --takehome-window-hours <n>] [--role <slug>] [--min-score <n>] [--since <date>] [--stage <s>] [--force] [--json]",
|
|
129
131
|
" cockpit usage sessions [--task <BLI-NNNN> | --subject <name>] [--repo <label>] [--since <window>] [--json]",
|
|
130
132
|
" cockpit usage sessions --session <id> [--from-window <n>] [--json]",
|
|
131
133
|
" --by-topic groups work types; --by-subject groups open subjects from session summaries.",
|
package/dist/commands/local.js
CHANGED
|
@@ -37,6 +37,7 @@ import { runMemoryLog } from "./memory-log.js";
|
|
|
37
37
|
import { runMemoryInstall } from "./memory-install.js";
|
|
38
38
|
import { runMemoryStore, runMemoryVerbList } from "./memory-store.js";
|
|
39
39
|
import { runClean } from "./clean.js";
|
|
40
|
+
import { runSourcesCheck } from "./sources-check.js";
|
|
40
41
|
import { runDocs } from "./docs.js";
|
|
41
42
|
import { runMsg } from "./msg.js";
|
|
42
43
|
import { runIssue } from "./issue.js";
|
|
@@ -213,6 +214,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
|
|
|
213
214
|
return await runMemoryStore(command, io);
|
|
214
215
|
case "clean":
|
|
215
216
|
return await runClean(command, io);
|
|
217
|
+
case "sources":
|
|
218
|
+
return await runSourcesCheck(command, io);
|
|
216
219
|
case "docs":
|
|
217
220
|
return await runDocs(command, io);
|
|
218
221
|
case "msg":
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* sharing it and taking it back, and moving it to another shelf. Sibling of
|
|
4
4
|
* `notes.ts`, named in its header.
|
|
5
5
|
*/
|
|
6
|
-
import { isInteractiveStdin,
|
|
6
|
+
import { isInteractiveStdin, readPipedText, writeLine, askYesNo } from "./cli-io.js";
|
|
7
7
|
import { NOTE_SLOW_UPLOAD_BYTES, noteAudioMime, decodeTextBytes, noteFileRefusalSentence, readNoteFile, } from "./notes-file.js";
|
|
8
8
|
import { ask, emit, fail, sayUpload, errorText, TAG, READ_DEADLINE_MS } from "./notes-door.js";
|
|
9
9
|
/**
|
|
@@ -165,8 +165,8 @@ export async function shareNote(command, door) {
|
|
|
165
165
|
if (!isInteractiveStdin(door.io)) {
|
|
166
166
|
return fail(door, "confirmation_required", "Sharing a note lets everyone signed in read it. Pass --yes to do it without being asked.");
|
|
167
167
|
}
|
|
168
|
-
const
|
|
169
|
-
if (!
|
|
168
|
+
const shareYes = await askYesNo(door.io, "Share this note with everyone signed in? [Y/n] ");
|
|
169
|
+
if (!shareYes) {
|
|
170
170
|
writeLine(door.io.stderr, `${TAG} share declined ${JSON.stringify({ reason: "operator_said_no" })}`);
|
|
171
171
|
writeLine(door.io.stdout, "Left alone. Nothing changed.");
|
|
172
172
|
return 0;
|
|
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
if (command === "--version" || command === "-V" || command === "version") {
|
|
24
|
-
writeLine(io?.stdout ?? process.stdout, "0.2.
|
|
24
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.160");
|
|
25
25
|
return 0;
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* that takes one. New content arrives on stdin and is forwarded without ever
|
|
20
20
|
* being written to stdout, stderr, or an argument vector.
|
|
21
21
|
*/
|
|
22
|
-
import { isInteractiveStdin, readLine,
|
|
22
|
+
import { isInteractiveStdin, readLine, settleYesNo, writeLine } from "./cli-io.js";
|
|
23
23
|
import { cacheAgeLabel, readThroughCache } from "./read-cache.js";
|
|
24
24
|
import { asList, asRecord, callTower, isForbidden, openTower, parseModelKey, readAllStdin, writeCommandFailure, } from "./tower-command.js";
|
|
25
25
|
import { showOverview } from "./settings-overview.js";
|
|
@@ -374,8 +374,9 @@ export async function confirmDestructive(command, io, prompt, label) {
|
|
|
374
374
|
};
|
|
375
375
|
}
|
|
376
376
|
const answer = await readLine(io, prompt);
|
|
377
|
-
if (
|
|
377
|
+
if (await settleYesNo(io, answer, prompt, { emptyIsYes: false, reask: false })) {
|
|
378
378
|
return { ok: true };
|
|
379
|
+
}
|
|
379
380
|
return {
|
|
380
381
|
ok: false,
|
|
381
382
|
reason: "declined",
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { writeLine } from "./cli-io.js";
|
|
2
|
+
import { CLAUDE_CURSOR_FILENAME, readRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
|
|
3
|
+
import { fetchTranscriptObjects } from "../evidence-transcripts-client.js";
|
|
4
|
+
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, } from "../local-state.js";
|
|
5
|
+
import { discoverLocalTranscripts } from "./sources-discovery.js";
|
|
6
|
+
import { fileFacts, judgeSource } from "./sources-verdict.js";
|
|
7
|
+
const VERDICTS = ["stored_full", "stored_partial", "not_stored"];
|
|
8
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
9
|
+
export async function runSourcesCheck(command, io, now = new Date()) {
|
|
10
|
+
const startedAt = Date.now();
|
|
11
|
+
const paths = getCollectorRuntimePaths(command.homeDir);
|
|
12
|
+
const config = await readLocalCollectorConfig(paths).catch(() => null);
|
|
13
|
+
const dashboardUrl = command.dashboardUrl ?? config?.dashboard_url ?? DEFAULT_DASHBOARD_URL;
|
|
14
|
+
const session = await readLocalCollectorSessionFile(paths).catch(() => null);
|
|
15
|
+
if (!session || session.session_state !== "valid" || typeof session.device_token !== "string" || !session.device_token) {
|
|
16
|
+
console.error("[collector sources] check not run", JSON.stringify({ reason: "collector_not_paired" }));
|
|
17
|
+
return fail(io, command, "collector_not_paired", "This machine is not signed in to Tower, so Tower cannot be asked. Run `cockpit doctor`.");
|
|
18
|
+
}
|
|
19
|
+
const remote = await fetchTranscriptObjects({ dashboardUrl, deviceToken: session.device_token, fetchImpl: io.fetch });
|
|
20
|
+
if (remote.status === "unavailable") {
|
|
21
|
+
console.error("[collector sources] check not answered", JSON.stringify({ reason: `transcript_door_unavailable:${remote.reason}` }));
|
|
22
|
+
return fail(io, command, `transcript_door_unavailable:${remote.reason}`, `Tower could not be asked which transcripts it holds (${remote.reason}). No file is answered; nothing here means "not stored".`);
|
|
23
|
+
}
|
|
24
|
+
const homeDir = command.homeDir ?? paths.home_dir;
|
|
25
|
+
const olderThanMs = command.olderThanDays === null ? null : now.getTime() - command.olderThanDays * DAY_MS;
|
|
26
|
+
const local = await discoverLocalTranscripts({ homeDir, olderThanMs });
|
|
27
|
+
const cursors = {
|
|
28
|
+
codex: await readRawEvidenceCursor(paths),
|
|
29
|
+
claude_code: await readRawEvidenceCursor(paths, { filename: CLAUDE_CURSOR_FILENAME, sessionsOnly: true }),
|
|
30
|
+
};
|
|
31
|
+
const report = emptyReport(command, remote, local.newer_count, local.file_read_failed + local.directory_read_failed);
|
|
32
|
+
for (const file of local.files) {
|
|
33
|
+
const judged = await judgeSource({
|
|
34
|
+
file,
|
|
35
|
+
remotes: remote.files.get(file.key) ?? [],
|
|
36
|
+
cursorEntry: file.kind === "claude_subagent" ? null : (cursors[file.source].sessions[file.session_id] ?? null),
|
|
37
|
+
facts: fileFacts(file),
|
|
38
|
+
}).catch((error) => {
|
|
39
|
+
report.local.unreadable += 1;
|
|
40
|
+
console.error("[collector sources] a transcript could not be judged; left out of the answer", JSON.stringify({ reason: "file_unreadable", kind: file.kind, byte_size: file.byte_size, error_name: error?.name ?? "Error" }));
|
|
41
|
+
return null;
|
|
42
|
+
});
|
|
43
|
+
if (!judged)
|
|
44
|
+
continue;
|
|
45
|
+
report.local.examined += 1;
|
|
46
|
+
report.counts[judged.verdict].files += 1;
|
|
47
|
+
report.counts[judged.verdict].bytes += file.byte_size;
|
|
48
|
+
report.reasons[judged.reason] = (report.reasons[judged.reason] ?? 0) + 1;
|
|
49
|
+
report.files.push({
|
|
50
|
+
path: file.file_path,
|
|
51
|
+
source: file.source,
|
|
52
|
+
kind: file.kind,
|
|
53
|
+
session_id: file.session_id,
|
|
54
|
+
byte_size: file.byte_size,
|
|
55
|
+
modified_at: new Date(file.mtime_ms).toISOString(),
|
|
56
|
+
verdict: judged.verdict,
|
|
57
|
+
reason: judged.reason,
|
|
58
|
+
remote_byte_size: judged.remote_byte_size,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
report.files.sort((a, b) => VERDICTS.indexOf(a.verdict) - VERDICTS.indexOf(b.verdict) || a.path.localeCompare(b.path));
|
|
62
|
+
// Counts and bytes only: never a path, a session id or a byte of content.
|
|
63
|
+
console.error("[collector sources] check", JSON.stringify({
|
|
64
|
+
reason: `sources_check_${report.status}`,
|
|
65
|
+
older_than_days: report.older_than_days,
|
|
66
|
+
remote_status: remote.status,
|
|
67
|
+
remote_unique: remote.unique,
|
|
68
|
+
examined: report.local.examined,
|
|
69
|
+
newer_skipped: report.local.newer_skipped,
|
|
70
|
+
unreadable: report.local.unreadable,
|
|
71
|
+
stored_full: report.counts.stored_full,
|
|
72
|
+
stored_partial: report.counts.stored_partial,
|
|
73
|
+
not_stored: report.counts.not_stored,
|
|
74
|
+
reasons: report.reasons,
|
|
75
|
+
elapsed_ms: Date.now() - startedAt,
|
|
76
|
+
}));
|
|
77
|
+
if (command.json)
|
|
78
|
+
writeLine(io.stdout, JSON.stringify(report, null, 2));
|
|
79
|
+
else
|
|
80
|
+
for (const line of sourcesCheckLines(report))
|
|
81
|
+
writeLine(io.stdout, line);
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
function emptyReport(command, remote, newerSkipped, unreadable) {
|
|
85
|
+
const { files, ...remoteCounts } = remote;
|
|
86
|
+
return {
|
|
87
|
+
status: remote.status === "exhausted" ? "complete" : "incomplete",
|
|
88
|
+
reason: remote.status === "exhausted" ? "ok" : `transcript_list_${remote.status}${remote.reason === "ok" ? "" : `:${remote.reason}`}`,
|
|
89
|
+
older_than_days: command.olderThanDays,
|
|
90
|
+
remote: { ...remoteCounts, file_count: files.size },
|
|
91
|
+
local: { examined: 0, newer_skipped: newerSkipped, unreadable },
|
|
92
|
+
counts: {
|
|
93
|
+
stored_full: { files: 0, bytes: 0 },
|
|
94
|
+
stored_partial: { files: 0, bytes: 0 },
|
|
95
|
+
not_stored: { files: 0, bytes: 0 },
|
|
96
|
+
},
|
|
97
|
+
reasons: {},
|
|
98
|
+
files: [],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function fail(io, command, reason, sentence) {
|
|
102
|
+
if (command.json) {
|
|
103
|
+
writeLine(io.stdout, JSON.stringify({ status: "unavailable", reason, older_than_days: command.olderThanDays }, null, 2));
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
writeLine(io.stderr, sentence);
|
|
107
|
+
}
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
/** What a person reads: counts and bytes per verdict, then why. Paths only with --json. */
|
|
111
|
+
export function sourcesCheckLines(report) {
|
|
112
|
+
const scope = report.older_than_days === null ? "every transcript" : `transcripts not modified in ${report.older_than_days} days`;
|
|
113
|
+
const lines = [
|
|
114
|
+
`Tower sources check: ${scope} on this machine`,
|
|
115
|
+
` stored in full ${report.counts.stored_full.files} file(s), ${mb(report.counts.stored_full.bytes)} MB`,
|
|
116
|
+
` stored in part ${report.counts.stored_partial.files} file(s), ${mb(report.counts.stored_partial.bytes)} MB`,
|
|
117
|
+
` not stored ${report.counts.not_stored.files} file(s), ${mb(report.counts.not_stored.bytes)} MB`,
|
|
118
|
+
];
|
|
119
|
+
const reasons = Object.entries(report.reasons).sort((a, b) => b[1] - a[1]);
|
|
120
|
+
if (reasons.length > 0)
|
|
121
|
+
lines.push(` why ${reasons.map(([reason, count]) => `${reason} ${count}`).join(", ")}`);
|
|
122
|
+
if (report.local.newer_skipped > 0)
|
|
123
|
+
lines.push(` newer ${report.local.newer_skipped} file(s) modified inside the window, not checked`);
|
|
124
|
+
if (report.local.unreadable > 0)
|
|
125
|
+
lines.push(` unreadable ${report.local.unreadable} file(s) could not be read and are not answered`);
|
|
126
|
+
lines.push(` Tower's list ${report.remote.status}: ${report.remote.unique} current transcript object(s) over ${report.remote.pages} page(s)`);
|
|
127
|
+
if (report.status === "incomplete") {
|
|
128
|
+
lines.push(` INCOMPLETE Tower's list was cut short (${report.reason}); "not stored" and "stored in part" may be understated. Run it again.`);
|
|
129
|
+
}
|
|
130
|
+
lines.push("Tower never deletes these files. `--json` lists every file with its path and verdict. Only a \"stored in full\" file is safe to delete by your own hand; see docs/runbooks/freeing-disk-on-a-collector-machine.md.");
|
|
131
|
+
return lines;
|
|
132
|
+
}
|
|
133
|
+
function mb(bytes) {
|
|
134
|
+
return (bytes / (1024 * 1024)).toFixed(1);
|
|
135
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every agent transcript on this machine, named the way the collector names
|
|
3
|
+
* its upload (BLI-11122).
|
|
4
|
+
*
|
|
5
|
+
* `cockpit sources check` has to pair each local file with the object Tower
|
|
6
|
+
* holds for it, and an object key carries the session id the collector read
|
|
7
|
+
* from the transcript's CONTENT, not its file name (a resumed or forked Codex
|
|
8
|
+
* rollout is filed under one id and names another; see
|
|
9
|
+
* `backfill-stub-locate.ts`). So each file is named by the same rules the
|
|
10
|
+
* readers use, kept here as pure functions so both path layouts (macOS and
|
|
11
|
+
* Windows) are testable without either file system:
|
|
12
|
+
*
|
|
13
|
+
* - Codex (`~/.codex/sessions/**`, `~/.codex/archived_sessions/**`): the first
|
|
14
|
+
* `session_meta` record's `payload.id`, else the uuid in the file name, else
|
|
15
|
+
* a hash of the path (`codex-attribution.ts`).
|
|
16
|
+
* - Claude main (`~/.claude/projects/<project>/<uuid>.jsonl`): the first
|
|
17
|
+
* record's `sessionId`, else the file name's uuid (`claude-attribution-score.ts`).
|
|
18
|
+
* - Claude subagent (`<project>/<uuid>/subagents/agent-*.jsonl`): the main's
|
|
19
|
+
* session id plus the file stem (`raw-evidence-claude-reader.ts`).
|
|
20
|
+
*
|
|
21
|
+
* This module reads files and never writes, moves or deletes one.
|
|
22
|
+
*/
|
|
23
|
+
import fs from "node:fs/promises";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { createReadStream } from "node:fs";
|
|
26
|
+
import readline from "node:readline";
|
|
27
|
+
import { transcriptFileKey } from "@bli-cockpit/telemetry-core";
|
|
28
|
+
import { SESSION_FILE_UUID_PATTERN, sanitizeSessionId, sessionIdFromFileName, shortHash, } from "../adapters/attribution-core.js";
|
|
29
|
+
import { isSecretLikePath, safeKeySegment } from "../adapters/raw-evidence-keys.js";
|
|
30
|
+
import { describeError } from "../health-detail.js";
|
|
31
|
+
export function sourcesRoots(homeDir, pathApi = path) {
|
|
32
|
+
return {
|
|
33
|
+
codex: [
|
|
34
|
+
pathApi.join(homeDir, ".codex", "sessions"),
|
|
35
|
+
pathApi.join(homeDir, ".codex", "archived_sessions"),
|
|
36
|
+
],
|
|
37
|
+
claudeProjects: pathApi.join(homeDir, ".claude", "projects"),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** Which kind of transcript a path is, or null for anything the collector does not upload as one. */
|
|
41
|
+
export function classifyTranscriptPath(filePath, roots, pathApi = path) {
|
|
42
|
+
if (!filePath.toLowerCase().endsWith(".jsonl"))
|
|
43
|
+
return null;
|
|
44
|
+
for (const root of roots.codex) {
|
|
45
|
+
const rel = pathApi.relative(root, filePath);
|
|
46
|
+
if (rel && !rel.startsWith("..") && !pathApi.isAbsolute(rel))
|
|
47
|
+
return { kind: "codex_rollout" };
|
|
48
|
+
}
|
|
49
|
+
const rel = pathApi.relative(roots.claudeProjects, filePath);
|
|
50
|
+
if (!rel || rel.startsWith("..") || pathApi.isAbsolute(rel))
|
|
51
|
+
return null;
|
|
52
|
+
const parts = rel.split(pathApi.sep);
|
|
53
|
+
if (parts.length === 2 && SESSION_FILE_UUID_PATTERN.test(parts[1]))
|
|
54
|
+
return { kind: "claude_main" };
|
|
55
|
+
if (parts.length === 4 && parts[2] === "subagents" && parts[3].startsWith("agent-")) {
|
|
56
|
+
return {
|
|
57
|
+
kind: "claude_subagent",
|
|
58
|
+
main_session_dir: parts[1],
|
|
59
|
+
sidecar_stem: parts[3].replace(/\.jsonl$/i, ""),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
/** The session id a reader would key this file's session by, from its content id when it has one. */
|
|
65
|
+
export function sessionIdFor(filePath, contentId, pathApi = path) {
|
|
66
|
+
const fromContent = sanitizeSessionId(contentId ?? undefined);
|
|
67
|
+
if (fromContent)
|
|
68
|
+
return { session_id: fromContent, session_id_source: "content" };
|
|
69
|
+
const fromName = sessionIdFromFileName(pathApi.basename(filePath));
|
|
70
|
+
if (fromName)
|
|
71
|
+
return { session_id: fromName, session_id_source: "file_name" };
|
|
72
|
+
return { session_id: shortHash(filePath), session_id_source: "path_hash" };
|
|
73
|
+
}
|
|
74
|
+
/** The key of the object a file would be uploaded as; see `raw-evidence-*-reader.ts`. */
|
|
75
|
+
export function transcriptKeyFor(source, sessionId, sidecarStem) {
|
|
76
|
+
const identity = {
|
|
77
|
+
source,
|
|
78
|
+
session_id: safeKeySegment(sessionId),
|
|
79
|
+
sidecar: sidecarStem === null ? null : isSecretLikePath(sidecarStem) ? "redacted-file-name" : safeKeySegment(sidecarStem),
|
|
80
|
+
};
|
|
81
|
+
return transcriptFileKey(identity);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The first content id in a transcript: Codex's first `session_meta`
|
|
85
|
+
* `payload.id`, Claude's first `sessionId`. Streams line by line and stops at
|
|
86
|
+
* the first hit; a line that does not parse is skipped, as the readers do.
|
|
87
|
+
*/
|
|
88
|
+
export async function readContentSessionId(filePath, source) {
|
|
89
|
+
const stream = createReadStream(filePath, { encoding: "utf8" });
|
|
90
|
+
const lines = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
91
|
+
try {
|
|
92
|
+
for await (const line of lines) {
|
|
93
|
+
const id = contentIdFromLine(line, source);
|
|
94
|
+
if (id)
|
|
95
|
+
return id;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
lines.close();
|
|
101
|
+
stream.destroy();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export function contentIdFromLine(line, source) {
|
|
105
|
+
if (!line.trim())
|
|
106
|
+
return null;
|
|
107
|
+
let record;
|
|
108
|
+
try {
|
|
109
|
+
record = JSON.parse(line);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
if (!record || typeof record !== "object" || Array.isArray(record))
|
|
115
|
+
return null;
|
|
116
|
+
const entry = record;
|
|
117
|
+
if (source === "claude_code")
|
|
118
|
+
return trimmed(entry["sessionId"]);
|
|
119
|
+
if (entry["type"] !== "session_meta")
|
|
120
|
+
return null;
|
|
121
|
+
const payload = entry["payload"];
|
|
122
|
+
if (!payload || typeof payload !== "object")
|
|
123
|
+
return null;
|
|
124
|
+
return trimmed(payload["id"]);
|
|
125
|
+
}
|
|
126
|
+
function trimmed(value) {
|
|
127
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Walk every root, name every transcript. `olderThanMs` keeps only files whose
|
|
131
|
+
* mtime is before it; the rest are counted, never read.
|
|
132
|
+
*/
|
|
133
|
+
export async function discoverLocalTranscripts(options) {
|
|
134
|
+
const roots = sourcesRoots(options.homeDir);
|
|
135
|
+
const result = { files: [], newer_count: 0, directory_read_failed: 0, file_read_failed: 0 };
|
|
136
|
+
const found = [];
|
|
137
|
+
let firstFailure = null;
|
|
138
|
+
const stack = [...roots.codex, roots.claudeProjects];
|
|
139
|
+
const seen = new Set();
|
|
140
|
+
while (stack.length > 0) {
|
|
141
|
+
const dir = stack.pop();
|
|
142
|
+
let entries;
|
|
143
|
+
try {
|
|
144
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
if (error?.code !== "ENOENT") {
|
|
148
|
+
result.directory_read_failed += 1;
|
|
149
|
+
firstFailure ??= describeError(error);
|
|
150
|
+
}
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
for (const entry of entries) {
|
|
154
|
+
const full = path.join(dir, entry.name);
|
|
155
|
+
if (entry.isDirectory()) {
|
|
156
|
+
stack.push(full);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (!entry.isFile())
|
|
160
|
+
continue;
|
|
161
|
+
const shape = classifyTranscriptPath(full, roots);
|
|
162
|
+
if (!shape || seen.has(full))
|
|
163
|
+
continue;
|
|
164
|
+
seen.add(full);
|
|
165
|
+
const stat = await fs.stat(full).catch((error) => {
|
|
166
|
+
result.file_read_failed += 1;
|
|
167
|
+
firstFailure ??= describeError(error);
|
|
168
|
+
return null;
|
|
169
|
+
});
|
|
170
|
+
if (!stat)
|
|
171
|
+
continue;
|
|
172
|
+
if (options.olderThanMs !== null && stat.mtimeMs >= options.olderThanMs) {
|
|
173
|
+
result.newer_count += 1;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
found.push({ file: full, size: stat.size, mtimeMs: stat.mtimeMs, shape });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// Claude mains first, so a subagent file can take its main's content id.
|
|
180
|
+
const claudeMainIds = new Map();
|
|
181
|
+
found.sort((a, b) => shapeOrder(a.shape) - shapeOrder(b.shape) || a.file.localeCompare(b.file));
|
|
182
|
+
for (const item of found) {
|
|
183
|
+
const source = item.shape.kind === "codex_rollout" ? "codex" : "claude_code";
|
|
184
|
+
let contentId = null;
|
|
185
|
+
if (item.shape.kind !== "claude_subagent") {
|
|
186
|
+
const read = await readContentSessionId(item.file, source).then((id) => ({ ok: true, id }), (error) => {
|
|
187
|
+
result.file_read_failed += 1;
|
|
188
|
+
firstFailure ??= describeError(error);
|
|
189
|
+
return { ok: false, id: null };
|
|
190
|
+
});
|
|
191
|
+
if (!read.ok)
|
|
192
|
+
continue;
|
|
193
|
+
contentId = read.id;
|
|
194
|
+
}
|
|
195
|
+
let named;
|
|
196
|
+
if (item.shape.kind === "claude_subagent") {
|
|
197
|
+
// The reader keys a subagent file under its MAIN session's id.
|
|
198
|
+
const mainPath = path.join(path.dirname(path.dirname(path.dirname(item.file))), `${item.shape.main_session_dir}.jsonl`);
|
|
199
|
+
const known = claudeMainIds.get(mainPath);
|
|
200
|
+
named = known
|
|
201
|
+
? { session_id: known, session_id_source: "content" }
|
|
202
|
+
: sessionIdFor(mainPath, await readContentSessionId(mainPath, "claude_code").catch(() => null));
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
named = sessionIdFor(item.file, contentId);
|
|
206
|
+
if (item.shape.kind === "claude_main")
|
|
207
|
+
claudeMainIds.set(item.file, named.session_id);
|
|
208
|
+
}
|
|
209
|
+
result.files.push({
|
|
210
|
+
source,
|
|
211
|
+
kind: item.shape.kind,
|
|
212
|
+
file_path: item.file,
|
|
213
|
+
byte_size: item.size,
|
|
214
|
+
mtime_ms: item.mtimeMs,
|
|
215
|
+
...named,
|
|
216
|
+
key: transcriptKeyFor(source, named.session_id, item.shape.kind === "claude_subagent" ? item.shape.sidecar_stem : null),
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
if (result.directory_read_failed > 0 || result.file_read_failed > 0) {
|
|
220
|
+
console.error("[collector sources] some transcripts could not be read; they are left out of the answer", JSON.stringify({
|
|
221
|
+
reason: "sources_discovery_incomplete",
|
|
222
|
+
directory_read_failed: result.directory_read_failed,
|
|
223
|
+
file_read_failed: result.file_read_failed,
|
|
224
|
+
found_count: result.files.length,
|
|
225
|
+
...(firstFailure ? { first_failure: firstFailure } : {}),
|
|
226
|
+
}));
|
|
227
|
+
}
|
|
228
|
+
return result;
|
|
229
|
+
}
|
|
230
|
+
function shapeOrder(shape) {
|
|
231
|
+
return shape.kind === "claude_main" ? 0 : shape.kind === "codex_rollout" ? 1 : 2;
|
|
232
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Does Tower hold this transcript, in full, in part, or not at all?
|
|
3
|
+
* (BLI-11122)
|
|
4
|
+
*
|
|
5
|
+
* One file, the current objects Tower lists for it, and this machine's cursor
|
|
6
|
+
* entry for its session. The rules, cheapest first; every answer names why:
|
|
7
|
+
*
|
|
8
|
+
* not_stored no current object for the file (`no_object`), or only the
|
|
9
|
+
* redaction-crash stub (`stub_only`): a stub holds no byte.
|
|
10
|
+
* stored_full the cursor's uploaded object is still current, the file
|
|
11
|
+
* is the size it was when uploaded and its hash is the one the
|
|
12
|
+
* cursor last recorded (`unchanged_since_upload`); or the
|
|
13
|
+
* file, redacted exactly as the upload redacts it, has the
|
|
14
|
+
* hash of a current object (`content_hash_match`) or is no
|
|
15
|
+
* larger than one (`remote_at_least_sanitized_size`).
|
|
16
|
+
* stored_partial a current object exists but is smaller than the redacted
|
|
17
|
+
* file (`changed_since_upload` when the cursor saw the file
|
|
18
|
+
* grow after its upload, else `remote_smaller`), or its size
|
|
19
|
+
* is not recorded (`remote_size_unknown`).
|
|
20
|
+
*
|
|
21
|
+
* A file too large to redact in memory is compared by its raw size
|
|
22
|
+
* (`remote_at_least_file_size`). A file whose redaction would crash to the stub
|
|
23
|
+
* is compared the same way, never by the stub's 42 bytes.
|
|
24
|
+
*
|
|
25
|
+
* Read-only: this module reads the file and never writes, moves or deletes it.
|
|
26
|
+
*/
|
|
27
|
+
import crypto from "node:crypto";
|
|
28
|
+
import fs from "node:fs/promises";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
import { RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES, REDACTION_STUB_SHA256, } from "@bli-cockpit/telemetry-core";
|
|
31
|
+
import { sanitizeTextEvidenceForUpload } from "../adapters/raw-evidence-sanitize.js";
|
|
32
|
+
import { isSecretLikePath } from "../adapters/raw-evidence-keys.js";
|
|
33
|
+
export async function judgeSource(input) {
|
|
34
|
+
const { file, remotes, cursorEntry } = input;
|
|
35
|
+
if (remotes.length === 0)
|
|
36
|
+
return { verdict: "not_stored", reason: "no_object", remote_byte_size: null };
|
|
37
|
+
const held = remotes.filter((remote) => !remote.stub);
|
|
38
|
+
if (held.length === 0)
|
|
39
|
+
return { verdict: "not_stored", reason: "stub_only", remote_byte_size: null };
|
|
40
|
+
const sizes = held.map((remote) => remote.byte_size).filter((size) => size !== null);
|
|
41
|
+
const best = sizes.length > 0 ? Math.max(...sizes) : null;
|
|
42
|
+
const full = (reason) => ({ verdict: "stored_full", reason, remote_byte_size: best });
|
|
43
|
+
const partial = (reason) => ({
|
|
44
|
+
verdict: "stored_partial",
|
|
45
|
+
reason,
|
|
46
|
+
remote_byte_size: best,
|
|
47
|
+
});
|
|
48
|
+
if (cursorEntry &&
|
|
49
|
+
!cursorEntry.stubbed &&
|
|
50
|
+
cursorEntry.uploaded_object_key &&
|
|
51
|
+
held.some((remote) => remote.object_key === cursorEntry.uploaded_object_key) &&
|
|
52
|
+
cursorEntry.uploaded_byte_size === file.byte_size &&
|
|
53
|
+
cursorEntry.file_hash_sha256 &&
|
|
54
|
+
cursorEntry.file_hash_sha256 === (await input.facts.rawSha256())) {
|
|
55
|
+
return full("unchanged_since_upload");
|
|
56
|
+
}
|
|
57
|
+
const sanitized = await input.facts.sanitized();
|
|
58
|
+
if (sanitized) {
|
|
59
|
+
if (held.some((remote) => remote.content_hash_sha256 === sanitized.sha256))
|
|
60
|
+
return full("content_hash_match");
|
|
61
|
+
if (best === null)
|
|
62
|
+
return partial("remote_size_unknown");
|
|
63
|
+
if (best >= sanitized.byte_size)
|
|
64
|
+
return full("remote_at_least_sanitized_size");
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
if (best === null)
|
|
68
|
+
return partial("remote_size_unknown");
|
|
69
|
+
if (best >= file.byte_size)
|
|
70
|
+
return full("remote_at_least_file_size");
|
|
71
|
+
}
|
|
72
|
+
const grewAfterUpload = cursorEntry?.uploaded_object_key != null &&
|
|
73
|
+
cursorEntry.uploaded_byte_size != null &&
|
|
74
|
+
cursorEntry.uploaded_byte_size < file.byte_size;
|
|
75
|
+
return partial(grewAfterUpload ? "changed_since_upload" : "remote_smaller");
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Reads the file at most once, on first ask, and redacts it exactly as the
|
|
79
|
+
* upload does (`raw-evidence-transcript-file.ts`): same sanitizer, same
|
|
80
|
+
* secret-like-name rule, same byte cap.
|
|
81
|
+
*/
|
|
82
|
+
export function fileFacts(file, maxBytes = RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES) {
|
|
83
|
+
let bytes = null;
|
|
84
|
+
const read = () => {
|
|
85
|
+
bytes ??= file.byte_size > maxBytes ? Promise.resolve(null) : fs.readFile(file.file_path);
|
|
86
|
+
return bytes;
|
|
87
|
+
};
|
|
88
|
+
let rawHash = null;
|
|
89
|
+
let sanitized = null;
|
|
90
|
+
return {
|
|
91
|
+
rawSha256() {
|
|
92
|
+
rawHash ??= read().then(async (buffer) => (buffer ? sha256(buffer) : await streamSha256(file.file_path)));
|
|
93
|
+
return rawHash;
|
|
94
|
+
},
|
|
95
|
+
sanitized() {
|
|
96
|
+
sanitized ??= read().then((buffer) => {
|
|
97
|
+
if (!buffer)
|
|
98
|
+
return null;
|
|
99
|
+
const result = sanitizeTextEvidenceForUpload({
|
|
100
|
+
text: buffer.toString("utf8"),
|
|
101
|
+
originalBytes: buffer,
|
|
102
|
+
redactedFields: [`${file.kind === "codex_rollout" ? "codex_jsonl" : file.kind === "claude_main" ? "claude_jsonl" : "claude_jsonl_sidecar"}.body`],
|
|
103
|
+
secretLikeFileName: isSecretLikePath(path.basename(file.file_path)),
|
|
104
|
+
});
|
|
105
|
+
const hash = sha256(result.bytes);
|
|
106
|
+
if (hash === REDACTION_STUB_SHA256)
|
|
107
|
+
return null;
|
|
108
|
+
return { sha256: hash, byte_size: result.bytes.byteLength };
|
|
109
|
+
});
|
|
110
|
+
return sanitized;
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function sha256(buffer) {
|
|
115
|
+
return crypto.createHash("sha256").update(buffer).digest("hex");
|
|
116
|
+
}
|
|
117
|
+
async function streamSha256(filePath) {
|
|
118
|
+
const hash = crypto.createHash("sha256");
|
|
119
|
+
const handle = await fs.open(filePath, "r");
|
|
120
|
+
try {
|
|
121
|
+
for await (const chunk of handle.createReadStream())
|
|
122
|
+
hash.update(chunk);
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
await handle.close().catch(() => undefined);
|
|
126
|
+
}
|
|
127
|
+
return hash.digest("hex");
|
|
128
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asks `POST /api/ambient/evidence/transcripts` for every current transcript
|
|
3
|
+
* object this operator owns, and groups the answer by local transcript file
|
|
4
|
+
* (BLI-11122). `cockpit sources check` compares each group to the file on disk.
|
|
5
|
+
*
|
|
6
|
+
* Never throws. A door that cannot be asked (an older dashboard answers 404, a
|
|
7
|
+
* network error, a malformed page) returns `unavailable` with the reason and
|
|
8
|
+
* the check says so instead of guessing.
|
|
9
|
+
*
|
|
10
|
+
* Bounded pages of up to 500, walked by the server's keyset cursor, at most
|
|
11
|
+
* `maxPages` of them. The result says whether the list was exhausted, capped by
|
|
12
|
+
* `maxPages`, or cut short by a failed page after some landed, and carries the
|
|
13
|
+
* requested, returned and unique counts so completeness is a number, not a claim.
|
|
14
|
+
*/
|
|
15
|
+
import { EVIDENCE_TRANSCRIPTS_MAX_PAGE, EVIDENCE_TRANSCRIPTS_SCHEMA_VERSION, EvidenceTranscriptsResponseSchema, transcriptFileFromObjectKey, transcriptFileKey, } from "@bli-cockpit/telemetry-core";
|
|
16
|
+
import { describeError } from "./health-detail.js";
|
|
17
|
+
const TRANSCRIPTS_PAGE_TIMEOUT_MS = 30_000;
|
|
18
|
+
/** 400 pages of 500 is 200,000 current transcript objects: far past any operator today. */
|
|
19
|
+
export const TRANSCRIPTS_DEFAULT_MAX_PAGES = 400;
|
|
20
|
+
export async function fetchTranscriptObjects(options) {
|
|
21
|
+
const maxPages = options.maxPages ?? TRANSCRIPTS_DEFAULT_MAX_PAGES;
|
|
22
|
+
const pageSize = Math.min(options.pageSize ?? EVIDENCE_TRANSCRIPTS_MAX_PAGE, EVIDENCE_TRANSCRIPTS_MAX_PAGE);
|
|
23
|
+
const files = new Map();
|
|
24
|
+
const seen = new Set();
|
|
25
|
+
let returned = 0;
|
|
26
|
+
let unkeyed = 0;
|
|
27
|
+
let pages = 0;
|
|
28
|
+
let after = null;
|
|
29
|
+
for (;;) {
|
|
30
|
+
if (pages >= maxPages)
|
|
31
|
+
return finish("capped", "ok");
|
|
32
|
+
const page = await requestPage({ ...options, after, limit: pageSize });
|
|
33
|
+
if (!page.ok)
|
|
34
|
+
return finish(pages === 0 ? "unavailable" : "failed_partial", page.reason);
|
|
35
|
+
pages += 1;
|
|
36
|
+
for (const object of page.objects) {
|
|
37
|
+
returned += 1;
|
|
38
|
+
if (seen.has(object.ref_id))
|
|
39
|
+
continue;
|
|
40
|
+
seen.add(object.ref_id);
|
|
41
|
+
addObject(object);
|
|
42
|
+
}
|
|
43
|
+
// Advance by what the server returned; a page that names no cursor is the last.
|
|
44
|
+
if (!page.nextAfter)
|
|
45
|
+
return finish("exhausted", "ok");
|
|
46
|
+
after = page.nextAfter;
|
|
47
|
+
}
|
|
48
|
+
function addObject(object) {
|
|
49
|
+
const identity = transcriptFileFromObjectKey(object.object_key);
|
|
50
|
+
if (!identity) {
|
|
51
|
+
unkeyed += 1;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const key = transcriptFileKey(identity);
|
|
55
|
+
const list = files.get(key) ?? [];
|
|
56
|
+
list.push({
|
|
57
|
+
object_key: object.object_key,
|
|
58
|
+
byte_size: object.byte_size,
|
|
59
|
+
content_hash_sha256: object.content_hash_sha256,
|
|
60
|
+
stub: object.stub,
|
|
61
|
+
});
|
|
62
|
+
files.set(key, list);
|
|
63
|
+
}
|
|
64
|
+
function finish(status, reason) {
|
|
65
|
+
const result = {
|
|
66
|
+
status,
|
|
67
|
+
reason,
|
|
68
|
+
pages,
|
|
69
|
+
requested: pages * pageSize,
|
|
70
|
+
returned,
|
|
71
|
+
unique: seen.size,
|
|
72
|
+
unkeyed,
|
|
73
|
+
files,
|
|
74
|
+
};
|
|
75
|
+
console.error("[collector sources] transcript objects listed", JSON.stringify({
|
|
76
|
+
reason: status === "unavailable" ? `transcript_door_unavailable:${reason}` : `transcripts_${status}`,
|
|
77
|
+
pages,
|
|
78
|
+
requested: result.requested,
|
|
79
|
+
returned,
|
|
80
|
+
unique: seen.size,
|
|
81
|
+
unkeyed,
|
|
82
|
+
file_count: files.size,
|
|
83
|
+
...(status === "failed_partial" ? { failure: reason } : {}),
|
|
84
|
+
}));
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
async function requestPage(options) {
|
|
89
|
+
const controller = new AbortController();
|
|
90
|
+
const timeout = setTimeout(() => controller.abort(), TRANSCRIPTS_PAGE_TIMEOUT_MS);
|
|
91
|
+
try {
|
|
92
|
+
const response = await options.fetchImpl(`${options.dashboardUrl}/api/ambient/evidence/transcripts`, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
headers: {
|
|
95
|
+
"Content-Type": "application/json",
|
|
96
|
+
Authorization: `Bearer ${options.deviceToken}`,
|
|
97
|
+
},
|
|
98
|
+
body: JSON.stringify({
|
|
99
|
+
schema_version: EVIDENCE_TRANSCRIPTS_SCHEMA_VERSION,
|
|
100
|
+
after: options.after,
|
|
101
|
+
limit: options.limit,
|
|
102
|
+
}),
|
|
103
|
+
signal: controller.signal,
|
|
104
|
+
});
|
|
105
|
+
if (!response.ok)
|
|
106
|
+
return { ok: false, reason: `http_${response.status}` };
|
|
107
|
+
const parsed = EvidenceTranscriptsResponseSchema.safeParse(await response.json().catch(() => null));
|
|
108
|
+
if (!parsed.success)
|
|
109
|
+
return { ok: false, reason: "invalid_response" };
|
|
110
|
+
return { ok: true, objects: parsed.data.objects, nextAfter: parsed.data.next_after };
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
return { ok: false, reason: describeError(error).error_name };
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
clearTimeout(timeout);
|
|
117
|
+
}
|
|
118
|
+
}
|
package/dist/onboarding-roots.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
|
+
import { parseYesNo } from "./yes-no.js";
|
|
2
3
|
import { realpathSync } from "node:fs";
|
|
3
4
|
import os from "node:os";
|
|
4
5
|
import path from "node:path";
|
|
@@ -235,12 +236,11 @@ async function promptForHomeRootOptIn(options, rejections) {
|
|
|
235
236
|
// Anything that isn't recognisably yes still routes to the decline prompt,
|
|
236
237
|
// where a typed path is recoverable rather than lost.
|
|
237
238
|
function isHomeRootYes(raw) {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
return answer.startsWith("y");
|
|
239
|
+
// BLI-12189: "you know what, no" used to pass on its leading "y". Unclear
|
|
240
|
+
// routes to the decline prompt, where a typed path is recoverable.
|
|
241
|
+
const verdict = parseYesNo(raw, { emptyIsYes: true });
|
|
242
|
+
console.error(`[onboarding-roots] home root consent ${JSON.stringify({ verdict })}`);
|
|
243
|
+
return verdict === "yes";
|
|
244
244
|
}
|
|
245
245
|
async function promptForDeclinedHomeRoot(options, homeDir) {
|
|
246
246
|
const prompt = requirePrompt(options);
|
package/dist/yes-no.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE reading of a typed yes/no answer (BLI-12189).
|
|
3
|
+
*
|
|
4
|
+
* The old `yesByDefault` returned true unless the first word was exactly
|
|
5
|
+
* `n`/`no`, so "nope", "nah", "cancel", "don't" and "No thanks" confirmed a
|
|
6
|
+
* device revoke. Now only a recognised yes confirms; a no word or a leading
|
|
7
|
+
* "n" declines; everything else is `unclear` and the caller decides (a
|
|
8
|
+
* destructive prompt declines, a harmless one asks once more).
|
|
9
|
+
*
|
|
10
|
+
* Pure: no I/O, no logging. Callers log the outcome label.
|
|
11
|
+
*/
|
|
12
|
+
const YES_WORDS = new Set(["y", "yes", "yeah", "yep", "sure", "ok", "okay"]);
|
|
13
|
+
const NO_WORDS = new Set(["cancel", "stop", "don't", "dont", "never", "abort", "quit"]);
|
|
14
|
+
/**
|
|
15
|
+
* `emptyIsYes`: bare Enter means yes where the prompt shows `[Y/n]`; on a
|
|
16
|
+
* `[y/N]` (destructive) prompt it means no.
|
|
17
|
+
*/
|
|
18
|
+
export function parseYesNo(raw, options) {
|
|
19
|
+
const first = raw.trim().split(/\s+/u)[0]?.toLowerCase() ?? "";
|
|
20
|
+
if (first === "")
|
|
21
|
+
return options.emptyIsYes ? "yes" : "no";
|
|
22
|
+
const word = first.replace(/[^\p{L}\p{N}']+$/gu, "");
|
|
23
|
+
if (YES_WORDS.has(word))
|
|
24
|
+
return "yes";
|
|
25
|
+
if (NO_WORDS.has(word) || word.startsWith("n"))
|
|
26
|
+
return "no";
|
|
27
|
+
return "unclear";
|
|
28
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.160",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
"test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@bli-cockpit/memory-mcp": "0.1.
|
|
31
|
-
"@bli-cockpit/mcp": "0.1.
|
|
32
|
-
"@bli-cockpit/telemetry-core": "0.1.
|
|
30
|
+
"@bli-cockpit/memory-mcp": "0.1.44",
|
|
31
|
+
"@bli-cockpit/mcp": "0.1.78",
|
|
32
|
+
"@bli-cockpit/telemetry-core": "0.1.56"
|
|
33
33
|
}
|
|
34
34
|
}
|