@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.
- package/dist/collector-heartbeat.d.ts +5 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/ingest-dto.d.ts +2 -0
- package/dist/memory-hook-stats.d.ts +105 -0
- package/dist/memory-hook-stats.js +122 -0
- package/dist/memory-install-receipt.d.ts +5 -0
- package/dist/memory-install-receipt.js +30 -0
- package/dist/ticket-id.d.ts +43 -0
- package/dist/ticket-id.js +57 -0
- package/dist/work-context.d.ts +2 -0
- package/dist/work-context.js +14 -0
- package/package.json +1 -1
|
@@ -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";
|
package/dist/ingest-dto.d.ts
CHANGED
|
@@ -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. */
|
package/dist/ticket-id.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/work-context.d.ts
CHANGED
|
@@ -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<{
|
package/dist/work-context.js
CHANGED
|
@@ -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([]),
|