vouchington-tooling 0.46.0 → 0.48.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 CHANGED
@@ -165,7 +165,11 @@ pass `--root`, `--workflow-directory`, or `--action-directory` for consumer-owne
165
165
  Claude-compatible transcript when `CURSOR_SESSION_ID` is set, and Grok's `updates.jsonl` session
166
166
  layout when `GROK_SESSION_ID` is set. Use `--grok-sessions-dir` to point discovery at a nondefault
167
167
  Grok session root. Without `--session-id`, it reads those session identities from the host
168
- environment.
168
+ environment. `resolveTranscriptFile` is the canonical resolver: it lowercases and UUID-validates
169
+ session ids, honors `CODEX_HOME`, `CLAUDE_CONFIG_DIR` and `GROK_HOME`, and errors when more than one
170
+ transcript matches. The subpath also exports `normalizeCommandPrefix`, `splitCommandSegments`,
171
+ `isPackageRunner`, `asRecord`, `asArray` and `openTranscriptLines(path, { maxRecordBytes })`, which
172
+ streams lines (2 MiB per record by default) and throws from the iterable on an oversized record.
169
173
 
170
174
  `retrospective-facts` keeps local Git evidence separate from GitHub PR data. `Commits ahead of
171
175
  origin/main` is populated only from a local ancestry range; GitHub responses instead populate
@@ -1,26 +1,4 @@
1
- export declare const HARNESS_IDS: readonly ["claude", "codex", "grok", "cursor"];
2
- export type HarnessId = (typeof HARNESS_IDS)[number];
3
- export interface HarnessEnvironment {
4
- readonly claude: {
5
- readonly compatMode: boolean;
6
- readonly sessionId?: string;
7
- };
8
- readonly codex: {
9
- readonly sessionId?: string;
10
- };
11
- readonly cursor: {
12
- readonly agentMarker: boolean;
13
- readonly sessionId?: string;
14
- };
15
- readonly grok: {
16
- readonly agentMarker: boolean;
17
- readonly hookEvent: boolean;
18
- readonly sessionId?: string;
19
- };
20
- }
21
- export interface HarnessSessionIdentity {
22
- readonly harness: HarnessId;
23
- readonly sessionId: string;
24
- }
1
+ import { type HarnessEnvironment } from 'agent-blackboard';
2
+ export { HARNESS_IDS, selectHarnessSession } from 'agent-blackboard';
3
+ export type { HarnessEnvironment, HarnessId, HarnessSessionIdentity } from 'agent-blackboard';
25
4
  export declare function inspectHarnessEnvironment(env?: NodeJS.ProcessEnv): HarnessEnvironment;
26
- export declare function selectHarnessSession(environment: HarnessEnvironment, precedence: readonly HarnessId[]): HarnessSessionIdentity | undefined;
@@ -1,30 +1,5 @@
1
- export const HARNESS_IDS = ['claude', 'codex', 'grok', 'cursor'];
2
- function session(value) {
3
- return value ? { sessionId: value } : {};
4
- }
1
+ import { inspectHarnessEnvironment as inspect } from 'agent-blackboard';
2
+ export { HARNESS_IDS, selectHarnessSession } from 'agent-blackboard';
5
3
  export function inspectHarnessEnvironment(env = process.env) {
6
- return {
7
- claude: {
8
- compatMode: env.CLAUDECODE === '1',
9
- ...session(env.CLAUDE_CODE_SESSION_ID),
10
- },
11
- codex: session(env.CODEX_THREAD_ID),
12
- cursor: {
13
- agentMarker: Boolean(env.CURSOR_AGENT),
14
- ...session(env.CURSOR_SESSION_ID),
15
- },
16
- grok: {
17
- agentMarker: Boolean(env.GROK_AGENT),
18
- hookEvent: Boolean(env.GROK_HOOK_EVENT),
19
- ...session(env.GROK_SESSION_ID),
20
- },
21
- };
22
- }
23
- export function selectHarnessSession(environment, precedence) {
24
- for (const harness of precedence) {
25
- const sessionId = environment[harness].sessionId;
26
- if (sessionId)
27
- return { harness, sessionId };
28
- }
29
- return undefined;
4
+ return inspect(env);
30
5
  }
@@ -1,5 +1,5 @@
1
+ import { HARNESS_SESSION_PRECEDENCE } from 'agent-blackboard';
1
2
  import { inspectHarnessEnvironment } from "../agent-harness-identity/index.mjs";
2
- const PRECEDENCE = ['codex', 'claude', 'cursor', 'grok'];
3
3
  /**
4
4
  * The harness that launched this server, from the launch environment first and the MCP client's
5
5
  * `initialize` `clientInfo.name` second. Neither comes from a tool argument.
@@ -12,11 +12,11 @@ export function deriveAgent(env, clientName) {
12
12
  cursor: found.cursor.agentMarker || Boolean(found.cursor.sessionId),
13
13
  grok: found.grok.agentMarker || found.grok.hookEvent || Boolean(found.grok.sessionId),
14
14
  };
15
- const fromEnv = PRECEDENCE.find((id) => present[id]);
15
+ const fromEnv = HARNESS_SESSION_PRECEDENCE.find((id) => present[id]);
16
16
  if (fromEnv !== undefined)
17
17
  return fromEnv;
18
18
  const name = clientName?.toLowerCase() ?? '';
19
- return PRECEDENCE.find((id) => name.includes(id));
19
+ return HARNESS_SESSION_PRECEDENCE.find((id) => name.includes(id));
20
20
  }
21
21
  /**
22
22
  * The text of the `Agent:` line before the session. The agent is the derived harness; a caller
@@ -12,9 +12,9 @@ export type PullRequestProvenance = {
12
12
  };
13
13
  /**
14
14
  * The pull request body: the Summary, Impact, Related issues and Details sections followed by the
15
- * provenance lines. With `noSourceIssue` the Related issues section holds `noSourceStatement`
16
- * (default {@link defaultNoSourceStatement}) instead of `- None`. Pure; the caller supplies the
17
- * device and worktree facts.
15
+ * provenance lines. With `noSourceIssue` the Related issues section ends with `noSourceStatement`
16
+ * (default {@link defaultNoSourceStatement}), after any `Refs` lines, instead of `- None`. Pure;
17
+ * the caller supplies the device and worktree facts.
18
18
  */
19
19
  export declare function renderPullRequestBody(input: PullRequestInput, provenance: PullRequestProvenance, noSourceStatement?: string): string;
20
20
  /**
@@ -1,7 +1,9 @@
1
1
  import { basename } from 'node:path';
2
+ import { formatReferenceKey } from "../gh-cli/closing-refs.mjs";
2
3
  function relatedLine(issue) {
3
4
  const verb = issue.relation === 'closes' ? 'Closes' : 'Refs';
4
- return `- ${verb} #${issue.number} — ${issue.reason}`;
5
+ const [owner, repo] = issue.repository?.split('/') ?? [];
6
+ return `- ${verb} ${formatReferenceKey({ number: issue.number, owner, repo })} — ${issue.reason}`;
5
7
  }
6
8
  /** The default no-source statement; a policy `renderNoSourceIssue` export may replace it. */
7
9
  function defaultNoSourceStatement(reason) {
@@ -9,18 +11,16 @@ function defaultNoSourceStatement(reason) {
9
11
  }
10
12
  /**
11
13
  * The pull request body: the Summary, Impact, Related issues and Details sections followed by the
12
- * provenance lines. With `noSourceIssue` the Related issues section holds `noSourceStatement`
13
- * (default {@link defaultNoSourceStatement}) instead of `- None`. Pure; the caller supplies the
14
- * device and worktree facts.
14
+ * provenance lines. With `noSourceIssue` the Related issues section ends with `noSourceStatement`
15
+ * (default {@link defaultNoSourceStatement}), after any `Refs` lines, instead of `- None`. Pure;
16
+ * the caller supplies the device and worktree facts.
15
17
  */
16
18
  export function renderPullRequestBody(input, provenance, noSourceStatement) {
17
- const related = input.relatedIssues.length
18
- ? input.relatedIssues.map(relatedLine)
19
- : [
20
- input.noSourceIssue === undefined
21
- ? '- None'
22
- : (noSourceStatement ?? defaultNoSourceStatement(input.noSourceIssue.reason)),
23
- ];
19
+ const related = input.relatedIssues.map(relatedLine);
20
+ if (input.noSourceIssue !== undefined)
21
+ related.push(noSourceStatement ?? defaultNoSourceStatement(input.noSourceIssue.reason));
22
+ if (!related.length)
23
+ related.push('- None');
24
24
  return [
25
25
  '## Summary',
26
26
  '',
@@ -4,13 +4,15 @@ export type RelatedIssue = {
4
4
  number: number;
5
5
  relation: 'closes' | 'refs';
6
6
  reason: string;
7
+ /** `owner/name` of a cross-repository issue (rendered lowercase); absent for this repository. */
8
+ repository?: string;
7
9
  };
8
10
  export type PullRequestInput = {
9
11
  title: string;
10
12
  summary: string;
11
13
  impact: string;
12
14
  relatedIssues: RelatedIssue[];
13
- /** Set instead of relatedIssues when the pull request has no source issue. */
15
+ /** Declares no source issue; relatedIssues beside it must all be `refs`. */
14
16
  noSourceIssue?: {
15
17
  reason: string;
16
18
  };
@@ -1,3 +1,4 @@
1
+ import { hasClosingIssueReference } from "../gh-cli/closing-refs.mjs";
1
2
  import { optionalString, rejectUnknown, requireObject, requiredChoice, requiredString, requiredStringArray, } from "./args.mjs";
2
3
  import { trustedMode } from "./pr-mode.mjs";
3
4
  const PR_MODES = ['interactive', 'autonomous'];
@@ -9,17 +10,30 @@ export function positiveInteger(value, label) {
9
10
  throw new Error(`${label} must be a positive integer`);
10
11
  return value;
11
12
  }
13
+ // The owner is a GitHub login: alphanumerics and single hyphens, at most 39 characters.
14
+ const REPOSITORY_RE = /^(?=[^/]{1,39}\/)[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*\/[\w.-]+$/;
15
+ function readRepository(issue, label) {
16
+ if (issue.repository === undefined)
17
+ return undefined;
18
+ // Not lowercased here: redaction runs on this value first, and rendering lowercases it.
19
+ const value = singleLine(issue, 'repository');
20
+ if (!REPOSITORY_RE.test(value))
21
+ throw new Error(`${label} must be a single owner/name`);
22
+ return value;
23
+ }
12
24
  function readRelatedIssues(args) {
13
25
  const value = args.relatedIssues;
14
26
  if (!Array.isArray(value))
15
27
  throw new Error('relatedIssues is required and must be an array');
16
28
  return value.map((item, index) => {
17
29
  const issue = requireObject(item);
18
- rejectUnknown(issue, ['number', 'relation', 'reason']);
30
+ rejectUnknown(issue, ['number', 'relation', 'reason', 'repository']);
31
+ const repository = readRepository(issue, `relatedIssues[${index}].repository`);
19
32
  return {
20
33
  number: positiveInteger(issue.number, `relatedIssues[${index}].number`),
21
34
  relation: requiredChoice(issue, 'relation', RELATIONS),
22
35
  reason: requiredString(issue, 'reason'),
36
+ ...(repository === undefined ? {} : { repository }),
23
37
  };
24
38
  });
25
39
  }
@@ -28,8 +42,12 @@ function readNoSourceIssue(args, related) {
28
42
  return undefined;
29
43
  const value = requireObject(args.noSourceIssue);
30
44
  rejectUnknown(value, ['reason']);
31
- if (related.length)
32
- throw new Error('noSourceIssue and a non-empty relatedIssues are mutually exclusive');
45
+ if (related.some((issue) => issue.relation === 'closes'))
46
+ throw new Error('noSourceIssue cannot be combined with a relatedIssues entry that closes');
47
+ related.forEach((issue, index) => {
48
+ if (hasClosingIssueReference(issue.reason))
49
+ throw new Error(`relatedIssues[${index}].reason must not contain a closing reference beside noSourceIssue`);
50
+ });
33
51
  return { reason: singleLine(value, 'reason') };
34
52
  }
35
53
  function readDetails(args) {
@@ -12,14 +12,20 @@ const FIELDS = {
12
12
  impact: text('Who or what is affected, as markdown.'),
13
13
  relatedIssues: {
14
14
  type: 'array',
15
- description: 'Issues this pull request closes or refs. Empty when it has none; with no source issue ' +
16
- 'at all, give noSourceIssue as well (it is rejected beside a non-empty list).',
15
+ description: 'Issues this pull request closes or refs. Empty when it has none; with no source issue, ' +
16
+ 'give noSourceIssue as well (beside a non-empty list every entry must be refs).',
17
17
  items: {
18
18
  type: 'object',
19
19
  properties: {
20
20
  number: { type: 'integer', minimum: 1 },
21
21
  relation: { enum: ['closes', 'refs'] },
22
22
  reason: text('Why the issue is related.'),
23
+ repository: {
24
+ type: 'string',
25
+ pattern: '^(?=[^/]{1,39}/)[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*/[\\w.-]+$',
26
+ description: 'Optional owner/name (lowercased) of the issue repository, rendered as ' +
27
+ 'owner/name#N; omit for an issue in this repository. Allowed for closes and refs.',
28
+ },
23
29
  },
24
30
  required: ['number', 'relation', 'reason'],
25
31
  additionalProperties: false,
@@ -27,9 +33,10 @@ const FIELDS = {
27
33
  },
28
34
  noSourceIssue: {
29
35
  type: 'object',
30
- description: 'Declares a pull request with no source issue, mutually exclusive with a non-empty ' +
31
- 'relatedIssues. Rendered inside Related issues (the policy may supply the wording) and ' +
32
- 'passed to the policy, which may allow it only in interactive mode.',
36
+ description: 'Declares a pull request with no source issue; rejected beside a closes relatedIssues ' +
37
+ 'entry, allowed beside refs-only ones. Rendered inside Related issues after any Refs ' +
38
+ 'lines (the policy may supply the wording) and passed to the policy, which may allow it ' +
39
+ 'only in interactive mode.',
33
40
  properties: { reason: text('Single-line reason there is no source issue.') },
34
41
  required: ['reason'],
35
42
  additionalProperties: false,
@@ -0,0 +1 @@
1
+ export declare function asArray(value: unknown): unknown[];
@@ -0,0 +1,3 @@
1
+ export function asArray(value) {
2
+ return Array.isArray(value) ? value : [];
3
+ }
@@ -0,0 +1 @@
1
+ export declare function normalizeCommandPrefix(command: string): string;
@@ -0,0 +1,124 @@
1
+ import { isPackageRunner, splitCommandSegments } from "./command-segments.mjs";
2
+ // A real command/subcommand token (binary name, git subcommand, short flag, script
3
+ // name) is always well under this length. A token that exceeds it isn't a command
4
+ // token at all — it's payload that got tokenized as one word because it had no
5
+ // internal whitespace (a `VAR={...json...}` assignment, a giant quoted printf arg).
6
+ // Such tokens are replaced with a redaction marker rather than truncated: truncation
7
+ // still leaks a prefix of whatever the token contains (a secret can sit at the front,
8
+ // e.g. "AWS_SECRET_ACCESS_KEY=AKIA..."), while a full-token redaction leaks nothing
9
+ // regardless of where sensitive content falls inside it. This intentionally also
10
+ // redacts long-but-benign tokens (e.g. a `cd /very/long/worktree/path` argument) —
11
+ // for a privacy boundary, over-redacting a legitimate token is the safe failure mode.
12
+ const MAX_COMMAND_TOKEN_LENGTH = 40;
13
+ const REDACTED_TOKEN = '…';
14
+ function redactOverlongToken(token) {
15
+ return token.length > MAX_COMMAND_TOKEN_LENGTH ? REDACTED_TOKEN : token;
16
+ }
17
+ const ENV_ASSIGNMENT_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*=/;
18
+ // A segment's real command starts after any leading `VAR=value` assignments (e.g.
19
+ // "CI=1 pnpm exec vitest", "GIT_EDITOR=true git rebase --continue" — both real
20
+ // patterns used in real agent sessions). Without stripping these, the
21
+ // env-var token itself gets normalized as if it were the command, so "CI=1 pnpm exec
22
+ // vitest" would classify as prefix "CI=1 pnpm" instead of "pnpm exec vitest". Only
23
+ // strips when a token remains afterward — a segment that is *entirely*
24
+ // assignment-shaped tokens (e.g. a lone `OLD_POLICY_JSON={...}` token with no
25
+ // whitespace) has no command to recover, and is left alone so it still falls through
26
+ // to the general redaction path below instead of normalizing to an empty prefix.
27
+ function stripEnvPrefix(segment) {
28
+ const index = segment.findIndex((token) => !ENV_ASSIGNMENT_PATTERN.test(token));
29
+ return index === -1 ? segment : segment.slice(index);
30
+ }
31
+ // Git global options precede the real subcommand — "git -C <path> status" and
32
+ // "git status" are the same command family for policy-matching purposes (`git -h`
33
+ // documents `git [-C <path>] [-c <name>=<value>] ... <command> [<args>]`). `-C`/`-c`
34
+ // take a separate-token argument; `--git-dir=<path>`-style options fold their argument
35
+ // into one `=`-joined token; the rest take no argument at all. Skipping them keeps the
36
+ // normalized prefix on the subcommand instead of stalling on the option — without this,
37
+ // e.g. `git -C <worktree> status` normalizes to "git -C" and reports as an uncovered
38
+ // escalation/block candidate under the wrong prefix, even though `git status` is
39
+ // already covered by an existing narrow allow rule.
40
+ const GIT_GLOBAL_OPTIONS_WITH_ARG = new Set(['-C', '-c']);
41
+ const GIT_GLOBAL_OPTIONS_NO_ARG = new Set([
42
+ '-v',
43
+ '--version',
44
+ '-h',
45
+ '--help',
46
+ '--exec-path',
47
+ '--html-path',
48
+ '--man-path',
49
+ '--info-path',
50
+ '-p',
51
+ '--paginate',
52
+ '-P',
53
+ '--no-pager',
54
+ '--no-replace-objects',
55
+ '--bare',
56
+ ]);
57
+ const GIT_GLOBAL_OPTION_WITH_INLINE_ARG = /^--[a-z-]+=/;
58
+ function stripGitGlobalOptions(tokens) {
59
+ let index = 0;
60
+ while (index < tokens.length) {
61
+ const token = tokens[index];
62
+ if (GIT_GLOBAL_OPTIONS_WITH_ARG.has(token))
63
+ index += 2;
64
+ else if (GIT_GLOBAL_OPTIONS_NO_ARG.has(token) || GIT_GLOBAL_OPTION_WITH_INLINE_ARG.test(token))
65
+ index += 1;
66
+ else
67
+ break;
68
+ }
69
+ return tokens.slice(index);
70
+ }
71
+ // Normalizes a segment to "<leading token> <first subcommand>" (e.g. "git status",
72
+ // "gh pr", or just "cat" when there's no second token), with one exception that goes
73
+ // one token deeper because a permissions file may distinguish at that depth:
74
+ // package runners — "pnpm run build" vs "pnpm exec build" are different
75
+ // permission entries, so the run/exec target must
76
+ // stay out of the generic two-token rule.
77
+ function normalizeSegment(segment) {
78
+ const [first, ...rest] = segment;
79
+ if (!first)
80
+ return '';
81
+ // A segment that is only assignments (stripEnvPrefix left it intact) has no command;
82
+ // its value may be a secret of any length, so always redact it.
83
+ if (ENV_ASSIGNMENT_PATTERN.test(first))
84
+ return REDACTED_TOKEN;
85
+ if (isPackageRunner(first) && (rest[0] === 'run' || rest[0] === 'exec') && rest[1]) {
86
+ return `${redactOverlongToken(first)} ${rest[0]} ${redactOverlongToken(rest[1])}`;
87
+ }
88
+ const effectiveRest = first === 'git' ? stripGitGlobalOptions(rest) : rest;
89
+ return effectiveRest[0]
90
+ ? `${redactOverlongToken(first)} ${redactOverlongToken(effectiveRest[0])}`
91
+ : redactOverlongToken(first);
92
+ }
93
+ // A leading "cd <path> && ..." (or "cd <path>; ...") prelude is not itself the
94
+ // actionable command — it's a workdir change common in Bash sessions with no separate
95
+ // cwd param. Without skipping it, "cd /repo && pnpm install" would normalize to "cd
96
+ // /repo" and both miss existing pnpm policy coverage and leak a checkout path into
97
+ // non-raw retro output. Only a *leading run* of cd segments is skipped — a bare `cd
98
+ // /repo` with nothing after it has no other segment to fall back to, so it still
99
+ // normalizes as-is.
100
+ function skipLeadingCdSegments(segments) {
101
+ let index = 0;
102
+ while (index < segments.length - 1 && stripEnvPrefix(segments[index])[0] === 'cd')
103
+ index++;
104
+ return segments.slice(index);
105
+ }
106
+ // Produces a classification key for a raw shell command, for comparison against the
107
+ // prefix lists in a consumer permissions file (excluded commands, allow,
108
+ // and deny lists). This is a privacy
109
+ // boundary: callers building non---raw report output must emit only this normalized
110
+ // prefix, never the raw `command` string.
111
+ //
112
+ // Only the first non-cd segment of a compound command (`git status && git push`) is
113
+ // normalized — this is a rough per-command categorization tool, not a full command
114
+ // parser, so a compound command is bucketed by its first actionable part.
115
+ //
116
+ // The leading token is preserved as-is (no basename stripping for a path-qualified
117
+ // binary like "/usr/bin/git"):
118
+ // permissions files may also list literal relative-path scripts (e.g. "./scripts/run")
119
+ // that must not be collapsed to a basename.
120
+ export function normalizeCommandPrefix(command) {
121
+ const segments = skipLeadingCdSegments(splitCommandSegments(command));
122
+ const segment = stripEnvPrefix(segments[0] ?? []);
123
+ return normalizeSegment(segment);
124
+ }
@@ -0,0 +1,2 @@
1
+ export declare function splitCommandSegments(command: string): string[][];
2
+ export declare function isPackageRunner(word: string | undefined): boolean;
@@ -0,0 +1,54 @@
1
+ // Commands are matched by command position, not by scanning for words anywhere in
2
+ // the string. The string is split into segments on unquoted `;`, `&`, `|` and
3
+ // newlines (so `git push;gh pr view` splits even with no surrounding whitespace).
4
+ // Quoted spans, including any whitespace inside them, are grouped into a single
5
+ // word, mirroring real shell tokenization.
6
+ export function splitCommandSegments(command) {
7
+ const segments = [];
8
+ let current = [];
9
+ let word = '';
10
+ let quote = null;
11
+ const flushWord = () => {
12
+ if (word) {
13
+ current.push(word);
14
+ word = '';
15
+ }
16
+ };
17
+ const flushSegment = () => {
18
+ flushWord();
19
+ if (current.length) {
20
+ segments.push(current);
21
+ current = [];
22
+ }
23
+ };
24
+ for (const char of command) {
25
+ if (quote) {
26
+ if (char === quote)
27
+ quote = null;
28
+ else
29
+ word += char;
30
+ continue;
31
+ }
32
+ if (char === '"' || char === "'") {
33
+ quote = char;
34
+ continue;
35
+ }
36
+ if (char === ';' || char === '&' || char === '|' || char === '\n' || char === '\r') {
37
+ flushSegment();
38
+ continue;
39
+ }
40
+ if (/\s/.test(char)) {
41
+ flushWord();
42
+ continue;
43
+ }
44
+ word += char;
45
+ }
46
+ flushSegment();
47
+ return segments;
48
+ }
49
+ const PACKAGE_RUNNERS = ['npx', 'pnpm', 'pnpx', 'yarn'];
50
+ export function isPackageRunner(word) {
51
+ if (!word)
52
+ return false;
53
+ return PACKAGE_RUNNERS.some((runner) => word === runner || word.endsWith(`/${runner}`));
54
+ }
@@ -5,6 +5,11 @@ export { codexChildren, codexIdentity } from './codex.mts';
5
5
  export { formatTranscriptFacts, formatUnavailable } from './format.mts';
6
6
  export type { ResolveOptions } from './resolve-transcript-file.mts';
7
7
  export { resolveTranscriptFile } from './resolve-transcript-file.mts';
8
+ export { asRecord } from './shared.mts';
9
+ export { asArray } from './as-array.mts';
10
+ export { normalizeCommandPrefix } from './command-prefix.mts';
11
+ export { isPackageRunner, splitCommandSegments } from './command-segments.mts';
12
+ export { DEFAULT_MAX_RECORD_BYTES, openTranscriptLines } from './transcript-lines.mts';
8
13
  export type RetrospectiveTranscriptReport = {
9
14
  markdown: string;
10
15
  coverage: 'complete' | 'partial' | 'unavailable';
@@ -11,6 +11,11 @@ import { emptyFacts, hasMalformedInteriorRecord, parseLines, } from "./shared.mj
11
11
  export { codexChildren, codexIdentity } from "./codex.mjs";
12
12
  export { formatTranscriptFacts, formatUnavailable } from "./format.mjs";
13
13
  export { resolveTranscriptFile } from "./resolve-transcript-file.mjs";
14
+ export { asRecord } from "./shared.mjs";
15
+ export { asArray } from "./as-array.mjs";
16
+ export { normalizeCommandPrefix } from "./command-prefix.mjs";
17
+ export { isPackageRunner, splitCommandSegments } from "./command-segments.mjs";
18
+ export { DEFAULT_MAX_RECORD_BYTES, openTranscriptLines } from "./transcript-lines.mjs";
14
19
  const MALFORMED_INTERIOR = 'malformed interior transcript record';
15
20
  function partialCapture(lines) {
16
21
  return parseLines(lines).length !== lines.filter((line) => line.trim()).length;
@@ -15,5 +15,15 @@ type TranscriptResolution = {
15
15
  error: string;
16
16
  };
17
17
  export declare function globFrom(root: string, pattern: string): string[];
18
+ /**
19
+ * Canonical transcript resolution for consumers.
20
+ *
21
+ * - Session ids are validated as UUIDs and lowercased (`invalid session id format` otherwise).
22
+ * - Roots honor `CODEX_HOME`, `CLAUDE_CONFIG_DIR` and `GROK_HOME` from `options.env`
23
+ * (default `process.env`), unless an explicit `*SessionsDir`/`projectsDir` is given.
24
+ * - Returns `{ error }` when more than one transcript matches a session, rather than
25
+ * picking the first match, and when none matches.
26
+ * - An explicit `jsonlPath` skips discovery.
27
+ */
18
28
  export declare function resolveTranscriptFile(options: ResolveOptions): TranscriptResolution;
19
29
  export {};
@@ -7,6 +7,16 @@ export const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9
7
7
  export function globFrom(root, pattern) {
8
8
  return globSync(pattern, { cwd: root }).map((path) => join(root, path));
9
9
  }
10
+ /**
11
+ * Canonical transcript resolution for consumers.
12
+ *
13
+ * - Session ids are validated as UUIDs and lowercased (`invalid session id format` otherwise).
14
+ * - Roots honor `CODEX_HOME`, `CLAUDE_CONFIG_DIR` and `GROK_HOME` from `options.env`
15
+ * (default `process.env`), unless an explicit `*SessionsDir`/`projectsDir` is given.
16
+ * - Returns `{ error }` when more than one transcript matches a session, rather than
17
+ * picking the first match, and when none matches.
18
+ * - An explicit `jsonlPath` skips discovery.
19
+ */
10
20
  export function resolveTranscriptFile(options) {
11
21
  if (options.sessionId && !SESSION_ID.test(options.sessionId))
12
22
  return { error: 'invalid session id format' };
@@ -0,0 +1,22 @@
1
+ export declare const DEFAULT_MAX_RECORD_BYTES: number;
2
+ export type OpenTranscriptLinesOptions = {
3
+ /** Largest allowed record (one line, excluding its newline) in bytes. Defaults to 2 MiB. */
4
+ maxRecordBytes?: number;
5
+ };
6
+ export type OpenTranscriptLinesResult = {
7
+ lines: AsyncIterable<string>;
8
+ } | {
9
+ error: string;
10
+ };
11
+ /**
12
+ * Opens a transcript for streaming, one line at a time, with `String.split('\n')`
13
+ * semantics (a trailing newline yields a final empty line, `\r` is preserved).
14
+ *
15
+ * A `maxRecordBytes` that is not a non-negative safe integer rejects with `RangeError`.
16
+ * An open failure resolves to `{ error }`. An oversized record is NOT skipped or
17
+ * reported inline: the returned async iterable throws
18
+ * `transcript record exceeds <maxRecordBytes> byte limit` when it reaches the
19
+ * record, ending iteration, and the underlying stream is destroyed. Lines yielded
20
+ * before the oversized record remain valid.
21
+ */
22
+ export declare function openTranscriptLines(path: string, { maxRecordBytes }?: OpenTranscriptLinesOptions): Promise<OpenTranscriptLinesResult>;
@@ -0,0 +1,74 @@
1
+ import { createReadStream } from 'node:fs';
2
+ export const DEFAULT_MAX_RECORD_BYTES = 2 * 1024 * 1024;
3
+ /**
4
+ * Opens a transcript for streaming, one line at a time, with `String.split('\n')`
5
+ * semantics (a trailing newline yields a final empty line, `\r` is preserved).
6
+ *
7
+ * A `maxRecordBytes` that is not a non-negative safe integer rejects with `RangeError`.
8
+ * An open failure resolves to `{ error }`. An oversized record is NOT skipped or
9
+ * reported inline: the returned async iterable throws
10
+ * `transcript record exceeds <maxRecordBytes> byte limit` when it reaches the
11
+ * record, ending iteration, and the underlying stream is destroyed. Lines yielded
12
+ * before the oversized record remain valid.
13
+ */
14
+ export async function openTranscriptLines(path, { maxRecordBytes = DEFAULT_MAX_RECORD_BYTES } = {}) {
15
+ if (!Number.isSafeInteger(maxRecordBytes) || maxRecordBytes < 0)
16
+ throw new RangeError('maxRecordBytes must be a non-negative integer');
17
+ const stream = createReadStream(path);
18
+ try {
19
+ await new Promise((resolve, reject) => {
20
+ stream.once('open', () => resolve());
21
+ stream.once('error', reject);
22
+ });
23
+ const lines = streamTranscriptLines(stream, maxRecordBytes);
24
+ const release = lines.return.bind(lines);
25
+ // The file is already open, so cancelling before the first next() must still close it;
26
+ // an unstarted generator never reaches its own finally block.
27
+ lines.return = (value) => {
28
+ stream.destroy();
29
+ return release(value);
30
+ };
31
+ return { lines };
32
+ }
33
+ catch (err) {
34
+ stream.destroy();
35
+ return { error: err.message };
36
+ }
37
+ }
38
+ async function* streamTranscriptLines(stream, maxRecordBytes) {
39
+ const pending = [];
40
+ let pendingBytes = 0;
41
+ const tooLarge = () => new Error(`transcript record exceeds ${maxRecordBytes} byte limit`);
42
+ const line = (tail) => {
43
+ const bytes = pendingBytes + (tail?.byteLength ?? 0);
44
+ if (bytes > maxRecordBytes)
45
+ throw tooLarge();
46
+ const value = Buffer.concat(tail ? [...pending, tail] : pending, bytes).toString('utf8');
47
+ pending.length = 0;
48
+ pendingBytes = 0;
49
+ return value;
50
+ };
51
+ try {
52
+ for await (const chunk of stream) {
53
+ let start = 0;
54
+ for (;;) {
55
+ const newline = chunk.indexOf(10, start);
56
+ if (newline === -1)
57
+ break;
58
+ yield line(chunk.subarray(start, newline));
59
+ start = newline + 1;
60
+ }
61
+ if (start < chunk.byteLength) {
62
+ const tail = chunk.subarray(start);
63
+ pendingBytes += tail.byteLength;
64
+ if (pendingBytes > maxRecordBytes)
65
+ throw tooLarge();
66
+ pending.push(tail);
67
+ }
68
+ }
69
+ yield line();
70
+ }
71
+ finally {
72
+ stream.destroy();
73
+ }
74
+ }
@@ -286,8 +286,10 @@ shape is an error, never a pass.
286
286
  retrospective tools. All three take `sessionId` (required) and `worktree` (optional), need a server
287
287
  launched inside a repository worktree (they run the checkout policy), and share this structured
288
288
  input: `title` (single line, at most 256 characters), `summary`, `impact`, `relatedIssues`
289
- (`[{ number, relation: "closes" | "refs", reason }]`, may be empty), optional `noSourceIssue`
290
- (`{ reason }`, see below), `details` (string array), `base` (default `main`), optional `agent`, optional
289
+ (`[{ number, relation: "closes" | "refs", reason, repository? }]`, may be empty; the optional
290
+ `repository` is a single `owner/name` and renders the line as lowercase `owner/name#N`), optional
291
+ `noSourceIssue` (`{ reason }`, see below), `details` (string array), `base` (default `main`),
292
+ optional `agent`, optional
291
293
  `model`, optional `mode` (`interactive` | `autonomous`) and `workspaceSetup`. `pr_update` also
292
294
  requires `pr`; `pr_validate` accepts an optional `pr`.
293
295
 
@@ -319,9 +321,11 @@ which may keep sending it.
319
321
 
320
322
  ### Pull requests with no source issue
321
323
 
322
- Pass `noSourceIssue: { reason }` (a single line) with an empty `relatedIssues`; a non-empty
323
- `relatedIssues` beside it is rejected. The Related issues section then holds the generic statement
324
- `- No source issue — <reason>` instead of `- None`. A policy may replace the statement by
324
+ Pass `noSourceIssue: { reason }` (a single line) with an empty `relatedIssues`, or with a
325
+ `relatedIssues` whose every entry is `refs`; a `closes` entry beside it is rejected. The Related
326
+ issues section then holds the generic statement `- No source issue — <reason>` instead of `- None`,
327
+ preceded by the `- Refs … — <reason>` lines when there are any (the statement is always the last
328
+ line). A policy may replace the statement by
325
329
  exporting `renderNoSourceIssue({ reason })`, returning a non-empty string (30 s). The field also
326
330
  reaches `validatePullRequest` (with the trusted `mode`, so the policy can apply an interactive-only
327
331
  rule) and `pullRequestDefaults`.
@@ -339,8 +343,9 @@ rule) and `pullRequestDefaults`.
339
343
 
340
344
  ## Related issues
341
345
 
342
- - Closes #N — <reason> (or "Refs #N"; "- None" when empty;
343
- "- No source issue — <reason>" for noSourceIssue)
346
+ - Closes #N — <reason> (or "Refs #N"; owner/name#N with `repository`; "- None" when empty;
347
+ "- No source issue — <reason>" for noSourceIssue, rendered
348
+ after any Refs lines)
344
349
 
345
350
  ## Details
346
351
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vouchington-tooling",
3
- "version": "0.46.0",
3
+ "version": "0.48.0",
4
4
  "description": "Vouchington CLI and extractable tooling libraries.",
5
5
  "homepage": "https://github.com/vouchington/vouchington-tooling/tree/main/packages/vouchington-tooling#readme",
6
6
  "bugs": {
@@ -375,6 +375,7 @@
375
375
  "@types/mdast": "4.0.4",
376
376
  "@types/unist": "3.0.3",
377
377
  "@typescript-eslint/parser": "^8.71.1",
378
+ "agent-blackboard": "^0.7.0",
378
379
  "csv-parse": "7.0.3",
379
380
  "csv-stringify": "6.9.0",
380
381
  "dockerfile-ast": "0.7.1",
@@ -390,22 +391,17 @@
390
391
  "@modelcontextprotocol/sdk": "1.32.1",
391
392
  "@types/picomatch": "^4.0.3",
392
393
  "@types/spdx-expression-parse": "4.0.0",
393
- "agent-blackboard": "^0.6.1",
394
394
  "no-mistakes": "0.83.0",
395
395
  "smol-toml": "1.9.1",
396
396
  "typescript5": "npm:typescript@5.9.3"
397
397
  },
398
398
  "peerDependencies": {
399
399
  "@modelcontextprotocol/sdk": "^1.31.0",
400
- "agent-blackboard": "^0.6.1",
401
400
  "no-mistakes": "*",
402
401
  "typescript": ">=5",
403
402
  "vitest": ">=5"
404
403
  },
405
404
  "peerDependenciesMeta": {
406
- "agent-blackboard": {
407
- "optional": true
408
- },
409
405
  "@modelcontextprotocol/sdk": {
410
406
  "optional": true
411
407
  },