@bli-cockpit/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -0
- package/dist/adapters/car-state.js +103 -0
- package/dist/adapters/common.js +35 -0
- package/dist/adapters/git-state.js +133 -0
- package/dist/adapters/local-sources.js +59 -0
- package/dist/adapters/risk-flags.js +67 -0
- package/dist/adapters/ticket-binding.js +65 -0
- package/dist/cli.js +5 -0
- package/dist/commands/local.js +699 -0
- package/dist/commands/public-root.js +32 -0
- package/dist/local-state.js +471 -0
- package/dist/server.js +99 -0
- package/dist/spool/local-spool.js +145 -0
- package/dist/upload.js +321 -0
- package/package.json +30 -0
package/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Cockpit CLI
|
|
2
|
+
|
|
3
|
+
Public BLI Cockpit command-line interface for approved operators and interns.
|
|
4
|
+
|
|
5
|
+
The CLI pairs a local laptop with the private Cockpit dashboard, records safe
|
|
6
|
+
metadata-only work context, and uploads that metadata after dashboard-approved
|
|
7
|
+
device pairing.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm exec --yes --package=@bli-cockpit/cli -- cockpit onboard \
|
|
11
|
+
--dashboard-url <dashboard-url> \
|
|
12
|
+
--email <approved-email> \
|
|
13
|
+
--device-name "<device-name>" \
|
|
14
|
+
--repo "$PWD"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
When ticket work starts later:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm exec --yes --package=@bli-cockpit/cli -- cockpit start \
|
|
21
|
+
--ticket <ticket-id> \
|
|
22
|
+
--repo "$PWD"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
This public package intentionally excludes Cockpit admin bootstrap commands,
|
|
26
|
+
service-role credential handling, source maps, tests, and internal runbooks.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { SourceScanResultSchema, } from "@bli-cockpit/telemetry-core";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { makeSourceAdapterIdentity, parseTicketIdFromText, } from "./common.js";
|
|
5
|
+
export async function collectCarState(context) {
|
|
6
|
+
const ticketsDir = path.join(context.repoRoot, ".codex-autorunner", "tickets");
|
|
7
|
+
try {
|
|
8
|
+
const filenames = (await fs.readdir(ticketsDir))
|
|
9
|
+
.filter(isCarTicketFilename)
|
|
10
|
+
.sort();
|
|
11
|
+
const tickets = await Promise.all(filenames.map(async (filename) => parseCarTicketFile(path.join(ticketsDir, filename))));
|
|
12
|
+
const openTickets = tickets.filter((ticket) => !ticket.done);
|
|
13
|
+
const currentTicket = openTickets[0] ?? null;
|
|
14
|
+
const ticketId = currentTicket && parseTicketIdFromText(`${currentTicket.ticket_id} ${currentTicket.title}`);
|
|
15
|
+
const ticketBindingCandidates = ticketId
|
|
16
|
+
? [
|
|
17
|
+
{
|
|
18
|
+
ticket_id: ticketId,
|
|
19
|
+
binding_source: "car_ticket",
|
|
20
|
+
confidence: 0.7,
|
|
21
|
+
evidence_labels: [`car_ticket:${currentTicket.file}`],
|
|
22
|
+
},
|
|
23
|
+
]
|
|
24
|
+
: [];
|
|
25
|
+
const facts = {
|
|
26
|
+
present: true,
|
|
27
|
+
current_ticket: currentTicket,
|
|
28
|
+
open_ticket_count: openTickets.length,
|
|
29
|
+
};
|
|
30
|
+
return {
|
|
31
|
+
scan: makeCarScan({
|
|
32
|
+
context,
|
|
33
|
+
status: "ok",
|
|
34
|
+
facts,
|
|
35
|
+
ticketBindingCandidates,
|
|
36
|
+
diagnosticLabels: [
|
|
37
|
+
`car_open_tickets:${facts.open_ticket_count}`,
|
|
38
|
+
currentTicket ? `car_current_ticket:${currentTicket.file}` : "car_no_open_ticket",
|
|
39
|
+
],
|
|
40
|
+
}),
|
|
41
|
+
facts,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
const facts = {
|
|
46
|
+
present: false,
|
|
47
|
+
current_ticket: null,
|
|
48
|
+
open_ticket_count: 0,
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
scan: makeCarScan({
|
|
52
|
+
context,
|
|
53
|
+
status: "skipped",
|
|
54
|
+
facts,
|
|
55
|
+
ticketBindingCandidates: [],
|
|
56
|
+
diagnosticLabels: ["car_not_present"],
|
|
57
|
+
}),
|
|
58
|
+
facts,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export async function parseCarTicketFile(filePath) {
|
|
63
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
64
|
+
return {
|
|
65
|
+
file: path.basename(filePath),
|
|
66
|
+
ticket_id: matchFrontmatter(raw, "ticket_id") ?? path.basename(filePath, ".md"),
|
|
67
|
+
title: matchFrontmatter(raw, "title") ?? path.basename(filePath, ".md"),
|
|
68
|
+
done: (matchFrontmatter(raw, "done") ?? "false") === "true",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function makeCarScan(options) {
|
|
72
|
+
return SourceScanResultSchema.parse({
|
|
73
|
+
adapter: makeSourceAdapterIdentity("car_state", "car-state"),
|
|
74
|
+
work_context_id: options.context.workContextId,
|
|
75
|
+
status: options.status,
|
|
76
|
+
started_at: options.context.now.toISOString(),
|
|
77
|
+
finished_at: options.context.now.toISOString(),
|
|
78
|
+
events: options.facts.current_ticket
|
|
79
|
+
? [
|
|
80
|
+
{
|
|
81
|
+
source_event_id: `car-ticket:${options.facts.current_ticket.file}`,
|
|
82
|
+
event_type: "car_ticket_snapshot",
|
|
83
|
+
occurred_at: options.context.now.toISOString(),
|
|
84
|
+
redaction: {
|
|
85
|
+
privacy_classification: "metadata",
|
|
86
|
+
redaction_status: "metadata_only",
|
|
87
|
+
redacted_fields: [],
|
|
88
|
+
raw_evidence_pointer_ids: [],
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
]
|
|
92
|
+
: [],
|
|
93
|
+
ticket_binding_candidates: options.ticketBindingCandidates,
|
|
94
|
+
diagnostic_labels: options.diagnosticLabels,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
function isCarTicketFilename(filename) {
|
|
98
|
+
return /^TICKET-\d+-.+\.md$/.test(filename);
|
|
99
|
+
}
|
|
100
|
+
function matchFrontmatter(raw, key) {
|
|
101
|
+
const match = raw.match(new RegExp(`^${key}:\\s*"?([^"\\n]+)"?`, "m"));
|
|
102
|
+
return match?.[1]?.trim() ?? null;
|
|
103
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { SourceScanResultSchema, } from "@bli-cockpit/telemetry-core";
|
|
2
|
+
import { LOCAL_COLLECTOR_VERSION } from "../local-state.js";
|
|
3
|
+
export function makeSourceAdapterIdentity(captureSource, adapterName) {
|
|
4
|
+
return {
|
|
5
|
+
capture_source: captureSource,
|
|
6
|
+
adapter_name: adapterName,
|
|
7
|
+
capture_adapter_version: LOCAL_COLLECTOR_VERSION,
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
export function makeCaptureProvenance(context, captureSource) {
|
|
11
|
+
return {
|
|
12
|
+
capture_source: captureSource,
|
|
13
|
+
capture_adapter_version: LOCAL_COLLECTOR_VERSION,
|
|
14
|
+
collector_version: LOCAL_COLLECTOR_VERSION,
|
|
15
|
+
repo: context.repoRoot,
|
|
16
|
+
branch: context.branch,
|
|
17
|
+
operator_id: context.operatorId,
|
|
18
|
+
session_id: context.sessionId,
|
|
19
|
+
work_context_id: context.workContextId,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function makeUnavailableScan(context, captureSource, adapterName, reasonLabel) {
|
|
23
|
+
return SourceScanResultSchema.parse({
|
|
24
|
+
adapter: makeSourceAdapterIdentity(captureSource, adapterName),
|
|
25
|
+
work_context_id: context.workContextId,
|
|
26
|
+
status: "skipped",
|
|
27
|
+
started_at: context.now.toISOString(),
|
|
28
|
+
finished_at: context.now.toISOString(),
|
|
29
|
+
diagnostic_labels: [reasonLabel],
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
export function parseTicketIdFromText(value) {
|
|
33
|
+
const match = value.match(/\b[A-Z][A-Z0-9]{1,12}-\d+\b/);
|
|
34
|
+
return match?.[0] ?? null;
|
|
35
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { SourceScanResultSchema, } from "@bli-cockpit/telemetry-core";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { makeSourceAdapterIdentity, parseTicketIdFromText, } from "./common.js";
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
export async function collectGitState(context, runner = defaultGitCommandRunner()) {
|
|
7
|
+
const startedAt = context.now.toISOString();
|
|
8
|
+
try {
|
|
9
|
+
const repoRoot = (await runner.runGit(["rev-parse", "--show-toplevel"], context.repoRoot, 2_000)).trim() ||
|
|
10
|
+
context.repoRoot;
|
|
11
|
+
const branch = (await runner.runGit(["rev-parse", "--abbrev-ref", "HEAD"], repoRoot, 2_000)).trim() ||
|
|
12
|
+
context.branch;
|
|
13
|
+
const porcelain = await runner.runGit(["status", "--porcelain=v1"], repoRoot, 2_000);
|
|
14
|
+
const unstagedNumstat = await runner.runGit(["diff", "--numstat"], repoRoot, 2_000);
|
|
15
|
+
const stagedNumstat = await runner.runGit(["diff", "--cached", "--numstat"], repoRoot, 2_000);
|
|
16
|
+
const fileStats = [...parseNumstat(unstagedNumstat), ...parseNumstat(stagedNumstat)];
|
|
17
|
+
const addedLines = fileStats.reduce((sum, file) => sum + file.added_lines, 0);
|
|
18
|
+
const deletedLines = fileStats.reduce((sum, file) => sum + file.deleted_lines, 0);
|
|
19
|
+
const changedPaths = [
|
|
20
|
+
...new Set([
|
|
21
|
+
...parsePorcelainPaths(porcelain),
|
|
22
|
+
...fileStats.map((file) => file.path),
|
|
23
|
+
]),
|
|
24
|
+
].sort();
|
|
25
|
+
const ticketIdFromBranch = parseTicketIdFromText(branch);
|
|
26
|
+
const ticketBindingCandidates = ticketIdFromBranch
|
|
27
|
+
? [
|
|
28
|
+
{
|
|
29
|
+
ticket_id: ticketIdFromBranch,
|
|
30
|
+
binding_source: "branch_name",
|
|
31
|
+
confidence: 0.85,
|
|
32
|
+
evidence_labels: [`branch:${branch}`],
|
|
33
|
+
},
|
|
34
|
+
]
|
|
35
|
+
: [];
|
|
36
|
+
const facts = {
|
|
37
|
+
repo_root: repoRoot,
|
|
38
|
+
branch,
|
|
39
|
+
changed_file_count: changedPaths.length,
|
|
40
|
+
added_lines: addedLines,
|
|
41
|
+
deleted_lines: deletedLines,
|
|
42
|
+
changed_paths: changedPaths,
|
|
43
|
+
ticket_id_from_branch: ticketIdFromBranch,
|
|
44
|
+
};
|
|
45
|
+
const scan = makeGitScan({
|
|
46
|
+
context,
|
|
47
|
+
startedAt,
|
|
48
|
+
status: "ok",
|
|
49
|
+
branch,
|
|
50
|
+
facts,
|
|
51
|
+
ticketBindingCandidates,
|
|
52
|
+
diagnosticLabels: [
|
|
53
|
+
`changed_files:${facts.changed_file_count}`,
|
|
54
|
+
`added_lines:${facts.added_lines}`,
|
|
55
|
+
`deleted_lines:${facts.deleted_lines}`,
|
|
56
|
+
],
|
|
57
|
+
});
|
|
58
|
+
return { scan, facts };
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
const scan = makeGitScan({
|
|
62
|
+
context,
|
|
63
|
+
startedAt,
|
|
64
|
+
status: "failed",
|
|
65
|
+
branch: context.branch,
|
|
66
|
+
facts: null,
|
|
67
|
+
ticketBindingCandidates: [],
|
|
68
|
+
diagnosticLabels: [
|
|
69
|
+
`git_unavailable:${error instanceof Error ? error.message : String(error)}`,
|
|
70
|
+
],
|
|
71
|
+
});
|
|
72
|
+
return { scan, facts: { git_unavailable: true } };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export function parseNumstat(raw) {
|
|
76
|
+
return raw
|
|
77
|
+
.split("\n")
|
|
78
|
+
.map((line) => line.trim())
|
|
79
|
+
.filter(Boolean)
|
|
80
|
+
.map((line) => {
|
|
81
|
+
const [addedRaw, deletedRaw, filePath] = line.split(/\s+/, 3);
|
|
82
|
+
return {
|
|
83
|
+
path: filePath ?? "unknown",
|
|
84
|
+
added_lines: Number.parseInt(addedRaw ?? "0", 10) || 0,
|
|
85
|
+
deleted_lines: Number.parseInt(deletedRaw ?? "0", 10) || 0,
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
export function parsePorcelainPaths(raw) {
|
|
90
|
+
return raw
|
|
91
|
+
.split("\n")
|
|
92
|
+
.filter((line) => line.trim())
|
|
93
|
+
.map((line) => line.slice(3).trim())
|
|
94
|
+
.filter(Boolean);
|
|
95
|
+
}
|
|
96
|
+
function makeGitScan(options) {
|
|
97
|
+
return SourceScanResultSchema.parse({
|
|
98
|
+
adapter: makeSourceAdapterIdentity("git_state", "git-state"),
|
|
99
|
+
work_context_id: options.context.workContextId,
|
|
100
|
+
status: options.status,
|
|
101
|
+
started_at: options.startedAt,
|
|
102
|
+
finished_at: options.context.now.toISOString(),
|
|
103
|
+
events: options.facts
|
|
104
|
+
? [
|
|
105
|
+
{
|
|
106
|
+
source_event_id: `git-state:${options.branch}`,
|
|
107
|
+
event_type: "git_state_snapshot",
|
|
108
|
+
occurred_at: options.context.now.toISOString(),
|
|
109
|
+
redaction: {
|
|
110
|
+
privacy_classification: "metadata",
|
|
111
|
+
redaction_status: "metadata_only",
|
|
112
|
+
redacted_fields: [],
|
|
113
|
+
raw_evidence_pointer_ids: [],
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
]
|
|
117
|
+
: [],
|
|
118
|
+
ticket_binding_candidates: options.ticketBindingCandidates,
|
|
119
|
+
diagnostic_labels: options.diagnosticLabels,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
function defaultGitCommandRunner() {
|
|
123
|
+
return {
|
|
124
|
+
async runGit(args, cwd, timeoutMs) {
|
|
125
|
+
const { stdout } = await execFileAsync("git", args, {
|
|
126
|
+
cwd,
|
|
127
|
+
timeout: timeoutMs,
|
|
128
|
+
maxBuffer: 1024 * 1024,
|
|
129
|
+
});
|
|
130
|
+
return stdout;
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { collectCarState } from "./car-state.js";
|
|
2
|
+
import { makeUnavailableScan, } from "./common.js";
|
|
3
|
+
import { collectGitState } from "./git-state.js";
|
|
4
|
+
import { generateDumbRiskFlags } from "./risk-flags.js";
|
|
5
|
+
import { resolveTicketBinding, } from "./ticket-binding.js";
|
|
6
|
+
export async function runLocalSourceCollectors(options) {
|
|
7
|
+
const context = {
|
|
8
|
+
repoRoot: options.repoRoot,
|
|
9
|
+
branch: options.branch,
|
|
10
|
+
operatorId: options.operatorId,
|
|
11
|
+
sessionId: options.sessionId,
|
|
12
|
+
workContextId: options.workContextId,
|
|
13
|
+
now: options.now ?? new Date(),
|
|
14
|
+
};
|
|
15
|
+
const git = await collectGitState(context);
|
|
16
|
+
const car = await collectCarState(context);
|
|
17
|
+
const unavailableScans = [
|
|
18
|
+
makeUnavailableScan(context, "codex_jsonl", "codex-jsonl", "codex_session_file_not_configured"),
|
|
19
|
+
makeUnavailableScan(context, "codex_otel", "codex-otel", "codex_otel_export_not_configured"),
|
|
20
|
+
makeUnavailableScan(context, "github_state", "github-state", "github_auth_not_configured"),
|
|
21
|
+
makeUnavailableScan(context, "linear_state", "linear-state", "linear_auth_not_configured"),
|
|
22
|
+
makeUnavailableScan(context, "mcp_local", "mcp-local", "mcp_activity_not_configured"),
|
|
23
|
+
makeUnavailableScan(context, "claude_hooks", "claude-hooks", "claude_hooks_not_present"),
|
|
24
|
+
];
|
|
25
|
+
const candidates = collectBindingCandidates([
|
|
26
|
+
git.scan,
|
|
27
|
+
car.scan,
|
|
28
|
+
...unavailableScans,
|
|
29
|
+
]);
|
|
30
|
+
const binding = resolveTicketBinding({
|
|
31
|
+
activeWorkContext: options.activeWorkContext,
|
|
32
|
+
candidates,
|
|
33
|
+
});
|
|
34
|
+
const gitFacts = isGitStateFacts(git.facts) ? git.facts : null;
|
|
35
|
+
const carFacts = isCarStateFacts(car.facts) ? car.facts : null;
|
|
36
|
+
const riskFlags = generateDumbRiskFlags({
|
|
37
|
+
context,
|
|
38
|
+
git: gitFacts,
|
|
39
|
+
binding,
|
|
40
|
+
});
|
|
41
|
+
return {
|
|
42
|
+
scans: [git.scan, car.scan, ...unavailableScans],
|
|
43
|
+
facts: {
|
|
44
|
+
git: gitFacts,
|
|
45
|
+
car: carFacts,
|
|
46
|
+
},
|
|
47
|
+
binding,
|
|
48
|
+
risk_flags: riskFlags,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function collectBindingCandidates(scans) {
|
|
52
|
+
return scans.flatMap((scan) => scan.ticket_binding_candidates);
|
|
53
|
+
}
|
|
54
|
+
function isGitStateFacts(value) {
|
|
55
|
+
return Boolean(value && typeof value === "object" && "repo_root" in value);
|
|
56
|
+
}
|
|
57
|
+
function isCarStateFacts(value) {
|
|
58
|
+
return Boolean(value && typeof value === "object" && "present" in value);
|
|
59
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { RiskFlagSchema, } from "@bli-cockpit/telemetry-core";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import { makeCaptureProvenance, } from "./common.js";
|
|
4
|
+
export function generateDumbRiskFlags(input) {
|
|
5
|
+
const flags = [];
|
|
6
|
+
const git = input.git ?? null;
|
|
7
|
+
const changedLines = (git?.added_lines ?? 0) + (git?.deleted_lines ?? 0);
|
|
8
|
+
if (git && changedLines >= 800 && !input.hasPlanSignal) {
|
|
9
|
+
flags.push(makeRiskFlag(input, "no_plan_signal_before_large_edit", [
|
|
10
|
+
"missing_plan_signal",
|
|
11
|
+
"large_edit",
|
|
12
|
+
]));
|
|
13
|
+
}
|
|
14
|
+
if (git &&
|
|
15
|
+
git.changed_file_count >= 1 &&
|
|
16
|
+
git.added_lines >= 200 &&
|
|
17
|
+
git.deleted_lines >= 200) {
|
|
18
|
+
flags.push(makeRiskFlag(input, "many_add_revert_loops", ["add_revert_loop"]));
|
|
19
|
+
}
|
|
20
|
+
if (input.reviewFindingPriorities?.some((priority) => ["p1", "p2", "P1", "P2"].includes(priority))) {
|
|
21
|
+
flags.push(makeRiskFlag(input, "p1_or_p2_review_finding", [
|
|
22
|
+
input.reviewFindingPriorities.some((priority) => priority.toLowerCase() === "p1")
|
|
23
|
+
? "p1_review_finding"
|
|
24
|
+
: "p2_review_finding",
|
|
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
|
+
}
|
|
41
|
+
const branchTicket = git?.ticket_id_from_branch;
|
|
42
|
+
if (branchTicket &&
|
|
43
|
+
input.binding.selected_ticket_id &&
|
|
44
|
+
branchTicket !== input.binding.selected_ticket_id) {
|
|
45
|
+
flags.push(makeRiskFlag(input, "branch_ticket_mismatch", [
|
|
46
|
+
"branch_ticket_disagreement",
|
|
47
|
+
]));
|
|
48
|
+
}
|
|
49
|
+
return flags;
|
|
50
|
+
}
|
|
51
|
+
function makeRiskFlag(input, riskType, evidenceLabels) {
|
|
52
|
+
return RiskFlagSchema.parse({
|
|
53
|
+
risk_flag_id: `risk-${crypto
|
|
54
|
+
.createHash("sha256")
|
|
55
|
+
.update(`${riskType}:${input.context.workContextId}:${evidenceLabels.join(",")}`)
|
|
56
|
+
.digest("hex")
|
|
57
|
+
.slice(0, 16)}`,
|
|
58
|
+
risk_type: riskType,
|
|
59
|
+
severity: riskType === "p1_or_p2_review_finding" || riskType === "branch_ticket_mismatch"
|
|
60
|
+
? "high"
|
|
61
|
+
: "warning",
|
|
62
|
+
observed_at: input.context.now.toISOString(),
|
|
63
|
+
evidence_labels: evidenceLabels,
|
|
64
|
+
evidence_pointer_ids: [],
|
|
65
|
+
provenance: makeCaptureProvenance(input.context, "collector_runtime"),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
const BINDING_PRIORITY = [
|
|
2
|
+
"active_work_context",
|
|
3
|
+
"branch_name",
|
|
4
|
+
"linear_assignment",
|
|
5
|
+
"car_ticket",
|
|
6
|
+
"manual_picker",
|
|
7
|
+
"github_pr",
|
|
8
|
+
"mcp_context",
|
|
9
|
+
];
|
|
10
|
+
export function resolveTicketBinding(options) {
|
|
11
|
+
const activeCandidate = options.activeWorkContext?.active_ticket_id
|
|
12
|
+
? {
|
|
13
|
+
ticket_id: options.activeWorkContext.active_ticket_id,
|
|
14
|
+
binding_source: "active_work_context",
|
|
15
|
+
confidence: 1,
|
|
16
|
+
evidence_labels: ["active_work_context"],
|
|
17
|
+
}
|
|
18
|
+
: null;
|
|
19
|
+
const candidates = dedupeCandidates([
|
|
20
|
+
...(activeCandidate ? [activeCandidate] : []),
|
|
21
|
+
...options.candidates,
|
|
22
|
+
]);
|
|
23
|
+
for (const source of BINDING_PRIORITY) {
|
|
24
|
+
const sourceCandidates = candidates.filter((candidate) => candidate.binding_source === source);
|
|
25
|
+
const uniqueTicketIds = new Set(sourceCandidates.map((candidate) => candidate.ticket_id));
|
|
26
|
+
if (uniqueTicketIds.size === 1) {
|
|
27
|
+
const selected = sourceCandidates[0];
|
|
28
|
+
return {
|
|
29
|
+
state: "bound",
|
|
30
|
+
selected_ticket_id: selected.ticket_id,
|
|
31
|
+
selected_source: selected.binding_source,
|
|
32
|
+
candidates,
|
|
33
|
+
diagnostic_labels: [`bound_by:${source}`],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (uniqueTicketIds.size > 1) {
|
|
37
|
+
return {
|
|
38
|
+
state: "ambiguous",
|
|
39
|
+
selected_ticket_id: null,
|
|
40
|
+
selected_source: source,
|
|
41
|
+
candidates,
|
|
42
|
+
diagnostic_labels: [`ambiguous:${source}`],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
state: "unbound",
|
|
48
|
+
selected_ticket_id: null,
|
|
49
|
+
selected_source: null,
|
|
50
|
+
candidates,
|
|
51
|
+
diagnostic_labels: ["no_ticket_binding_candidate"],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function dedupeCandidates(candidates) {
|
|
55
|
+
const seen = new Set();
|
|
56
|
+
const deduped = [];
|
|
57
|
+
for (const candidate of candidates) {
|
|
58
|
+
const key = `${candidate.binding_source}:${candidate.ticket_id}`;
|
|
59
|
+
if (seen.has(key))
|
|
60
|
+
continue;
|
|
61
|
+
seen.add(key);
|
|
62
|
+
deduped.push(candidate);
|
|
63
|
+
}
|
|
64
|
+
return deduped;
|
|
65
|
+
}
|