vouchington-tooling 0.0.22 → 0.1.1

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.
Files changed (49) hide show
  1. package/README.md +28 -0
  2. package/dist/browser-session-runner/attempt.d.mts +2 -0
  3. package/dist/browser-session-runner/attempt.mjs +196 -0
  4. package/dist/browser-session-runner/index.d.mts +4 -0
  5. package/dist/browser-session-runner/index.mjs +68 -0
  6. package/dist/browser-session-runner/output.d.mts +8 -0
  7. package/dist/browser-session-runner/output.mjs +24 -0
  8. package/dist/browser-session-runner/process-group.d.mts +5 -0
  9. package/dist/browser-session-runner/process-group.mjs +23 -0
  10. package/dist/browser-session-runner/result.d.mts +2 -0
  11. package/dist/browser-session-runner/result.mjs +11 -0
  12. package/dist/browser-session-runner/tail-queue.d.mts +8 -0
  13. package/dist/browser-session-runner/tail-queue.mjs +30 -0
  14. package/dist/browser-session-runner/tail.d.mts +1 -0
  15. package/dist/browser-session-runner/tail.mjs +12 -0
  16. package/dist/browser-session-runner/types.d.mts +70 -0
  17. package/dist/browser-session-runner/types.mjs +1 -0
  18. package/dist/browser-session-runner/watchdog.d.mts +10 -0
  19. package/dist/browser-session-runner/watchdog.mjs +38 -0
  20. package/dist/cli/commands/retrospective-transcript.d.mts +1 -0
  21. package/dist/cli/commands/retrospective-transcript.mjs +31 -0
  22. package/dist/cli/index.mjs +3 -0
  23. package/dist/cli/parse.d.mts +3 -0
  24. package/dist/cli/parse.mjs +2 -0
  25. package/dist/cli/usage.d.mts +1 -1
  26. package/dist/cli/usage.mjs +2 -0
  27. package/dist/index.d.mts +6 -0
  28. package/dist/index.mjs +3 -0
  29. package/dist/retrospective-transcript/claude.d.mts +2 -0
  30. package/dist/retrospective-transcript/claude.mjs +67 -0
  31. package/dist/retrospective-transcript/codex-segment.d.mts +2 -0
  32. package/dist/retrospective-transcript/codex-segment.mjs +73 -0
  33. package/dist/retrospective-transcript/codex.d.mts +10 -0
  34. package/dist/retrospective-transcript/codex.mjs +170 -0
  35. package/dist/retrospective-transcript/format.d.mts +4 -0
  36. package/dist/retrospective-transcript/format.mjs +22 -0
  37. package/dist/retrospective-transcript/index.d.mts +22 -0
  38. package/dist/retrospective-transcript/index.mjs +157 -0
  39. package/dist/retrospective-transcript/javascript-command.d.mts +1 -0
  40. package/dist/retrospective-transcript/javascript-command.mjs +25 -0
  41. package/dist/retrospective-transcript/shared.d.mts +31 -0
  42. package/dist/retrospective-transcript/shared.mjs +213 -0
  43. package/dist/vitest-diagnostics/directory.d.mts +12 -0
  44. package/dist/vitest-diagnostics/directory.mjs +44 -0
  45. package/dist/vitest-diagnostics/index.d.mts +22 -0
  46. package/dist/vitest-diagnostics/index.mjs +123 -0
  47. package/dist/vitest-diagnostics/read-file.d.mts +2 -0
  48. package/dist/vitest-diagnostics/read-file.mjs +37 -0
  49. package/package.json +16 -1
package/README.md CHANGED
@@ -37,6 +37,7 @@ vouchington run-with-timeout 120 10 docker push example
37
37
  vouchington lint-links --offline
38
38
  vouchington materialize-pr-context
39
39
  vouchington wait-for-apt-locks
40
+ vouchington retrospective-transcript --jsonl /path/to/transcript.jsonl
40
41
  vouchington install-playwright-chromium-arm64
41
42
  vouchington ghcr-package-retention example%2Fapi
42
43
  vouchington nuget-central-version trusted.props candidate.props metadata.json out.props
@@ -45,6 +46,12 @@ vouchington post-review
45
46
  vouchington stage-review-payload optional|required <source> <destination>
46
47
  ```
47
48
 
49
+ `retrospective-transcript` discovers Codex and Claude transcripts by default. It also reads a
50
+ Claude-compatible transcript when `CURSOR_SESSION_ID` is set, and Grok's `updates.jsonl` session
51
+ layout when `GROK_SESSION_ID` is set. Use `--grok-sessions-dir` to point discovery at a nondefault
52
+ Grok session root. Without `--session-id`, it reads those session identities from the host
53
+ environment.
54
+
48
55
  Host-lock environment:
49
56
 
50
57
  | Variable | Default | Meaning |
@@ -83,6 +90,7 @@ import {
83
90
  import { createArtifactClassifier, runCleanup } from 'vouchington-tooling/gha-artifacts-cleanup'
84
91
  import { validateOptionalHttpOrigin } from 'vouchington-tooling/http-origin'
85
92
  import { boundPendingLine, splitCompleteLines } from 'vouchington-tooling/process-line-buffer'
93
+ import { runBrowserSession } from 'vouchington-tooling/browser-session-runner'
86
94
  import {
87
95
  generateSchemaSnapshot,
88
96
  renderSchemaMarkdown,
@@ -111,8 +119,28 @@ import { validateNugetUpdate } from 'vouchington-tooling/nuget-central-version'
111
119
  import { normalizeSwiftSource } from 'vouchington-tooling/swift-semantic-equal'
112
120
  import { parseUniqueSwiftBinaryTargetChecksum } from 'vouchington-tooling/swift-source-offset'
113
121
  import { validateResolvedPinDelta } from 'vouchington-tooling/swift-resolved-pin-delta'
122
+ import {
123
+ formatDiagnosticReportSummaries,
124
+ readDiagnosticReportSummaries,
125
+ } from 'vouchington-tooling/vitest-diagnostics'
126
+ import { runRetrospectiveTranscript } from 'vouchington-tooling/retrospective-transcript'
114
127
  ```
115
128
 
116
129
  The artifact, review-payload, HTTP body, and pagination APIs validate untrusted inputs at their
117
130
  boundaries. Review posting lives in `gha-post-review` and talks to GitHub only through caller-supplied
118
131
  credentials (job token or a minted Claude GitHub App token).
132
+
133
+ `vitest-diagnostics` reads Node diagnostic report JSON from a caller-selected directory. It sorts
134
+ filenames, tolerates partial files, returns only a bounded field allowlist, and never emits raw
135
+ native frame symbols. Both structured reads and text rendering have hard report-count limits.
136
+
137
+ `browser-session-runner` supervises caller-created browser-test processes. Callers supply command
138
+ construction, line classification, retry/outcome policy, and budgets; the library owns process-group
139
+ termination, output line buffering, shared deadlines, progress watchdogs, diagnostics, and parent signals.
140
+ The returned process must identify a dedicated process group, such as a child spawned with
141
+ `detached: true`; its `processGroupId` is signalled without assuming the child PID is a group ID.
142
+ Stall exits use the caller's `classifyExit` policy, while parent signals and shared-deadline expiration
143
+ are terminal. Output is decoded independently per stream; unfinished lines are classified at close and
144
+ `diagnosticTailBytes` retains a UTF-8-safe tail no larger than its byte budget.
145
+ The runner uses monotonic elapsed time, rejects timer budgets above Node's maximum delay, and waits for
146
+ the dedicated process group to exit after the direct child closes so descendants cannot overlap a retry.
@@ -0,0 +1,2 @@
1
+ import type { BrowserSessionDeps, BrowserSessionOptions, BrowserSessionResult } from './types.mts';
2
+ export declare function runAttempt(options: BrowserSessionOptions, deps: BrowserSessionDeps, deadline: number, attempt: number): Promise<BrowserSessionResult>;
@@ -0,0 +1,196 @@
1
+ import { createOutputConsumer } from './output.mjs';
2
+ import { tailText } from './tail.mjs';
3
+ import { TailQueue } from './tail-queue.mjs';
4
+ import { createWatchdogLifecycle, registerWatchdogSetup } from './watchdog.mjs';
5
+ export function runAttempt(options, deps, deadline, attempt) {
6
+ return new Promise((resolve, reject) => {
7
+ const startedAt = deps.now(), process = options.start(attempt);
8
+ if (!Number.isSafeInteger(process.processGroupId) ||
9
+ process.processGroupId <= 0 ||
10
+ process.processGroupId > 2_147_483_647) {
11
+ try {
12
+ process.kill('SIGKILL');
13
+ }
14
+ catch { }
15
+ throw new RangeError('processGroupId must be positive');
16
+ }
17
+ const maxTail = options.diagnosticTailBytes ?? 4096;
18
+ const drainTimeoutMs = options.graceMs + (options.processGroupDrainMs ?? options.graceMs);
19
+ const tail = new TailQueue(maxTail);
20
+ let startupProgress = false, semanticProgress = false;
21
+ let failure, hasFailure = false, childExited = false, closeExit, draining = false, drainDone = false, reason = 'exit', complete = false, terminating = false;
22
+ let killTimer, stallTimer;
23
+ const watchdog = createWatchdogLifecycle();
24
+ const listeners = [];
25
+ const captureFailure = (error) => !hasFailure && ((failure = error), (hasFailure = true));
26
+ const finish = (exit) => {
27
+ if (complete)
28
+ return;
29
+ complete = true;
30
+ deps.clearTimeout(deadlineTimer);
31
+ if (killTimer)
32
+ deps.clearTimeout(killTimer);
33
+ deps.clearTimeout(stallTimer);
34
+ watchdog.complete(captureFailure);
35
+ for (const remove of listeners)
36
+ remove();
37
+ const result = {
38
+ attempts: 0,
39
+ deadlineExceeded: reason === 'deadline',
40
+ diagnosticTail: tailText(tail.toBuffer(), maxTail),
41
+ exit,
42
+ reason,
43
+ semanticProgress,
44
+ startupProgress,
45
+ };
46
+ if (hasFailure)
47
+ reject(failure);
48
+ else
49
+ resolve(result);
50
+ };
51
+ const signal = (value) => {
52
+ try {
53
+ deps.killProcessGroup(process.processGroupId, value);
54
+ }
55
+ catch { }
56
+ try {
57
+ process.kill(value);
58
+ }
59
+ catch { }
60
+ };
61
+ const terminate = (nextReason) => {
62
+ if (nextReason === 'deadline' && childExited && reason === 'exit')
63
+ return;
64
+ if (nextReason === 'parent-signal' || nextReason === 'deadline') {
65
+ if (reason === 'exit' || reason === 'startup-stall' || reason === 'semantic-stall')
66
+ reason = nextReason;
67
+ else if (reason !== nextReason)
68
+ return;
69
+ }
70
+ else if (reason !== 'exit')
71
+ return;
72
+ else if (nextReason !== 'exit')
73
+ reason = nextReason;
74
+ if (terminating)
75
+ return;
76
+ terminating = true;
77
+ killTimer = deps.setTimeout(() => {
78
+ if (deps.isProcessGroupAlive(process.processGroupId))
79
+ signal('SIGKILL');
80
+ }, options.graceMs);
81
+ signal('SIGTERM');
82
+ };
83
+ const fail = (error) => {
84
+ if (hasFailure || complete)
85
+ return;
86
+ captureFailure(error);
87
+ terminate('exit');
88
+ };
89
+ const drain = () => {
90
+ if (draining)
91
+ return;
92
+ draining = true;
93
+ void deps.waitForProcessGroupExit(process.processGroupId, drainTimeoutMs).then(() => {
94
+ drainDone = true;
95
+ if (closeExit)
96
+ finish(closeExit);
97
+ }, (error) => {
98
+ fail(error);
99
+ finish(closeExit ?? { code: null, signal: null });
100
+ });
101
+ };
102
+ const armStall = (ms, nextReason) => {
103
+ if (stallTimer)
104
+ deps.clearTimeout(stallTimer);
105
+ stallTimer = deps.setTimeout(() => terminate(nextReason), ms);
106
+ };
107
+ const line = (value) => {
108
+ const event = options.onLine(value);
109
+ if (event === 'startup' && !startupProgress) {
110
+ startupProgress = true;
111
+ if (!childExited)
112
+ armStall(options.semanticStallMs, 'semantic-stall');
113
+ }
114
+ if (event === 'semantic') {
115
+ semanticProgress = true;
116
+ if (startupProgress && !childExited)
117
+ armStall(options.semanticStallMs, 'semantic-stall');
118
+ }
119
+ };
120
+ const streams = [process.stdout, process.stderr].map((stream, index) => stream
121
+ ? createOutputConsumer(options, tail, index === 0 ? 'stdout' : 'stderr', line)
122
+ : undefined);
123
+ for (const [index, stream] of [process.stdout, process.stderr].entries())
124
+ stream?.on('data', (chunk) => {
125
+ try {
126
+ streams[index].write(chunk);
127
+ }
128
+ catch (error) {
129
+ fail(error);
130
+ }
131
+ });
132
+ for (const stream of [process.stdout, process.stderr])
133
+ stream?.on('error', fail);
134
+ const remainingDeadlineMs = deadline - deps.now();
135
+ const deadlineTimer = deps.setTimeout(() => terminate('deadline'), Math.max(1, remainingDeadlineMs));
136
+ const remainingStartupMs = options.startupStallMs - (deps.now() - startedAt);
137
+ armStall(Math.max(1, remainingStartupMs), 'startup-stall');
138
+ if (remainingStartupMs <= 0)
139
+ terminate('startup-stall');
140
+ if (remainingDeadlineMs <= 0)
141
+ terminate('deadline');
142
+ for (const value of ['SIGINT', 'SIGTERM']) {
143
+ const listener = () => terminate('parent-signal');
144
+ deps.onParentSignal(value, listener);
145
+ listeners.push(() => deps.offParentSignal(value, listener));
146
+ }
147
+ process.on('error', () => terminate('exit'));
148
+ process.on('exit', () => {
149
+ if (reason === 'exit' && deps.now() >= deadline)
150
+ reason = 'deadline';
151
+ childExited = true;
152
+ deps.clearTimeout(stallTimer);
153
+ if (!deps.isProcessGroupAlive(process.processGroupId))
154
+ return;
155
+ terminate(reason);
156
+ drain();
157
+ });
158
+ process.on('close', (code, value) => {
159
+ for (const stream of streams)
160
+ try {
161
+ stream?.flush();
162
+ }
163
+ catch (error) {
164
+ fail(error);
165
+ }
166
+ if (!childExited &&
167
+ deps.now() >= deadline &&
168
+ reason !== 'parent-signal' &&
169
+ reason !== 'deadline')
170
+ reason = 'deadline';
171
+ closeExit = { code, signal: value };
172
+ if (!deps.isProcessGroupAlive(process.processGroupId))
173
+ return finish(closeExit);
174
+ terminate(reason);
175
+ drain();
176
+ if (drainDone || hasFailure)
177
+ finish(closeExit);
178
+ });
179
+ try {
180
+ const setup = options.watchdog?.({
181
+ attempt,
182
+ deadline,
183
+ now: () => deps.now(),
184
+ process,
185
+ terminate: () => {
186
+ if (!childExited)
187
+ terminate('provider-watchdog');
188
+ },
189
+ });
190
+ registerWatchdogSetup(setup, watchdog, fail);
191
+ }
192
+ catch (error) {
193
+ fail(error);
194
+ }
195
+ });
196
+ }
@@ -0,0 +1,4 @@
1
+ import type { BrowserSessionDeps, BrowserSessionOptions, BrowserSessionResult } from './types.mts';
2
+ export type { BrowserSessionDeps, BrowserSessionEvent, BrowserSessionExit, BrowserSessionOptions, BrowserSessionOutput, BrowserSessionProcess, BrowserSessionResult, BrowserSessionTerminationReason, BrowserSessionWatchdog, BrowserSessionWatchdogCleanup, BrowserSessionWatchdogController, } from './types.mts';
3
+ export { ProcessGroupDrainTimeoutError } from './process-group.mts';
4
+ export declare function runBrowserSession(options: BrowserSessionOptions, deps?: BrowserSessionDeps): Promise<BrowserSessionResult>;
@@ -0,0 +1,68 @@
1
+ import { runAttempt } from './attempt.mjs';
2
+ import { expiredResult } from './result.mjs';
3
+ import { isProcessGroupAlive, waitForProcessGroupExit } from './process-group.mjs';
4
+ export { ProcessGroupDrainTimeoutError } from './process-group.mjs';
5
+ const defaultDeps = {
6
+ clearInterval,
7
+ clearTimeout,
8
+ isProcessGroupAlive,
9
+ killProcessGroup: (processGroupId, signal) => {
10
+ try {
11
+ process.kill(-processGroupId, signal);
12
+ }
13
+ catch (error) {
14
+ if (error.code !== 'ESRCH')
15
+ throw error;
16
+ }
17
+ },
18
+ now: () => performance.now(),
19
+ offParentSignal: (signal, listener) => process.off(signal, listener),
20
+ onParentSignal: (signal, listener) => process.on(signal, listener),
21
+ setInterval,
22
+ setTimeout,
23
+ waitForProcessGroupExit,
24
+ };
25
+ export async function runBrowserSession(options, deps = defaultDeps) {
26
+ validateOptions(options);
27
+ if (deps === defaultDeps && process.platform === 'win32')
28
+ throw new Error('browser-session-runner default process-group control is unsupported on Windows');
29
+ const deadline = deps.now() + options.deadlineMs;
30
+ let last;
31
+ let attempts = 0;
32
+ for (let attempt = 1; attempt <= options.attempts && deps.now() < deadline; attempt += 1) {
33
+ attempts = attempt;
34
+ last = { ...(await runAttempt(options, deps, deadline, attempt)), attempts };
35
+ options.onAttemptComplete?.(snapshotResult(last));
36
+ if (last.reason === 'parent-signal' ||
37
+ last.reason === 'deadline' ||
38
+ options.classifyExit(last.exit, omitAttempts(last)) !== 'retry')
39
+ return last;
40
+ }
41
+ if (attempts === options.attempts)
42
+ return { ...last, attempts };
43
+ return { ...(last ?? expiredResult()), attempts, deadlineExceeded: true, reason: 'deadline' };
44
+ }
45
+ function validateOptions(options) {
46
+ const maxTimerDelay = 2_147_483_647;
47
+ for (const [name, value] of [
48
+ ['attempts', options.attempts],
49
+ ['deadlineMs', options.deadlineMs],
50
+ ['graceMs', options.graceMs],
51
+ ['processGroupDrainMs', options.processGroupDrainMs ?? options.graceMs],
52
+ ['semanticStallMs', options.semanticStallMs],
53
+ ['startupStallMs', options.startupStallMs],
54
+ ['watchdogIntervalMs', options.watchdogIntervalMs ?? 1000],
55
+ ]) {
56
+ if (!Number.isSafeInteger(value) || value <= 0 || value > maxTimerDelay)
57
+ throw new RangeError(`${name} must be a positive Node timer delay`);
58
+ }
59
+ const tailBytes = options.diagnosticTailBytes ?? 4096;
60
+ if (!Number.isSafeInteger(tailBytes) || tailBytes <= 0)
61
+ throw new RangeError('diagnosticTailBytes must be positive');
62
+ }
63
+ function omitAttempts({ attempts: _attempts, ...result }) {
64
+ return result;
65
+ }
66
+ function snapshotResult(result) {
67
+ return { ...result, exit: { ...result.exit } };
68
+ }
@@ -0,0 +1,8 @@
1
+ import type { BrowserSessionOptions, BrowserSessionOutput } from './types.mts';
2
+ import type { TailQueue } from './tail-queue.mts';
3
+ type OutputConsumer = {
4
+ flush(): void;
5
+ write(chunk: string | Buffer): void;
6
+ };
7
+ export declare function createOutputConsumer(options: BrowserSessionOptions, tail: TailQueue, source: BrowserSessionOutput['source'], onLine: (line: string) => void): OutputConsumer;
8
+ export {};
@@ -0,0 +1,24 @@
1
+ import { StringDecoder } from 'node:string_decoder';
2
+ import { boundPendingLine, splitCompleteLines } from '../process-line-buffer/index.mjs';
3
+ export function createOutputConsumer(options, tail, source, onLine) {
4
+ const decoder = new StringDecoder('utf8');
5
+ let pending = '';
6
+ const appendLines = (text) => {
7
+ tail.append(text);
8
+ const split = splitCompleteLines(pending + text);
9
+ pending = boundPendingLine(split.pending);
10
+ for (const value of split.complete)
11
+ onLine(value);
12
+ };
13
+ return {
14
+ flush: () => {
15
+ appendLines(decoder.end());
16
+ if (pending)
17
+ onLine(pending);
18
+ },
19
+ write: (chunk) => {
20
+ options.onOutput?.({ chunk, source });
21
+ appendLines(decoder.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
22
+ },
23
+ };
24
+ }
@@ -0,0 +1,5 @@
1
+ export declare class ProcessGroupDrainTimeoutError extends Error {
2
+ constructor(processGroupId: number, timeoutMs: number);
3
+ }
4
+ export declare function isProcessGroupAlive(processGroupId: number): boolean;
5
+ export declare function waitForProcessGroupExit(processGroupId: number, timeoutMs: number): Promise<void>;
@@ -0,0 +1,23 @@
1
+ export class ProcessGroupDrainTimeoutError extends Error {
2
+ constructor(processGroupId, timeoutMs) {
3
+ super(`Process group ${processGroupId} did not exit within ${timeoutMs}ms after SIGKILL`);
4
+ this.name = 'ProcessGroupDrainTimeoutError';
5
+ }
6
+ }
7
+ export function isProcessGroupAlive(processGroupId) {
8
+ try {
9
+ process.kill(-processGroupId, 0);
10
+ return true;
11
+ }
12
+ catch (error) {
13
+ return error.code !== 'ESRCH';
14
+ }
15
+ }
16
+ export async function waitForProcessGroupExit(processGroupId, timeoutMs) {
17
+ const deadline = performance.now() + timeoutMs;
18
+ while (isProcessGroupAlive(processGroupId)) {
19
+ if (performance.now() >= deadline)
20
+ throw new ProcessGroupDrainTimeoutError(processGroupId, timeoutMs);
21
+ await new Promise((resolve) => setTimeout(resolve, Math.min(10, deadline - performance.now())));
22
+ }
23
+ }
@@ -0,0 +1,2 @@
1
+ import type { BrowserSessionResult } from './types.mts';
2
+ export declare function expiredResult(): BrowserSessionResult;
@@ -0,0 +1,11 @@
1
+ export function expiredResult() {
2
+ return {
3
+ attempts: 0,
4
+ deadlineExceeded: true,
5
+ diagnosticTail: '',
6
+ exit: { code: null, signal: null },
7
+ reason: 'deadline',
8
+ semanticProgress: false,
9
+ startupProgress: false,
10
+ };
11
+ }
@@ -0,0 +1,8 @@
1
+ export declare class TailQueue {
2
+ readonly maxBytes: number;
3
+ readonly chunks: Buffer[];
4
+ bytes: number;
5
+ constructor(maxBytes: number);
6
+ append(text: string): void;
7
+ toBuffer(): Buffer;
8
+ }
@@ -0,0 +1,30 @@
1
+ export class TailQueue {
2
+ maxBytes;
3
+ chunks = [];
4
+ bytes = 0;
5
+ constructor(maxBytes) {
6
+ this.maxBytes = maxBytes;
7
+ }
8
+ append(text) {
9
+ const chunk = Buffer.from(text);
10
+ if (chunk.length >= this.maxBytes) {
11
+ this.chunks.splice(0, this.chunks.length, chunk.subarray(-this.maxBytes));
12
+ this.bytes = this.maxBytes;
13
+ return;
14
+ }
15
+ this.chunks.push(chunk);
16
+ this.bytes += chunk.length;
17
+ while (this.bytes > this.maxBytes) {
18
+ const first = this.chunks[0];
19
+ const overflow = this.bytes - this.maxBytes;
20
+ if (first.length <= overflow)
21
+ this.chunks.shift();
22
+ else
23
+ this.chunks[0] = first.subarray(overflow);
24
+ this.bytes -= Math.min(first.length, overflow);
25
+ }
26
+ }
27
+ toBuffer() {
28
+ return Buffer.concat(this.chunks, this.bytes);
29
+ }
30
+ }
@@ -0,0 +1 @@
1
+ export declare function tailText(tail: Buffer, maxBytes: number): string;
@@ -0,0 +1,12 @@
1
+ export function tailText(tail, maxBytes) {
2
+ const bounded = tail.subarray(Math.max(0, tail.length - maxBytes));
3
+ for (let start = 0; start < Math.min(4, bounded.length); start += 1) {
4
+ for (let end = 0; end < Math.min(4, bounded.length - start); end += 1) {
5
+ try {
6
+ return new TextDecoder('utf-8', { fatal: true }).decode(bounded.subarray(start, bounded.length - end));
7
+ }
8
+ catch { }
9
+ }
10
+ }
11
+ return '';
12
+ }
@@ -0,0 +1,70 @@
1
+ import type { ChildProcess } from 'node:child_process';
2
+ export type BrowserSessionEvent = 'startup' | 'semantic';
3
+ export type BrowserSessionExit = {
4
+ code: number | null;
5
+ signal: NodeJS.Signals | null;
6
+ };
7
+ export type BrowserSessionOutput = {
8
+ chunk: string | Buffer;
9
+ source: 'stderr' | 'stdout';
10
+ };
11
+ export type BrowserSessionTerminationReason = 'deadline' | 'parent-signal' | 'provider-watchdog' | 'semantic-stall' | 'startup-stall';
12
+ type BrowserSessionStream = {
13
+ on(event: 'data', listener: (chunk: string | Buffer) => void): unknown;
14
+ on(event: 'error', listener: (error: Error) => void): unknown;
15
+ };
16
+ export type BrowserSessionResult = {
17
+ attempts: number;
18
+ deadlineExceeded: boolean;
19
+ diagnosticTail: string;
20
+ exit: BrowserSessionExit;
21
+ reason: 'exit' | BrowserSessionTerminationReason;
22
+ startupProgress: boolean;
23
+ semanticProgress: boolean;
24
+ };
25
+ export type BrowserSessionProcess = Pick<ChildProcess, 'kill'> & {
26
+ processGroupId: number;
27
+ on(event: 'close', listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown;
28
+ on(event: 'error', listener: (error: Error) => void): unknown;
29
+ on(event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown;
30
+ stderr?: BrowserSessionStream | null;
31
+ stdout?: BrowserSessionStream | null;
32
+ };
33
+ export type BrowserSessionDeps = {
34
+ clearInterval(handle: unknown): void;
35
+ clearTimeout(handle: unknown): void;
36
+ isProcessGroupAlive(processGroupId: number): boolean;
37
+ killProcessGroup(processGroupId: number, signal: NodeJS.Signals): void;
38
+ now(): number;
39
+ offParentSignal(signal: NodeJS.Signals, listener: () => void): void;
40
+ onParentSignal(signal: NodeJS.Signals, listener: () => void): void;
41
+ setInterval(callback: () => void, ms: number): unknown;
42
+ setTimeout(callback: () => void, ms: number): unknown;
43
+ waitForProcessGroupExit(processGroupId: number, timeoutMs: number): Promise<void>;
44
+ };
45
+ export type BrowserSessionWatchdogController = {
46
+ attempt: number;
47
+ deadline: number;
48
+ now(): number;
49
+ process: BrowserSessionProcess;
50
+ terminate(reason?: 'provider-watchdog'): void;
51
+ };
52
+ export type BrowserSessionWatchdogCleanup = () => void;
53
+ export type BrowserSessionWatchdog = (controller: BrowserSessionWatchdogController) => void | BrowserSessionWatchdogCleanup | Promise<void | BrowserSessionWatchdogCleanup>;
54
+ export type BrowserSessionOptions = {
55
+ attempts: number;
56
+ classifyExit(exit: BrowserSessionExit, result: Omit<BrowserSessionResult, 'attempts'>): 'retry' | 'return';
57
+ deadlineMs: number;
58
+ diagnosticTailBytes?: number;
59
+ graceMs: number;
60
+ onLine(line: string): BrowserSessionEvent | undefined;
61
+ onOutput?(output: BrowserSessionOutput): void;
62
+ onAttemptComplete?(result: BrowserSessionResult): void;
63
+ processGroupDrainMs?: number;
64
+ semanticStallMs: number;
65
+ start(attempt: number): BrowserSessionProcess;
66
+ startupStallMs: number;
67
+ watchdogIntervalMs?: number;
68
+ watchdog?: BrowserSessionWatchdog;
69
+ };
70
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,10 @@
1
+ export type WatchdogCleanup = () => void;
2
+ type WatchdogSetup = void | WatchdogCleanup | Promise<void | WatchdogCleanup>;
3
+ type WatchdogLifecycle = ReturnType<typeof createWatchdogLifecycle>;
4
+ export declare function createWatchdogLifecycle(): {
5
+ complete: (onError: (error: unknown) => void) => void;
6
+ isComplete: () => boolean;
7
+ register: (next: void | WatchdogCleanup, onError: (error: unknown) => void) => void;
8
+ };
9
+ export declare function registerWatchdogSetup(setup: WatchdogSetup, lifecycle: WatchdogLifecycle, onError: (error: unknown) => void): void;
10
+ export {};
@@ -0,0 +1,38 @@
1
+ export function createWatchdogLifecycle() {
2
+ let complete = false;
3
+ let cleanup;
4
+ const run = (next, onError) => {
5
+ try {
6
+ next();
7
+ }
8
+ catch (error) {
9
+ onError(error);
10
+ }
11
+ };
12
+ return {
13
+ complete: (onError) => {
14
+ complete = true;
15
+ if (!cleanup)
16
+ return;
17
+ const next = cleanup;
18
+ cleanup = undefined;
19
+ run(next, onError);
20
+ },
21
+ isComplete: () => complete,
22
+ register: (next, onError) => {
23
+ if (!next)
24
+ return;
25
+ if (complete)
26
+ return run(next, onError);
27
+ cleanup = next;
28
+ },
29
+ };
30
+ }
31
+ export function registerWatchdogSetup(setup, lifecycle, onError) {
32
+ if (!(setup instanceof Promise))
33
+ return lifecycle.register(setup, onError);
34
+ void setup.then((cleanup) => lifecycle.register(cleanup, onError), (error) => {
35
+ if (!lifecycle.isComplete())
36
+ onError(error);
37
+ });
38
+ }
@@ -0,0 +1 @@
1
+ export declare function runRetrospectiveTranscriptCommand(args: string[]): Promise<number>;
@@ -0,0 +1,31 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { runRetrospectiveTranscript, } from '../../retrospective-transcript/index.mjs';
3
+ export async function runRetrospectiveTranscriptCommand(args) {
4
+ try {
5
+ const { values } = parseArgs({
6
+ args,
7
+ strict: true,
8
+ options: {
9
+ 'session-id': { type: 'string' },
10
+ jsonl: { type: 'string' },
11
+ 'projects-dir': { type: 'string' },
12
+ 'codex-sessions-dir': { type: 'string' },
13
+ 'grok-sessions-dir': { type: 'string' },
14
+ },
15
+ });
16
+ const options = {
17
+ ...(values['session-id'] ? { sessionId: values['session-id'] } : {}),
18
+ ...(values.jsonl ? { jsonlPath: values.jsonl } : {}),
19
+ ...(values['projects-dir'] ? { projectsDir: values['projects-dir'] } : {}),
20
+ ...(values['codex-sessions-dir'] ? { codexSessionsDir: values['codex-sessions-dir'] } : {}),
21
+ ...(values['grok-sessions-dir'] ? { grokSessionsDir: values['grok-sessions-dir'] } : {}),
22
+ env: process.env,
23
+ };
24
+ process.stdout.write(await runRetrospectiveTranscript(options));
25
+ return 0;
26
+ }
27
+ catch (error) {
28
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
29
+ return 2;
30
+ }
31
+ }
@@ -14,6 +14,7 @@ import { runPostReviewCommand } from './commands/post-review.mjs';
14
14
  import { runStageReviewPayloadCommand } from './commands/stage-review-payload.mjs';
15
15
  import { runSwiftSemanticEqualCommand } from './commands/swift-semantic-equal.mjs';
16
16
  import { runVitestBlobManifestCommand } from './commands/vitest-blob-manifest.mjs';
17
+ import { runRetrospectiveTranscriptCommand } from './commands/retrospective-transcript.mjs';
17
18
  import { runWithHostLock } from './commands/with-host-lock.mjs';
18
19
  import { parseCli } from './parse.mjs';
19
20
  import { packageScriptPath } from './script-path.mjs';
@@ -92,6 +93,8 @@ export function runCli(argv = process.argv) {
92
93
  return runHttpOrigin(parsed.field, parsed.value);
93
94
  case 'gha-artifacts-cleanup':
94
95
  return runGhaArtifactsCleanup(parsed);
96
+ case 'retrospective-transcript':
97
+ return runRetrospectiveTranscriptCommand(parsed.args);
95
98
  }
96
99
  }
97
100
  function readInstalledVersion() {
@@ -40,6 +40,9 @@ export type ParsedCli = {
40
40
  kind: 'http-origin';
41
41
  field: string;
42
42
  value: string;
43
+ } | {
44
+ kind: 'retrospective-transcript';
45
+ args: string[];
43
46
  } | ParsedGhaRuntimeAudit | ParsedGhaArtifactsCleanup;
44
47
  export type ScriptCommand = 'gha-output' | 'gha-needs-results' | 'download-with-diagnostics' | 'host-pressure-diagnostics' | 'allocate-browser-safe-ports' | 'diagnose-port-collision' | 'prepare-trivy-db' | 'check-cache-size' | 'make-shard-matrix' | 'load-runner-env' | 'clean-workspace' | 'install-github-release' | 'run-with-timeout' | 'lint-links' | 'materialize-pr-context' | 'wait-for-apt-locks' | 'install-playwright-chromium-arm64' | 'ghcr-package-retention';
45
48
  export declare function parseCli(argv: readonly string[]): ParsedCli;