@bli-cockpit/telemetry-core 0.1.29 → 0.1.31

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.
@@ -99,6 +99,11 @@ export declare const CollectorHeartbeatSchema: z.ZodObject<{
99
99
  }, z.core.$strict>;
100
100
  reasons: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
101
101
  bin_found: z.ZodOptional<z.ZodBoolean>;
102
+ hook_runs_24h: z.ZodOptional<z.ZodNumber>;
103
+ hook_timeouts_24h: z.ZodOptional<z.ZodNumber>;
104
+ hook_printed_24h: z.ZodOptional<z.ZodNumber>;
105
+ hook_failed_24h: z.ZodOptional<z.ZodNumber>;
106
+ hook_stats_reason: z.ZodOptional<z.ZodString>;
102
107
  }, z.core.$strict>>;
103
108
  setup_receipt: z.ZodOptional<z.ZodObject<{
104
109
  schema_version: z.ZodLiteral<"setup-receipt.v1">;
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@ export * from "./evidence-reconcile.js";
9
9
  export * from "./evidence-upload.js";
10
10
  export * from "./ingest-dto.js";
11
11
  export * from "./local-config.js";
12
+ export * from "./memory-hook-stats.js";
12
13
  export * from "./memory-install-receipt.js";
13
14
  export * from "./paths.js";
14
15
  export * from "./privacy.js";
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ export * from "./evidence-reconcile.js";
9
9
  export * from "./evidence-upload.js";
10
10
  export * from "./ingest-dto.js";
11
11
  export * from "./local-config.js";
12
+ export * from "./memory-hook-stats.js";
12
13
  export * from "./memory-install-receipt.js";
13
14
  export * from "./paths.js";
14
15
  export * from "./privacy.js";
@@ -401,6 +401,8 @@ export declare const TelemetryIngestEnvelopeSchema: z.ZodObject<{
401
401
  started_at: z.ZodString;
402
402
  updated_at: z.ZodOptional<z.ZodString>;
403
403
  active_ticket_id: z.ZodOptional<z.ZodString>;
404
+ tower_issue_id: z.ZodOptional<z.ZodString>;
405
+ linear_ticket_id: z.ZodOptional<z.ZodString>;
404
406
  ticket_binding_candidates: z.ZodDefault<z.ZodArray<z.ZodObject<{
405
407
  ticket_id: z.ZodString;
406
408
  binding_source: z.ZodEnum<{
@@ -0,0 +1,105 @@
1
+ /**
2
+ * THE HOOK COUNTER, SHARED (BLI-3788).
3
+ *
4
+ * `@bli-cockpit/memory-mcp` WRITES this file — one hook run at a time, on the
5
+ * machine, counts only — and `@bli-cockpit/local-collector` READS it once a
6
+ * tick to put `hook_timeouts_24h` on the heartbeat's memory receipt. The two
7
+ * packages are published separately and never import each other, so the
8
+ * contract between them lives here, in the one package they both depend on:
9
+ * the file's address, its shape, and the rule for turning hourly buckets into
10
+ * a 24-hour number.
11
+ *
12
+ * The alternative was two copies of a JSON shape kept in step by comment, and
13
+ * BLI-2541 is what that costs — a fixture built from our own formatting rather
14
+ * than from the real thing, green while the fleet was broken.
15
+ *
16
+ * WHY IT EXISTS AT ALL: a prompt hook that misses its deadline prints NOTHING,
17
+ * deliberately. That is right for the person and blind for the operator, and
18
+ * QA tick 18 found the recall silently missing on 4 of 6 runs with no surface
19
+ * anywhere reporting it. These counts are that silence made countable.
20
+ *
21
+ * WHAT MAY NEVER BE IN IT: a prompt, a memory, a container tag, a path, a
22
+ * token, or any string that did not come from the hook's own closed reason
23
+ * vocabulary. Counts and hour labels, nothing else.
24
+ */
25
+ import { z } from "zod";
26
+ export declare const MEMORY_HOOK_STATS_SCHEMA_VERSION = "memory-hook-stats.v1";
27
+ /** The file name, in the collector's state directory. One spelling, here. */
28
+ export declare const MEMORY_HOOK_STATS_FILE_NAME = "memory-hook-stats.json";
29
+ /** The three hook events, spelled as the bin's subcommands are. */
30
+ export declare const MEMORY_HOOK_STAT_EVENTS: readonly ["session-start", "prompt", "stop"];
31
+ export type MemoryHookStatEvent = (typeof MEMORY_HOOK_STAT_EVENTS)[number];
32
+ /**
33
+ * One hour of one event.
34
+ *
35
+ * `timeouts` is counted apart from `failed` (a refusal, an outage, an
36
+ * unpaired machine — different fixes) and apart from `empty` (the record was
37
+ * genuinely silent, which is an answer and not a fault). Collapsing them is
38
+ * exactly how "the door is slow" and "the shelf is empty" became one
39
+ * unreadable number in the first place.
40
+ */
41
+ export declare const MemoryHookCountsSchema: z.ZodObject<{
42
+ runs: z.ZodNumber;
43
+ printed: z.ZodNumber;
44
+ empty: z.ZodNumber;
45
+ timeouts: z.ZodNumber;
46
+ failed: z.ZodNumber;
47
+ skipped: z.ZodNumber;
48
+ }, z.core.$strict>;
49
+ export type MemoryHookCounts = z.infer<typeof MemoryHookCountsSchema>;
50
+ /** `YYYY-MM-DDTHH` in UTC — the bucket key, and the reason the window is exact. */
51
+ export declare const MEMORY_HOOK_BUCKET_PATTERN: RegExp;
52
+ export declare const MemoryHookStatsFileSchema: z.ZodObject<{
53
+ schema_version: z.ZodLiteral<"memory-hook-stats.v1">;
54
+ buckets: z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodEnum<{
55
+ "session-start": "session-start";
56
+ prompt: "prompt";
57
+ stop: "stop";
58
+ }> & z.core.$partial, z.ZodObject<{
59
+ runs: z.ZodNumber;
60
+ printed: z.ZodNumber;
61
+ empty: z.ZodNumber;
62
+ timeouts: z.ZodNumber;
63
+ failed: z.ZodNumber;
64
+ skipped: z.ZodNumber;
65
+ }, z.core.$strict>>>;
66
+ }, z.core.$strict>;
67
+ export type MemoryHookStatsFile = z.infer<typeof MemoryHookStatsFileSchema>;
68
+ export declare function memoryHookStatsFilePath(homeDir: string): string;
69
+ /** The hour a moment belongs to. UTC, so two machines agree on a boundary. */
70
+ export declare function memoryHookHourBucket(at: Date): string;
71
+ export declare function emptyMemoryHookCounts(): MemoryHookCounts;
72
+ export interface MemoryHookWindow {
73
+ runs: number;
74
+ printed: number;
75
+ timeouts: number;
76
+ failed: number;
77
+ /** Buckets that were inside the window and had something in them. */
78
+ hours: number;
79
+ }
80
+ /**
81
+ * Sum one event's buckets over the last `hours` hours, ending at `now`.
82
+ *
83
+ * The window is inclusive of the hour `now` falls in, so a run three minutes
84
+ * ago counts; that makes the last bucket partial, which is the honest
85
+ * direction for a gauge whose whole job is to notice that something is going
86
+ * wrong RIGHT NOW.
87
+ */
88
+ export declare function summariseMemoryHookWindow(file: MemoryHookStatsFile, event: MemoryHookStatEvent, options: {
89
+ now: Date;
90
+ hours?: number;
91
+ }): MemoryHookWindow;
92
+ /**
93
+ * Parse what is on disk, or say why not.
94
+ *
95
+ * A file this version does not recognise is REPLACED by the writer and
96
+ * reported as unreadable by the reader — never merged into a shape it was not
97
+ * written in, which would publish a number nobody can explain.
98
+ */
99
+ export declare function parseMemoryHookStats(raw: string): {
100
+ ok: true;
101
+ file: MemoryHookStatsFile;
102
+ } | {
103
+ ok: false;
104
+ reason: string;
105
+ };
@@ -0,0 +1,122 @@
1
+ /**
2
+ * THE HOOK COUNTER, SHARED (BLI-3788).
3
+ *
4
+ * `@bli-cockpit/memory-mcp` WRITES this file — one hook run at a time, on the
5
+ * machine, counts only — and `@bli-cockpit/local-collector` READS it once a
6
+ * tick to put `hook_timeouts_24h` on the heartbeat's memory receipt. The two
7
+ * packages are published separately and never import each other, so the
8
+ * contract between them lives here, in the one package they both depend on:
9
+ * the file's address, its shape, and the rule for turning hourly buckets into
10
+ * a 24-hour number.
11
+ *
12
+ * The alternative was two copies of a JSON shape kept in step by comment, and
13
+ * BLI-2541 is what that costs — a fixture built from our own formatting rather
14
+ * than from the real thing, green while the fleet was broken.
15
+ *
16
+ * WHY IT EXISTS AT ALL: a prompt hook that misses its deadline prints NOTHING,
17
+ * deliberately. That is right for the person and blind for the operator, and
18
+ * QA tick 18 found the recall silently missing on 4 of 6 runs with no surface
19
+ * anywhere reporting it. These counts are that silence made countable.
20
+ *
21
+ * WHAT MAY NEVER BE IN IT: a prompt, a memory, a container tag, a path, a
22
+ * token, or any string that did not come from the hook's own closed reason
23
+ * vocabulary. Counts and hour labels, nothing else.
24
+ */
25
+ import { z } from "zod";
26
+ import { getUserLocalCockpitPaths } from "./paths.js";
27
+ export const MEMORY_HOOK_STATS_SCHEMA_VERSION = "memory-hook-stats.v1";
28
+ /** The file name, in the collector's state directory. One spelling, here. */
29
+ export const MEMORY_HOOK_STATS_FILE_NAME = "memory-hook-stats.json";
30
+ /** The three hook events, spelled as the bin's subcommands are. */
31
+ export const MEMORY_HOOK_STAT_EVENTS = ["session-start", "prompt", "stop"];
32
+ /**
33
+ * One hour of one event.
34
+ *
35
+ * `timeouts` is counted apart from `failed` (a refusal, an outage, an
36
+ * unpaired machine — different fixes) and apart from `empty` (the record was
37
+ * genuinely silent, which is an answer and not a fault). Collapsing them is
38
+ * exactly how "the door is slow" and "the shelf is empty" became one
39
+ * unreadable number in the first place.
40
+ */
41
+ export const MemoryHookCountsSchema = z
42
+ .object({
43
+ runs: z.number().int().min(0),
44
+ printed: z.number().int().min(0),
45
+ empty: z.number().int().min(0),
46
+ timeouts: z.number().int().min(0),
47
+ failed: z.number().int().min(0),
48
+ skipped: z.number().int().min(0),
49
+ })
50
+ .strict();
51
+ /** `YYYY-MM-DDTHH` in UTC — the bucket key, and the reason the window is exact. */
52
+ export const MEMORY_HOOK_BUCKET_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}$/u;
53
+ export const MemoryHookStatsFileSchema = z
54
+ .object({
55
+ schema_version: z.literal(MEMORY_HOOK_STATS_SCHEMA_VERSION),
56
+ // `partialRecord`, not `record`: a record keyed by an enum is EXHAUSTIVE
57
+ // in zod 4 — every event would be required — and an hour in which only
58
+ // the prompt hook ran is the ordinary case, not a malformed file.
59
+ buckets: z.record(z.string().regex(MEMORY_HOOK_BUCKET_PATTERN), z.partialRecord(z.enum(MEMORY_HOOK_STAT_EVENTS), MemoryHookCountsSchema)),
60
+ })
61
+ .strict();
62
+ export function memoryHookStatsFilePath(homeDir) {
63
+ const paths = getUserLocalCockpitPaths(homeDir);
64
+ const separator = paths.state_dir.includes("\\") ? "\\" : "/";
65
+ return `${paths.state_dir}${separator}${MEMORY_HOOK_STATS_FILE_NAME}`;
66
+ }
67
+ /** The hour a moment belongs to. UTC, so two machines agree on a boundary. */
68
+ export function memoryHookHourBucket(at) {
69
+ return at.toISOString().slice(0, 13);
70
+ }
71
+ export function emptyMemoryHookCounts() {
72
+ return { runs: 0, printed: 0, empty: 0, timeouts: 0, failed: 0, skipped: 0 };
73
+ }
74
+ /**
75
+ * Sum one event's buckets over the last `hours` hours, ending at `now`.
76
+ *
77
+ * The window is inclusive of the hour `now` falls in, so a run three minutes
78
+ * ago counts; that makes the last bucket partial, which is the honest
79
+ * direction for a gauge whose whole job is to notice that something is going
80
+ * wrong RIGHT NOW.
81
+ */
82
+ export function summariseMemoryHookWindow(file, event, options) {
83
+ const hours = options.hours ?? 24;
84
+ const earliest = memoryHookHourBucket(new Date(options.now.getTime() - (hours - 1) * 3_600_000));
85
+ const latest = memoryHookHourBucket(options.now);
86
+ const total = { runs: 0, printed: 0, timeouts: 0, failed: 0, hours: 0 };
87
+ for (const [bucket, events] of Object.entries(file.buckets)) {
88
+ // Lexical order is chronological for this key, which is the only reason a
89
+ // string comparison is allowed to stand in for a date one here.
90
+ if (bucket < earliest || bucket > latest)
91
+ continue;
92
+ const counts = events[event];
93
+ if (!counts)
94
+ continue;
95
+ total.runs += counts.runs;
96
+ total.printed += counts.printed;
97
+ total.timeouts += counts.timeouts;
98
+ total.failed += counts.failed;
99
+ total.hours += 1;
100
+ }
101
+ return total;
102
+ }
103
+ /**
104
+ * Parse what is on disk, or say why not.
105
+ *
106
+ * A file this version does not recognise is REPLACED by the writer and
107
+ * reported as unreadable by the reader — never merged into a shape it was not
108
+ * written in, which would publish a number nobody can explain.
109
+ */
110
+ export function parseMemoryHookStats(raw) {
111
+ let parsed;
112
+ try {
113
+ parsed = JSON.parse(raw);
114
+ }
115
+ catch {
116
+ return { ok: false, reason: "hook_stats_unparseable" };
117
+ }
118
+ const result = MemoryHookStatsFileSchema.safeParse(parsed);
119
+ if (!result.success)
120
+ return { ok: false, reason: "hook_stats_unrecognised_shape" };
121
+ return { ok: true, file: result.data };
122
+ }
@@ -109,6 +109,11 @@ export declare const MemoryInstallReceiptSchema: z.ZodObject<{
109
109
  }, z.core.$strict>;
110
110
  reasons: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
111
111
  bin_found: z.ZodOptional<z.ZodBoolean>;
112
+ hook_runs_24h: z.ZodOptional<z.ZodNumber>;
113
+ hook_timeouts_24h: z.ZodOptional<z.ZodNumber>;
114
+ hook_printed_24h: z.ZodOptional<z.ZodNumber>;
115
+ hook_failed_24h: z.ZodOptional<z.ZodNumber>;
116
+ hook_stats_reason: z.ZodOptional<z.ZodString>;
112
117
  }, z.core.$strict>;
113
118
  export type MemoryInstallReceipt = z.infer<typeof MemoryInstallReceiptSchema>;
114
119
  /** Every piece is `ok`. The one question the board asks first. */
@@ -103,6 +103,36 @@ export const MemoryInstallReceiptSchema = z
103
103
  * (`no_bin_no_write`).
104
104
  */
105
105
  bin_found: z.boolean().optional(),
106
+ /**
107
+ * DID THE HOOKS ACTUALLY WORK? (BLI-3788)
108
+ *
109
+ * The five words above say a hook is REGISTERED. They cannot say whether
110
+ * it did anything, and a prompt hook that loses its deadline prints
111
+ * nothing by design — so a machine could read `memory: claude mcp ✓ hooks
112
+ * ✓` while two thirds of that person's turns silently got no recall (QA
113
+ * tick 18). These four counts, summed over the last 24 hours from the
114
+ * hook's own on-disk counter, are that gap closed:
115
+ *
116
+ * `hook_runs_24h` hooks that ran at all — the denominator, without
117
+ * which a timeout count says nothing
118
+ * `hook_timeouts_24h` runs that lost the clock and printed nothing
119
+ * `hook_printed_24h` runs that injected a block
120
+ * `hook_failed_24h` runs that could not run (a refusal, an outage)
121
+ *
122
+ * An honestly EMPTY shelf is in none of them but `runs`, because zero hits
123
+ * is a real answer and not a fault. All four are optional: a collector or
124
+ * a memory-mcp older than this ticket reports none, and absent must read
125
+ * as "not reported", never as zero.
126
+ *
127
+ * They are a FLOOR. Two hooks finishing in the same millisecond can lose
128
+ * one increment to the file rename, which understates and cannot invent.
129
+ */
130
+ hook_runs_24h: z.number().int().min(0).optional(),
131
+ hook_timeouts_24h: z.number().int().min(0).optional(),
132
+ hook_printed_24h: z.number().int().min(0).optional(),
133
+ hook_failed_24h: z.number().int().min(0).optional(),
134
+ /** Named when the counts are absent because the file could not be read. */
135
+ hook_stats_reason: ReasonLabelSchema.optional(),
106
136
  })
107
137
  .strict();
108
138
  /** Every piece is `ok`. The one question the board asks first. */
@@ -1 +1,44 @@
1
+ /**
2
+ * WHICH TRACKER MINTED THIS ID (BLI-3779).
3
+ *
4
+ * Tower runs its own issue tracker beside Linear during the dual-run, and both
5
+ * spell an identifier the same way: a team key, a hyphen, a number. Two facts
6
+ * tell them apart, and neither is a guess:
7
+ *
8
+ * - **The number.** Tower's tracker mints from `BLI-10001` up;
9
+ * Linear's BLI team is in the 3,000s. An id at or above
10
+ * `TOWER_NATIVE_ISSUE_NUMBER_FLOOR` is one only Tower could have issued.
11
+ * The dashboard's `lib/jarvis/chat-v2/issue-word-boundary.ts` (BLI-3783)
12
+ * re-exports this constant rather than keeping a second copy of it.
13
+ * - **The title.** A Tower-native row that mirrors a Linear ticket ends its
14
+ * title with `[BLI-3779]` — Edward's dogfooding convention, 2026-09-05 —
15
+ * so the Linear id a Tower row stands for is READ, never inferred.
16
+ *
17
+ * Exact after upper-casing, never fuzzy: the `person-identity.ts` discipline,
18
+ * for the same reason. A wrong match binds a session's work to somebody
19
+ * else's ticket.
20
+ */
21
+ /**
22
+ * The first identifier Tower's own tracker mints. Linear's BLI team is in the
23
+ * 3,000s as of 2026-09-05 and would need six thousand more tickets to reach
24
+ * it; if it ever does, this constant moves and `work_issues.source` becomes
25
+ * the only honest discriminator.
26
+ */
27
+ export declare const TOWER_NATIVE_ISSUE_NUMBER_FLOOR = 10000;
1
28
  export declare function parseTicketIdFromText(value: string): string | null;
29
+ /** Is this the shape either tracker uses for an identifier? */
30
+ export declare function looksLikeIssueIdentifier(value: string): boolean;
31
+ /** The one spelling of an identifier: trimmed and upper-cased, or null. */
32
+ export declare function normalizeIssueIdentifier(value: string): string | null;
33
+ /**
34
+ * Is this an id only TOWER's tracker could have minted? A fact about the
35
+ * number, not a claim about any row: `BLI-10042` cannot be in Linear whether
36
+ * or not it is in Tower.
37
+ */
38
+ export declare function isTowerNativeIssueId(value: string): boolean;
39
+ /**
40
+ * The Linear id a Tower-native title mirrors: the LAST `[BLI-3779]` in the
41
+ * title, because a title may quote another ticket in its prose and the
42
+ * convention puts the mirrored id at the end.
43
+ */
44
+ export declare function mirroredTicketIdInTitle(title: string): string | null;
package/dist/ticket-id.js CHANGED
@@ -1,4 +1,61 @@
1
+ /**
2
+ * WHICH TRACKER MINTED THIS ID (BLI-3779).
3
+ *
4
+ * Tower runs its own issue tracker beside Linear during the dual-run, and both
5
+ * spell an identifier the same way: a team key, a hyphen, a number. Two facts
6
+ * tell them apart, and neither is a guess:
7
+ *
8
+ * - **The number.** Tower's tracker mints from `BLI-10001` up;
9
+ * Linear's BLI team is in the 3,000s. An id at or above
10
+ * `TOWER_NATIVE_ISSUE_NUMBER_FLOOR` is one only Tower could have issued.
11
+ * The dashboard's `lib/jarvis/chat-v2/issue-word-boundary.ts` (BLI-3783)
12
+ * re-exports this constant rather than keeping a second copy of it.
13
+ * - **The title.** A Tower-native row that mirrors a Linear ticket ends its
14
+ * title with `[BLI-3779]` — Edward's dogfooding convention, 2026-09-05 —
15
+ * so the Linear id a Tower row stands for is READ, never inferred.
16
+ *
17
+ * Exact after upper-casing, never fuzzy: the `person-identity.ts` discipline,
18
+ * for the same reason. A wrong match binds a session's work to somebody
19
+ * else's ticket.
20
+ */
21
+ /** `BLI-3654`, `TRI-12` — a team key, a hyphen, a number, and nothing else. */
22
+ const ISSUE_IDENTIFIER = /^[A-Z][A-Z0-9]{1,12}-\d+$/;
23
+ /**
24
+ * The first identifier Tower's own tracker mints. Linear's BLI team is in the
25
+ * 3,000s as of 2026-09-05 and would need six thousand more tickets to reach
26
+ * it; if it ever does, this constant moves and `work_issues.source` becomes
27
+ * the only honest discriminator.
28
+ */
29
+ export const TOWER_NATIVE_ISSUE_NUMBER_FLOOR = 10_000;
1
30
  export function parseTicketIdFromText(value) {
2
31
  const match = value.match(/\b[A-Z][A-Z0-9]{1,12}-\d+\b/);
3
32
  return match?.[0] ?? null;
4
33
  }
34
+ /** Is this the shape either tracker uses for an identifier? */
35
+ export function looksLikeIssueIdentifier(value) {
36
+ return ISSUE_IDENTIFIER.test(value.trim().toUpperCase());
37
+ }
38
+ /** The one spelling of an identifier: trimmed and upper-cased, or null. */
39
+ export function normalizeIssueIdentifier(value) {
40
+ const normalized = value.trim().toUpperCase();
41
+ return ISSUE_IDENTIFIER.test(normalized) ? normalized : null;
42
+ }
43
+ /**
44
+ * Is this an id only TOWER's tracker could have minted? A fact about the
45
+ * number, not a claim about any row: `BLI-10042` cannot be in Linear whether
46
+ * or not it is in Tower.
47
+ */
48
+ export function isTowerNativeIssueId(value) {
49
+ const match = /^[A-Z][A-Z0-9]{1,12}-(\d+)$/.exec(value.trim().toUpperCase());
50
+ return match ? Number(match[1]) >= TOWER_NATIVE_ISSUE_NUMBER_FLOOR : false;
51
+ }
52
+ /**
53
+ * The Linear id a Tower-native title mirrors: the LAST `[BLI-3779]` in the
54
+ * title, because a title may quote another ticket in its prose and the
55
+ * convention puts the mirrored id at the end.
56
+ */
57
+ export function mirroredTicketIdInTitle(title) {
58
+ const matches = [...title.matchAll(/\[([A-Z][A-Z0-9]{1,12}-\d+)\]/gi)];
59
+ const last = matches.at(-1);
60
+ return last ? last[1].toUpperCase() : null;
61
+ }
@@ -85,6 +85,8 @@ export declare const LocalWorkContextSchema: z.ZodObject<{
85
85
  started_at: z.ZodString;
86
86
  updated_at: z.ZodOptional<z.ZodString>;
87
87
  active_ticket_id: z.ZodOptional<z.ZodString>;
88
+ tower_issue_id: z.ZodOptional<z.ZodString>;
89
+ linear_ticket_id: z.ZodOptional<z.ZodString>;
88
90
  ticket_binding_candidates: z.ZodDefault<z.ZodArray<z.ZodObject<{
89
91
  ticket_id: z.ZodString;
90
92
  binding_source: z.ZodEnum<{
@@ -76,6 +76,20 @@ export const LocalWorkContextSchema = z
76
76
  started_at: IsoDateTimeSchema,
77
77
  updated_at: IsoDateTimeSchema.optional(),
78
78
  active_ticket_id: NonEmptyStringSchema.optional(),
79
+ /**
80
+ * BLI-3779, the dual-run: one piece of work has an id in TWO trackers, so
81
+ * the context carries both beside `active_ticket_id` (which stays exactly
82
+ * what the person typed — rewriting it would move every downstream
83
+ * attribution to an id nobody bound).
84
+ *
85
+ * `tower_issue_id` is a `work_issues.identifier` (`BLI-10019`);
86
+ * `linear_ticket_id` is the Linear one (`BLI-3779`). Both OPTIONAL and
87
+ * both absent when nothing was resolved: `cockpit start` never blocks a
88
+ * session on a tracker lookup (session-first commandment), so an
89
+ * unresolved ticket is a named gap, never a missing context.
90
+ */
91
+ tower_issue_id: NonEmptyStringSchema.optional(),
92
+ linear_ticket_id: NonEmptyStringSchema.optional(),
79
93
  ticket_binding_candidates: z
80
94
  .array(TicketBindingCandidateSchema)
81
95
  .default([]),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/telemetry-core",
3
- "version": "0.1.29",
3
+ "version": "0.1.31",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",