@bli-cockpit/cli 0.2.286 → 0.2.288
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/capture-opt-outs.js +129 -0
- package/dist/collection-scope.js +201 -0
- package/dist/commands/agent-session-report.js +3 -0
- package/dist/commands/agreements-render.js +61 -0
- package/dist/commands/agreements.js +196 -0
- package/dist/commands/analyze.js +2 -0
- package/dist/commands/backfill-scan.js +16 -11
- package/dist/commands/backfill-scope.js +16 -4
- package/dist/commands/backfill-session-report.js +2 -0
- package/dist/commands/books-files.js +159 -0
- package/dist/commands/books-usage.js +14 -0
- package/dist/commands/books.js +214 -0
- package/dist/commands/cli-io.js +1 -3
- package/dist/commands/docs-share.js +21 -11
- package/dist/commands/doctor-access.js +7 -0
- package/dist/commands/doctor-registration.js +39 -0
- package/dist/commands/doctor-report.js +1 -0
- package/dist/commands/doctor-shelf.js +62 -0
- package/dist/commands/doctor-update.js +2 -0
- package/dist/commands/doctor-usage-telemetry.js +18 -2
- package/dist/commands/doctor.js +9 -0
- package/dist/commands/heartbeat-post.js +19 -0
- package/dist/commands/heartbeat.js +13 -2
- package/dist/commands/install-receipts.js +4 -0
- package/dist/commands/local-args-collector-setup.js +2 -0
- package/dist/commands/local-args-collector-status.js +7 -21
- package/dist/commands/local-args-tower-agreements.js +80 -0
- package/dist/commands/local-args-tower-docs-msg.js +17 -5
- package/dist/commands/local-args-tower.js +2 -0
- package/dist/commands/local-args.js +6 -1
- package/dist/commands/local-command-shapes-agreements.js +5 -0
- package/dist/commands/local-discovery.js +2 -2
- package/dist/commands/local-help-agreements.js +24 -0
- package/dist/commands/local-help-commands-tower-ops.js +3 -3
- package/dist/commands/local-help-commands-tower.js +13 -6
- package/dist/commands/local-help-commands.js +20 -5
- package/dist/commands/local-help.js +8 -2
- package/dist/commands/local.js +9 -0
- package/dist/commands/memory-capture-prompt.js +130 -0
- package/dist/commands/memory-install-claude.js +51 -4
- package/dist/commands/memory-install-report.js +19 -3
- package/dist/commands/memory-install-toml.js +22 -0
- package/dist/commands/memory-install.js +110 -11
- package/dist/commands/memory-store.js +5 -0
- package/dist/commands/memory-switch.js +165 -0
- package/dist/commands/memory-uninstall.js +187 -0
- package/dist/commands/memory-verb-list.js +6 -2
- package/dist/commands/onboard.js +5 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/roots-label.js +85 -0
- package/dist/commands/roots.js +28 -10
- package/dist/commands/session-sync-plan.js +8 -4
- package/dist/commands/session-sync-scan.js +25 -3
- package/dist/commands/session-sync-shelf.js +143 -0
- package/dist/commands/session-sync-upload.js +6 -2
- package/dist/commands/session-sync.js +68 -8
- package/dist/commands/settings.js +1 -1
- package/dist/commands/shelf.js +165 -0
- package/dist/commands/sources-check.js +2 -2
- package/dist/commands/sources-reconciliation.js +2 -1
- package/dist/commands/speed-seen.js +2 -2
- package/dist/commands/sync-doctor.js +2 -1
- package/dist/commands/sync-receipt.js +1 -0
- package/dist/commands/sync-report.js +1 -0
- package/dist/commands/sync-stub-heal.js +2 -2
- package/dist/commands/sync.js +12 -0
- package/dist/error-message.js +11 -0
- package/dist/evidence-reconcile-client.js +2 -0
- package/dist/evidence-redelivery-plan.js +6 -0
- package/dist/evidence-redelivery.js +6 -1
- package/dist/evidence-stubs-client.js +2 -0
- package/dist/evidence-transcripts-client.js +2 -0
- package/dist/evidence-upload-transport.js +2 -0
- package/dist/founder-standing-file.js +52 -0
- package/dist/founder-standing.js +117 -0
- package/dist/image-describe-client.js +2 -1
- package/dist/live-founder-memo.js +57 -0
- package/dist/local-state-config.js +13 -7
- package/dist/memory-capture-choice.js +73 -0
- package/dist/root-work.js +55 -7
- package/dist/shelf-decision.js +52 -0
- package/dist/shelf-headers.js +15 -0
- package/dist/shelf-reports.js +167 -0
- package/dist/shelf-state.js +112 -0
- package/dist/source-opt-out.js +83 -6
- package/dist/state-dir-audit.js +2 -0
- package/dist/tower-client.js +4 -1
- package/dist/upload-agent-artifacts.js +2 -0
- package/dist/upload-envelope-build.js +2 -2
- package/dist/upload-ingest-receipt.js +2 -0
- package/dist/upload-session-reports-wire.js +2 -0
- package/package.json +4 -4
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { optOutVerdict, } from "@bli-cockpit/telemetry-core";
|
|
2
|
+
import { writeLine } from "./commands/cli-io.js";
|
|
3
|
+
import { askTowerFounderStanding } from "./founder-standing.js";
|
|
4
|
+
import { recallLiveFounderAnswer } from "./live-founder-memo.js";
|
|
5
|
+
import { getCollectorRuntimePaths, readLocalCollectorConfig } from "./local-state.js";
|
|
6
|
+
import { readMemoryCaptureChoice } from "./memory-capture-choice.js";
|
|
7
|
+
import { configOptOutLabels } from "./source-opt-out.js";
|
|
8
|
+
/**
|
|
9
|
+
* BLI-22640 (Jeetesh's review of 2026-10-25): this machine's opt-outs, which of
|
|
10
|
+
* them it obeys, and the founder answer it acted on, in one reading.
|
|
11
|
+
*
|
|
12
|
+
* - The sync tick calls `refreshFounderStandingForOptOuts` first: a machine
|
|
13
|
+
* that holds any opt-out asks Tower live before anything is collected, and
|
|
14
|
+
* every decider in that tick reuses the answer (memory only,
|
|
15
|
+
* `live-founder-memo.ts`). A machine with no opt-out asks nothing.
|
|
16
|
+
* - The heartbeat carries `readCaptureOptOuts` as `capture_opt_outs`, so
|
|
17
|
+
* Tower's fleet records see what each machine obeyed, ignored and held, and
|
|
18
|
+
* Tower can compare `obeyed` with its own owners list. It reports what THIS
|
|
19
|
+
* process heard from Tower; it never reads the saved `founder-standing.json`.
|
|
20
|
+
* - `cockpit doctor` prints `captureOptOutLine` when something is ignored and
|
|
21
|
+
* `heldOptOutLine` when Tower could not be asked.
|
|
22
|
+
*
|
|
23
|
+
* Never throws: a reading that fails is logged and reads as "no opt-out".
|
|
24
|
+
* A new sibling must also join `scripts/build-public-cli.mjs` `runtimeFiles`.
|
|
25
|
+
*/
|
|
26
|
+
export async function readCaptureOptOuts(homeDir) {
|
|
27
|
+
const paths = getCollectorRuntimePaths(homeDir);
|
|
28
|
+
try {
|
|
29
|
+
const found = await foundOptOuts(homeDir);
|
|
30
|
+
// What this process heard from Tower this tick; no answer means unknown (held).
|
|
31
|
+
const standing = found.length > 0 ? (recallLiveFounderAnswer(paths.state_dir)?.standing ?? "unknown") : "unknown";
|
|
32
|
+
return splitByVerdict(found, standing);
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
console.error("[capture-opt-outs] opt-outs could not be read; reporting none", JSON.stringify({ reason: "opt_outs_unreadable", error_name: error instanceof Error ? error.name : typeof error }));
|
|
36
|
+
return { standing: "unknown", obeyed: [], ignored: [] };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Asks Tower live when this machine holds an opt-out, so this tick decides on
|
|
41
|
+
* Tower's current answer. When Tower cannot be asked the answer is `unknown`
|
|
42
|
+
* and the opt-outs are HELD (`owner_check_unavailable`), never decided on the
|
|
43
|
+
* saved file. Never throws; logs both branches.
|
|
44
|
+
*/
|
|
45
|
+
export async function refreshFounderStandingForOptOuts(options) {
|
|
46
|
+
let found;
|
|
47
|
+
try {
|
|
48
|
+
found = await foundOptOuts(options.homeDir);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
found = [];
|
|
52
|
+
}
|
|
53
|
+
if (found.length === 0) {
|
|
54
|
+
console.error("[capture-opt-outs] no opt-out on this machine; Tower not asked", JSON.stringify({ reason: "no_opt_out" }));
|
|
55
|
+
return { standing: "unknown", obeyed: [], ignored: [] };
|
|
56
|
+
}
|
|
57
|
+
const answer = await askTowerFounderStanding({
|
|
58
|
+
...(options.homeDir === undefined ? {} : { homeDir: options.homeDir }),
|
|
59
|
+
...(options.dashboardUrl === undefined ? {} : { dashboardUrl: options.dashboardUrl }),
|
|
60
|
+
...(options.fetch === undefined ? {} : { fetch: options.fetch }),
|
|
61
|
+
});
|
|
62
|
+
const report = splitByVerdict(found, answer.standing);
|
|
63
|
+
console.error("[capture-opt-outs] this tick's opt-outs", JSON.stringify({
|
|
64
|
+
reason: optOutVerdict(report.standing).reason,
|
|
65
|
+
...(answer.standing === "unknown" ? { not_live_reason: answer.reason } : {}),
|
|
66
|
+
obeyed: report.obeyed,
|
|
67
|
+
ignored: report.ignored,
|
|
68
|
+
}));
|
|
69
|
+
return report;
|
|
70
|
+
}
|
|
71
|
+
/** The one line doctor prints, or null when nothing is ignored. */
|
|
72
|
+
export function captureOptOutLine(report) {
|
|
73
|
+
if (report.ignored.length === 0)
|
|
74
|
+
return null;
|
|
75
|
+
return `Ignored on this machine: ${report.ignored.map(labelWords).join(", ")}. Only Tower's founders can keep work out of Tower, `
|
|
76
|
+
+ "so BLI Memory stays on and these sessions upload; your settings are left as you wrote them (opt_out_ignored_not_founder).";
|
|
77
|
+
}
|
|
78
|
+
/** The line doctor prints when Tower could not be asked, or null when nothing is held. */
|
|
79
|
+
export function heldOptOutLine(report) {
|
|
80
|
+
if (report.standing !== "unknown" || report.obeyed.length === 0)
|
|
81
|
+
return null;
|
|
82
|
+
return `Held on this machine: ${report.obeyed.map(labelWords).join(", ")}. Tower could not say whether you are a founder, `
|
|
83
|
+
+ "so nothing these cover is sent until it can; your settings are left as you wrote them (owner_check_unavailable).";
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* `cockpit doctor`'s step: asks Tower when this machine holds an opt-out, and
|
|
87
|
+
* prints one line when any is ignored because the person is not a founder, or
|
|
88
|
+
* held because Tower could not be asked.
|
|
89
|
+
* `--json` keeps stdout for its object; the line goes to stderr there. Never
|
|
90
|
+
* throws: doctor is never lost to this line.
|
|
91
|
+
*/
|
|
92
|
+
export async function reportIgnoredOptOuts(options, io) {
|
|
93
|
+
try {
|
|
94
|
+
const report = await refreshFounderStandingForOptOuts({
|
|
95
|
+
...(options.homeDir === undefined ? {} : { homeDir: options.homeDir }),
|
|
96
|
+
fetch: io.fetch,
|
|
97
|
+
});
|
|
98
|
+
const line = captureOptOutLine(report) ?? heldOptOutLine(report);
|
|
99
|
+
if (line)
|
|
100
|
+
writeLine(options.json ? io.stderr : io.stdout, line);
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
console.error("[doctor] opt-out report failed; doctor carries on", JSON.stringify({ reason: "opt_out_report_threw", error_name: error instanceof Error ? error.name : typeof error }));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async function foundOptOuts(homeDir) {
|
|
107
|
+
const paths = getCollectorRuntimePaths(homeDir);
|
|
108
|
+
const config = await readLocalCollectorConfig(paths).catch(() => null);
|
|
109
|
+
const choice = await readMemoryCaptureChoice(paths);
|
|
110
|
+
return [
|
|
111
|
+
...(choice.state === "off" ? ["memory_capture_off"] : []),
|
|
112
|
+
...configOptOutLabels(config),
|
|
113
|
+
];
|
|
114
|
+
}
|
|
115
|
+
function splitByVerdict(found, standing) {
|
|
116
|
+
if (found.length === 0)
|
|
117
|
+
return { standing, obeyed: [], ignored: [] };
|
|
118
|
+
return optOutVerdict(standing).obeyed
|
|
119
|
+
? { standing, obeyed: found, ignored: [] }
|
|
120
|
+
: { standing, obeyed: [], ignored: found };
|
|
121
|
+
}
|
|
122
|
+
function labelWords(label) {
|
|
123
|
+
switch (label) {
|
|
124
|
+
case "memory_capture_off": return "BLI Memory off";
|
|
125
|
+
case "collect_claude_jsonl_false": return "collect_claude_jsonl: false";
|
|
126
|
+
case "collect_codex_jsonl_false": return "collect_codex_jsonl: false";
|
|
127
|
+
case "raw_evidence_upload_disabled": return "raw_evidence_upload: \"disabled\"";
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BLI-23590: "only collect from my approved folders" (`collection_scope: "roots_only"`).
|
|
3
|
+
*
|
|
4
|
+
* Roots are labels by default (session-first collection, BLI-10734): every session the machine can see is uploaded
|
|
5
|
+
* and tagged inside or outside. A person Tower names eligible (the founders and Ed, decided on the SERVER) can choose
|
|
6
|
+
* `roots_only`: a session whose working directory is outside every approved root is dropped from the scan result
|
|
7
|
+
* before anything is staged, uploaded, reported, committed-to-git-scanned or saved to the local work scan. Its only
|
|
8
|
+
* trace is a count on the heartbeat with {@link COLLECTION_SCOPE_ROOTS_ONLY_REASON}.
|
|
9
|
+
*
|
|
10
|
+
* Where the answer comes from, in order:
|
|
11
|
+
*
|
|
12
|
+
* 1. Tower's last reply (`collection_scope: { scope, eligible }` on the install-events reply), kept in
|
|
13
|
+
* `collection-scope.json` in the state directory so an offline tick keeps the last answer.
|
|
14
|
+
* 2. The local config key `collection_scope: "roots_only"`, honoured ONLY when the cached reply says
|
|
15
|
+
* `eligible: true`. An ineligible person's key, or one nobody has confirmed yet, is ignored and the reason is
|
|
16
|
+
* logged. Editing a laptop's config therefore cannot switch this on for anyone Tower has not cleared.
|
|
17
|
+
* 3. Otherwise `all`, today's behaviour for everyone.
|
|
18
|
+
*
|
|
19
|
+
* Plain-words note on "not read": the scan has to open a session file's head to learn its working directory, so the
|
|
20
|
+
* file is touched; nothing from an outside-root session is kept past that decision (no staging, upload, session row,
|
|
21
|
+
* hash in a report, git scan or work-scan entry).
|
|
22
|
+
*
|
|
23
|
+
* Holds scope, eligibility and a timestamp. Never a path, an id or a byte of a session.
|
|
24
|
+
*
|
|
25
|
+
* A new sibling must also join `scripts/build-public-cli.mjs` `runtimeFiles`.
|
|
26
|
+
*/
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import { COLLECTION_SCOPE_ROOTS_ONLY_REASON, CollectionScopeReplySchema, } from "@bli-cockpit/telemetry-core";
|
|
29
|
+
import { isPathWithin } from "./adapters/attribution-core-paths.js";
|
|
30
|
+
import { describeError, isMissingFileFailure } from "./health-detail.js";
|
|
31
|
+
import { readJsonFile, writeJsonFileAtomic } from "./local-state-files.js";
|
|
32
|
+
export const COLLECTION_SCOPE_STATE_FILENAME = "collection-scope.json";
|
|
33
|
+
const TAG = "[collection-scope]";
|
|
34
|
+
export function collectionScopeStatePath(stateDir) {
|
|
35
|
+
return path.join(stateDir, COLLECTION_SCOPE_STATE_FILENAME);
|
|
36
|
+
}
|
|
37
|
+
/** The cached answer, or null when there is none or it cannot be trusted (said out loud when unreadable). */
|
|
38
|
+
export async function readCollectionScopeState(stateDir) {
|
|
39
|
+
let raw;
|
|
40
|
+
try {
|
|
41
|
+
raw = await readJsonFile(collectionScopeStatePath(stateDir));
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (isMissingFileFailure(error))
|
|
45
|
+
return null;
|
|
46
|
+
console.error(TAG + " cache unreadable, collecting as the default", JSON.stringify({ reason: "collection_scope_cache_unreadable", ...describeError(error) }));
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const parsed = CollectionScopeReplySchema.safeParse(raw && typeof raw === "object" && !Array.isArray(raw)
|
|
50
|
+
? { scope: raw["scope"], eligible: raw["eligible"] }
|
|
51
|
+
: null);
|
|
52
|
+
if (!parsed.success) {
|
|
53
|
+
console.error(TAG + " cache shape invalid, collecting as the default", JSON.stringify({ reason: "collection_scope_cache_shape_invalid" }));
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const observedAt = raw["observed_at"];
|
|
57
|
+
return { ...parsed.data, observed_at: typeof observedAt === "string" ? observedAt : "" };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Put Tower's reply on file. A reply that names no scope (older server, failed read) is ignored and the previous
|
|
61
|
+
* answer stays in force. An unknown shape is ignored with a log. Returns true only when the file now holds the reply.
|
|
62
|
+
*/
|
|
63
|
+
export async function recordObservedCollectionScope(stateDir, observed, now = () => new Date()) {
|
|
64
|
+
if (observed === undefined || observed === null)
|
|
65
|
+
return false;
|
|
66
|
+
const parsed = CollectionScopeReplySchema.safeParse(observed);
|
|
67
|
+
if (!parsed.success) {
|
|
68
|
+
console.error(TAG + " reply carried a scope this collector does not understand, ignored", JSON.stringify({ reason: "collection_scope_reply_invalid" }));
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
const previous = await readCollectionScopeState(stateDir);
|
|
72
|
+
try {
|
|
73
|
+
await writeJsonFileAtomic(collectionScopeStatePath(stateDir), {
|
|
74
|
+
schema_version: "cockpit-collection-scope.v1",
|
|
75
|
+
...parsed.data,
|
|
76
|
+
observed_at: now().toISOString(),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
console.error(TAG + " could not cache the answer Tower sent; the previous answer stays in force", JSON.stringify({ reason: "collection_scope_cache_write_failed", ...describeError(error) }));
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
if (previous?.scope !== parsed.data.scope || previous?.eligible !== parsed.data.eligible) {
|
|
84
|
+
console.error(TAG + " answer for this person changed", JSON.stringify({ reason: "collection_scope_changed", from: previous?.scope ?? "none", to: parsed.data.scope, eligible: parsed.data.eligible }));
|
|
85
|
+
}
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
/** Pure: the scope a tick runs under, from Tower's cached answer and the local config request. */
|
|
89
|
+
export function resolveEffectiveCollectionScope(input) {
|
|
90
|
+
const { cache, localRequest } = input;
|
|
91
|
+
if (cache?.scope === "roots_only") {
|
|
92
|
+
// Tower only ever sends roots_only for a person it cleared; an `eligible: false` beside it is a contradiction,
|
|
93
|
+
// and the safe reading of a contradiction is the default.
|
|
94
|
+
if (cache.eligible)
|
|
95
|
+
return { scope: "roots_only", source: "server", reason: "tower_set_roots_only" };
|
|
96
|
+
return { scope: "all", source: "default", reason: "tower_answer_contradicts_itself_ignored" };
|
|
97
|
+
}
|
|
98
|
+
if (localRequest === "roots_only") {
|
|
99
|
+
if (cache?.eligible)
|
|
100
|
+
return { scope: "roots_only", source: "local_config_eligible", reason: "local_roots_only_request_tower_confirms_eligible" };
|
|
101
|
+
return {
|
|
102
|
+
scope: "all",
|
|
103
|
+
source: "default",
|
|
104
|
+
reason: cache ? "local_roots_only_ignored_not_eligible" : "local_roots_only_ignored_eligibility_unconfirmed",
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return { scope: "all", source: "default", reason: cache ? "tower_says_all" : "no_answer_from_tower_yet" };
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The tick's decision, logged once. `localRequest` is the config key. Never throws: a read failure is the default
|
|
111
|
+
* scope with its reason, and the failure was already logged by the reader.
|
|
112
|
+
*/
|
|
113
|
+
export async function settleCollectionScope(stateDir, localRequest) {
|
|
114
|
+
const cache = await readCollectionScopeState(stateDir);
|
|
115
|
+
const effective = resolveEffectiveCollectionScope({ cache, localRequest });
|
|
116
|
+
console.error(TAG + " scope for this tick", JSON.stringify({ reason: effective.reason, scope: effective.scope, source: effective.source, eligible: cache?.eligible ?? null }));
|
|
117
|
+
return effective;
|
|
118
|
+
}
|
|
119
|
+
/** `settleCollectionScope` for the sync tick, which it must never fail. */
|
|
120
|
+
export async function settleTickCollectionScopeBestEffort(stateDir, observed) {
|
|
121
|
+
try {
|
|
122
|
+
await recordObservedCollectionScope(stateDir, observed);
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
console.error(TAG + " could not settle the answer for this tick", JSON.stringify({ reason: "collection_scope_settle_failed", ...describeError(error) }));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// The root test and the filter
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
/**
|
|
132
|
+
* Is this working directory inside an approved root? Uses the collector's one containment rule
|
|
133
|
+
* (`isPathWithin`): the platform is read from each path itself (a drive letter or UNC share is Windows), Windows
|
|
134
|
+
* compares case-insensitively with either slash, `projects-old` is not inside `projects`, and a Windows path never
|
|
135
|
+
* matches a POSIX root or the reverse.
|
|
136
|
+
*/
|
|
137
|
+
export function cwdInsideRoots(cwd, roots) {
|
|
138
|
+
if (!cwd || roots.length === 0)
|
|
139
|
+
return false;
|
|
140
|
+
return roots.some((root) => isPathWithin(cwd, root));
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* A session stays when its working directory is inside an approved root, or when attribution put it in a worktree
|
|
144
|
+
* that discovery found under the roots and its working directory is inside that worktree (a Codex-managed worktree
|
|
145
|
+
* of an approved repo lives outside the roots but is the approved repo's work). No recorded working directory means
|
|
146
|
+
* it cannot be proven inside, so it is withheld.
|
|
147
|
+
*/
|
|
148
|
+
export function sessionInsideRoots(result, roots) {
|
|
149
|
+
if (cwdInsideRoots(result.local_cwd, roots))
|
|
150
|
+
return true;
|
|
151
|
+
return Boolean(result.worktree && cwdInsideRoots(result.local_cwd, [result.worktree.repo_root]));
|
|
152
|
+
}
|
|
153
|
+
export function withholdOutsideRoots(results, roots) {
|
|
154
|
+
const kept = [];
|
|
155
|
+
let withheld = 0;
|
|
156
|
+
let withoutCwd = 0;
|
|
157
|
+
for (const result of results) {
|
|
158
|
+
if (sessionInsideRoots(result, roots)) {
|
|
159
|
+
kept.push(result);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
withheld += 1;
|
|
163
|
+
if (!result.local_cwd)
|
|
164
|
+
withoutCwd += 1;
|
|
165
|
+
}
|
|
166
|
+
return { kept, withheld, withheld_without_cwd: withoutCwd };
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Drop the outside-root sessions from one source's scan result and keep its per-state counts honest (a count of
|
|
170
|
+
* `unattributed` that still included a withheld session would say the machine saw something it was told to forget).
|
|
171
|
+
* The other counters (files discovered, scanned) describe the READ and stay as they were.
|
|
172
|
+
*/
|
|
173
|
+
export function scopeScanResult(scan, roots) {
|
|
174
|
+
const { kept, withheld, withheld_without_cwd } = withholdOutsideRoots(scan.results, roots);
|
|
175
|
+
if (withheld === 0)
|
|
176
|
+
return { scan, withheld: 0, withheld_without_cwd: 0 };
|
|
177
|
+
const counts = Object.fromEntries(Object.keys(scan.counts).map((state) => [state, kept.filter((result) => result.state === state).length]));
|
|
178
|
+
return { scan: { ...scan, results: kept, counts }, withheld, withheld_without_cwd };
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* `cockpit backfill`'s version of the same drop: under `roots_only` a session outside every approved root never becomes
|
|
182
|
+
* a candidate, so backfill can never upload what the scheduled sync withholds. Under `all` the scans pass through.
|
|
183
|
+
*/
|
|
184
|
+
export function scopeHistoryScans(options, codex, claude) {
|
|
185
|
+
if (options.collectionScope.scope !== "roots_only")
|
|
186
|
+
return { codex, claude };
|
|
187
|
+
const codexScoped = codex ? scopeScanResult(codex, options.collectionRoots) : null;
|
|
188
|
+
const claudeScoped = claude ? scopeScanResult(claude, options.collectionRoots) : null;
|
|
189
|
+
logScopeWithhold("backfill", {
|
|
190
|
+
source: "codex+claude_code",
|
|
191
|
+
kept: (codexScoped?.scan.results.length ?? 0) + (claudeScoped?.scan.results.length ?? 0),
|
|
192
|
+
withheld: (codexScoped?.withheld ?? 0) + (claudeScoped?.withheld ?? 0),
|
|
193
|
+
withheld_without_cwd: (codexScoped?.withheld_without_cwd ?? 0) + (claudeScoped?.withheld_without_cwd ?? 0),
|
|
194
|
+
roots: options.collectionRoots.length,
|
|
195
|
+
});
|
|
196
|
+
return { codex: codexScoped?.scan ?? null, claude: claudeScoped?.scan ?? null };
|
|
197
|
+
}
|
|
198
|
+
/** The one log line every acting site writes, so a withhold is never silent. Counts only. */
|
|
199
|
+
export function logScopeWithhold(site, facts) {
|
|
200
|
+
console.error(`[${site}] sessions outside the approved roots withheld by collection scope`, JSON.stringify({ reason: COLLECTION_SCOPE_ROOTS_ONLY_REASON, ...facts }));
|
|
201
|
+
}
|
|
@@ -152,6 +152,9 @@ function sessionReportEntry(result, context) {
|
|
|
152
152
|
attribution_score: result.attribution_score,
|
|
153
153
|
path_score: result.path_score,
|
|
154
154
|
root_scope: rootScopeForReason(result.reason),
|
|
155
|
+
// BLI-23478 (three shelves): every row this collector sends is a team-shelf row; a drawer session sends no row
|
|
156
|
+
// (it left the pass in `session-sync-shelf.ts`). Tower refuses a founder's row without it once routing is on.
|
|
157
|
+
shelf: "team",
|
|
155
158
|
signals: result.signals,
|
|
156
159
|
...(result.content_hash_sha256
|
|
157
160
|
? { session_file_hash_sha256: result.content_hash_sha256 }
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How `cockpit agreements` prints the doors' answers as plain text. The shapes copy
|
|
3
|
+
* `apps/dashboard/src/lib/agreements/contracts.ts` (this package does not import the dashboard); only the fields
|
|
4
|
+
* printed here are named.
|
|
5
|
+
*/
|
|
6
|
+
const STATUS_WORDS = { open: "waiting for your signature", signed: "signed", cancelled: "cancelled" };
|
|
7
|
+
/** One request in `cockpit agreements`: what, its state, and the id to show it. */
|
|
8
|
+
export function rowLines(row) {
|
|
9
|
+
const when = row.status === "signed" && row.signed_at ? `, ${row.signed_at.slice(0, 10)}` : row.status === "open" && row.due_at ? `, due ${row.due_at.slice(0, 10)}` : "";
|
|
10
|
+
return [`${row.document_title} (${row.version_label}): ${STATUS_WORDS[row.status] ?? row.status}${when}`, ` cockpit agreements show ${row.request_id}`];
|
|
11
|
+
}
|
|
12
|
+
/** A box as it was answered once signed; empty before then, because a box is never pre-ticked. */
|
|
13
|
+
function boxLine(box, ticked) {
|
|
14
|
+
return ` ${ticked ? "[x]" : "[ ]"} ${box.wording} (${box.required ? "required" : "optional"})`;
|
|
15
|
+
}
|
|
16
|
+
/** `cockpit agreements show`: the exact text, the boxes, and the record once signed, or where to sign while open. */
|
|
17
|
+
export function agreementLines(agreement, dashboardUrl) {
|
|
18
|
+
const lines = [
|
|
19
|
+
`${agreement.document_title} (${agreement.version_label})`,
|
|
20
|
+
`Status: ${STATUS_WORDS[agreement.status] ?? agreement.status}${agreement.status === "open" && agreement.due_at ? `, due ${agreement.due_at.slice(0, 10)}` : ""}`,
|
|
21
|
+
`Published ${agreement.published_at}. Text fingerprint (SHA-256): ${agreement.text_sha256}`,
|
|
22
|
+
"",
|
|
23
|
+
agreement.body,
|
|
24
|
+
"",
|
|
25
|
+
];
|
|
26
|
+
const signature = agreement.signature;
|
|
27
|
+
if (agreement.consent_boxes.length > 0) {
|
|
28
|
+
lines.push("Boxes:", ...agreement.consent_boxes.map((box) => boxLine(box, signature?.consents[box.key] === true)), "");
|
|
29
|
+
}
|
|
30
|
+
if (signature) {
|
|
31
|
+
lines.push("Signing record:", ` Signed by: ${signature.typed_name}${signature.signer_email ? ` <${signature.signer_email}>` : ""}`, ` Signed at: ${signature.signed_at}`, ` Identified by: ${signature.identity_method === "tower_session+email_code" ? "Tower sign-in and a one-time code sent by email" : signature.identity_method}`, ` Event number in Tower's signing log: ${signature.event_seq}`, `Your copy: cockpit agreements copy ${agreement.request_id} --out <file>`);
|
|
32
|
+
}
|
|
33
|
+
else if (agreement.status === "open") {
|
|
34
|
+
lines.push(`Sign it in Tower in your browser: ${dashboardUrl}/agreements/${agreement.request_id}`);
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
lines.push("This request was cancelled. Nothing was signed.");
|
|
38
|
+
}
|
|
39
|
+
return lines;
|
|
40
|
+
}
|
|
41
|
+
function flagWords(doc) {
|
|
42
|
+
const flags = [doc.needs_countersign ? "needs a countersignature" : "", doc.required_for_access ? "required for Tower access" : "", doc.needs_handwritten_confirmation ? "needs a handwritten confirmation" : ""].filter(Boolean);
|
|
43
|
+
return flags.length > 0 ? ` (${flags.join("; ")})` : "";
|
|
44
|
+
}
|
|
45
|
+
/** `cockpit agreements documents`: each document, its versions, and who was asked to sign each. */
|
|
46
|
+
export function documentLines(doc) {
|
|
47
|
+
const lines = [`${doc.title} [${doc.slug}]${flagWords(doc)}`, ` document ${doc.document_id}`];
|
|
48
|
+
if (doc.versions.length === 0)
|
|
49
|
+
lines.push(" No versions yet.");
|
|
50
|
+
for (const version of doc.versions) {
|
|
51
|
+
const state = version.published_at ? `published ${version.published_at.slice(0, 10)}` : "draft, not published";
|
|
52
|
+
const boxes = version.consent_boxes.length === 0 ? "no boxes" : `${version.consent_boxes.length} box${version.consent_boxes.length === 1 ? "" : "es"}`;
|
|
53
|
+
lines.push(` ${version.label} (${version.language}): ${state}, ${boxes}`, ` version ${version.version_id}`);
|
|
54
|
+
for (const asked of version.requests) {
|
|
55
|
+
const who = asked.signer_name ?? asked.signer_user_id;
|
|
56
|
+
const due = asked.status === "open" && asked.due_at ? `, due ${asked.due_at.slice(0, 10)}` : "";
|
|
57
|
+
lines.push(` - ${who}: ${asked.status}${due} (request ${asked.request_id})`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return lines;
|
|
61
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit agreements` (docs/architecture/agreements.md): agreements to sign, from the terminal.
|
|
3
|
+
*
|
|
4
|
+
* Plain words: a founder publishes a document as a fixed version and asks named people to sign it. Each person reads it
|
|
5
|
+
* and signs in Tower in their browser; this command reads their own requests and the signed copy, and lets a founder
|
|
6
|
+
* draft a document, ask someone to sign a published version, or cancel a request.
|
|
7
|
+
*
|
|
8
|
+
* Seven verbs, each one `/api/agreements` door on this machine's device token, the same doors the web page, the MCP
|
|
9
|
+
* twins (`agreements_*`) and JARVIS use. The door's own words are printed; `--json` carries the raw body on stdout and
|
|
10
|
+
* nothing else. A draft arrives as JSON on stdin. There is deliberately no verb to ask for a signing code, to sign, or
|
|
11
|
+
* to publish: those are a person's own act in the browser, and their doors refuse a device token (founders, 2026-10-28).
|
|
12
|
+
*/
|
|
13
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor, fetchAgentDoorFile, openAgentDoor } from "./agent-door.js";
|
|
16
|
+
import { agreementLines, documentLines, rowLines } from "./agreements-render.js";
|
|
17
|
+
import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
|
|
18
|
+
const TAG = "[agreements cli]";
|
|
19
|
+
const READ_DEADLINE_MS = 30_000;
|
|
20
|
+
const WRITE_DEADLINE_MS = 55_000;
|
|
21
|
+
/** A document's text is at most 200,000 characters (`MAX_BODY_CHARS`); the boxes and names ride on top. */
|
|
22
|
+
const DRAFT_MAX_CHARS = 300_000;
|
|
23
|
+
export async function runAgreements(command, io) {
|
|
24
|
+
let door;
|
|
25
|
+
try {
|
|
26
|
+
door = await openAgentDoor("agreements", command, io);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
const detail = error instanceof Error ? error.message : "this machine is not paired with Tower";
|
|
30
|
+
writeLine(io.stderr, `${TAG} refused ${JSON.stringify({ reason: "not_paired" })}`);
|
|
31
|
+
if (command.json)
|
|
32
|
+
writeLine(io.stdout, JSON.stringify({ ok: false, error: "not_paired", detail }));
|
|
33
|
+
else
|
|
34
|
+
writeLine(io.stderr, detail);
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
switch (command.action) {
|
|
38
|
+
case "list": return list(door);
|
|
39
|
+
case "show": return show(command, door);
|
|
40
|
+
case "copy": return copy(command, door);
|
|
41
|
+
case "documents": return documents(door);
|
|
42
|
+
case "draft": return draft(door);
|
|
43
|
+
case "request": return request(command, door);
|
|
44
|
+
case "cancel": return cancel(command, door);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async function list(door) {
|
|
48
|
+
const answer = await askAgentDoor(door, { path: "/api/agreements", method: "GET", label: "agreements list", timeoutMs: READ_DEADLINE_MS });
|
|
49
|
+
if (!answer.ok)
|
|
50
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
51
|
+
const body = answer.body;
|
|
52
|
+
const open = body.agreements.filter((row) => row.status === "open").length;
|
|
53
|
+
writeLine(door.io.stderr, `${TAG} list answered ${JSON.stringify({ returned: body.agreements.length, open })}`);
|
|
54
|
+
if (door.json)
|
|
55
|
+
return emitAgentDoor(door, answer.body);
|
|
56
|
+
const out = door.io.stdout;
|
|
57
|
+
if (body.agreements.length === 0) {
|
|
58
|
+
writeLine(out, "Nothing to sign, and nothing signed yet.");
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
writeLine(out, open === 0 ? "Nothing is waiting for your signature." : `${open} waiting for your signature.`);
|
|
62
|
+
for (const row of body.agreements) {
|
|
63
|
+
writeLine(out, "");
|
|
64
|
+
for (const line of rowLines(row))
|
|
65
|
+
writeLine(out, line);
|
|
66
|
+
}
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
async function show(command, door) {
|
|
70
|
+
const id = command.requestId ?? "";
|
|
71
|
+
const answer = await askAgentDoor(door, { path: `/api/agreements/${encodeURIComponent(id)}`, method: "GET", label: "agreements show", timeoutMs: READ_DEADLINE_MS });
|
|
72
|
+
if (!answer.ok)
|
|
73
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
74
|
+
const body = answer.body;
|
|
75
|
+
writeLine(door.io.stderr, `${TAG} show answered ${JSON.stringify({ request_id: body.agreement.request_id, status: body.agreement.status, boxes: body.agreement.consent_boxes.length })}`);
|
|
76
|
+
if (door.json)
|
|
77
|
+
return emitAgentDoor(door, answer.body);
|
|
78
|
+
for (const line of agreementLines(body.agreement, door.dashboardUrl))
|
|
79
|
+
writeLine(door.io.stdout, line);
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
/** The signed copy, as the door wrote it: to `--out`, or on stdout. Only a signed agreement has one. */
|
|
83
|
+
async function copy(command, door) {
|
|
84
|
+
const id = command.requestId ?? "";
|
|
85
|
+
const answer = await fetchAgentDoorFile(door, { path: `/api/agreements/${encodeURIComponent(id)}/copy`, label: "agreements copy", timeoutMs: READ_DEADLINE_MS });
|
|
86
|
+
if (!answer.ok)
|
|
87
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
88
|
+
const text = answer.bytes.toString("utf8");
|
|
89
|
+
if (!command.outPath) {
|
|
90
|
+
writeLine(door.io.stderr, `${TAG} copy answered ${JSON.stringify({ request_id: id, bytes: answer.bytes.length, written: false })}`);
|
|
91
|
+
if (door.json)
|
|
92
|
+
return emitAgentDoor(door, { ok: true, request_id: id, filename: answer.filename, text });
|
|
93
|
+
door.io.stdout.write(text);
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
const target = path.resolve(command.outPath);
|
|
97
|
+
try {
|
|
98
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
99
|
+
await writeFile(target, answer.bytes);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
103
|
+
return failAgentDoor(door, TAG, "copy_write_failed", `Could not write ${target}: ${detail}`);
|
|
104
|
+
}
|
|
105
|
+
writeLine(door.io.stderr, `${TAG} copy answered ${JSON.stringify({ request_id: id, bytes: answer.bytes.length, written: true })}`);
|
|
106
|
+
if (door.json)
|
|
107
|
+
return emitAgentDoor(door, { ok: true, request_id: id, path: target, bytes: answer.bytes.length });
|
|
108
|
+
writeLine(door.io.stdout, `Saved your signed copy: ${target}`);
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
async function documents(door) {
|
|
112
|
+
const answer = await askAgentDoor(door, { path: "/api/agreements/documents", method: "GET", label: "agreements documents", timeoutMs: READ_DEADLINE_MS });
|
|
113
|
+
if (!answer.ok)
|
|
114
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
115
|
+
const body = answer.body;
|
|
116
|
+
writeLine(door.io.stderr, `${TAG} documents answered ${JSON.stringify({ documents: body.documents.length, versions: body.documents.reduce((sum, doc) => sum + doc.versions.length, 0) })}`);
|
|
117
|
+
if (door.json)
|
|
118
|
+
return emitAgentDoor(door, answer.body);
|
|
119
|
+
if (body.documents.length === 0) {
|
|
120
|
+
writeLine(door.io.stdout, "No documents yet. Draft one with `cockpit agreements draft` (the JSON on stdin).");
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
for (const doc of body.documents) {
|
|
124
|
+
for (const line of documentLines(doc))
|
|
125
|
+
writeLine(door.io.stdout, line);
|
|
126
|
+
writeLine(door.io.stdout, "");
|
|
127
|
+
}
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
async function draft(door) {
|
|
131
|
+
const piped = await readJsonObject(door);
|
|
132
|
+
if (!piped.ok)
|
|
133
|
+
return failAgentDoor(door, TAG, piped.reason, piped.detail);
|
|
134
|
+
const answer = await askAgentDoor(door, { path: "/api/agreements/documents", method: "POST", label: "agreements draft", timeoutMs: WRITE_DEADLINE_MS, body: piped.value });
|
|
135
|
+
if (!answer.ok)
|
|
136
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
137
|
+
const body = answer.body;
|
|
138
|
+
writeLine(door.io.stderr, `${TAG} draft saved ${JSON.stringify({ document_id: body.document_id, version_id: body.version_id })}`);
|
|
139
|
+
if (door.json)
|
|
140
|
+
return emitAgentDoor(door, answer.body);
|
|
141
|
+
const out = door.io.stdout;
|
|
142
|
+
writeLine(out, "Saved the draft. Nobody can sign it until a founder publishes it.");
|
|
143
|
+
writeLine(out, ` document ${body.document_id}`);
|
|
144
|
+
writeLine(out, ` version ${body.version_id}`);
|
|
145
|
+
writeLine(out, "Publishing is done in Tower in your browser, where a founder reads the text and confirms it.");
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
async function request(command, door) {
|
|
149
|
+
const requestBody = { version_id: command.versionId, signer_user_id: command.personId, ...(command.dueAt ? { due_at: command.dueAt } : {}) };
|
|
150
|
+
const answer = await askAgentDoor(door, { path: "/api/agreements/requests", method: "POST", label: "agreements request", timeoutMs: WRITE_DEADLINE_MS, body: requestBody });
|
|
151
|
+
if (!answer.ok)
|
|
152
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
153
|
+
const body = answer.body;
|
|
154
|
+
writeLine(door.io.stderr, `${TAG} request created ${JSON.stringify({ request_id: body.request_id, due: Boolean(command.dueAt) })}`);
|
|
155
|
+
if (door.json)
|
|
156
|
+
return emitAgentDoor(door, answer.body);
|
|
157
|
+
writeLine(door.io.stdout, body.message ?? "Asked.");
|
|
158
|
+
writeLine(door.io.stdout, ` request ${body.request_id}`);
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
async function cancel(command, door) {
|
|
162
|
+
const id = command.requestId ?? "";
|
|
163
|
+
const answer = await askAgentDoor(door, { path: `/api/agreements/requests/${encodeURIComponent(id)}/cancel`, method: "POST", label: "agreements cancel", timeoutMs: WRITE_DEADLINE_MS, body: {} });
|
|
164
|
+
if (!answer.ok)
|
|
165
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
166
|
+
const body = answer.body;
|
|
167
|
+
writeLine(door.io.stderr, `${TAG} cancelled ${JSON.stringify({ request_id: body.request_id })}`);
|
|
168
|
+
if (door.json)
|
|
169
|
+
return emitAgentDoor(door, answer.body);
|
|
170
|
+
writeLine(door.io.stdout, body.message ?? "Request cancelled.");
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
/** A JSON object from stdin. A document's text never travels on the command line, and a terminal is refused instead of waited on. */
|
|
174
|
+
async function readJsonObject(door) {
|
|
175
|
+
if (isInteractiveStdin(door.io)) {
|
|
176
|
+
return { ok: false, reason: "nothing_piped", detail: "Pipe the draft in as JSON, e.g. `cockpit agreements draft < draft.json`. Nothing was saved." };
|
|
177
|
+
}
|
|
178
|
+
let text;
|
|
179
|
+
try {
|
|
180
|
+
text = await readPipedText(door.io.stdin, { maxChars: DRAFT_MAX_CHARS, overflowMessage: `The JSON on stdin is limited to ${DRAFT_MAX_CHARS} characters.` });
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
return { ok: false, reason: "body_too_long", detail: error instanceof Error ? error.message : String(error) };
|
|
184
|
+
}
|
|
185
|
+
let parsed;
|
|
186
|
+
try {
|
|
187
|
+
parsed = JSON.parse(text);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return { ok: false, reason: "invalid_json_body", detail: `stdin was not valid JSON (${text.length} characters read). Nothing was saved.` };
|
|
191
|
+
}
|
|
192
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
193
|
+
return { ok: false, reason: "invalid_json_body", detail: "stdin must be one JSON object, like {\"slug\":\"...\",\"title\":\"...\",\"label\":\"v1\",\"body\":\"...\"}. Nothing was saved." };
|
|
194
|
+
}
|
|
195
|
+
return { ok: true, value: parsed };
|
|
196
|
+
}
|
package/dist/commands/analyze.js
CHANGED
|
@@ -3,6 +3,7 @@ import { normalizeUrl } from "./local-args.js";
|
|
|
3
3
|
import { runSync } from "./sync.js";
|
|
4
4
|
import { describeError, isMissingFileFailure } from "../health-detail.js";
|
|
5
5
|
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorSessionFile, } from "../local-state.js";
|
|
6
|
+
import { collectorShelfHeaders } from "../shelf-headers.js";
|
|
6
7
|
export async function runAnalyze(command, io) {
|
|
7
8
|
const sync = await runAnalysisSync(command, io);
|
|
8
9
|
if (sync.exitCode !== 0 || !isUploadedSyncResult(sync.output)) {
|
|
@@ -56,6 +57,7 @@ async function queueAnalysis(command, io, sync) {
|
|
|
56
57
|
method: "POST",
|
|
57
58
|
headers: {
|
|
58
59
|
authorization: `Bearer ${session.device_token}`,
|
|
60
|
+
...collectorShelfHeaders(),
|
|
59
61
|
"content-type": "application/json",
|
|
60
62
|
},
|
|
61
63
|
body: "{}",
|