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 +5 -1
- package/dist/agent-harness-identity/index.d.mts +3 -25
- package/dist/agent-harness-identity/index.mjs +3 -28
- package/dist/mcp-server/pr-agent.mjs +3 -3
- package/dist/mcp-server/pr-body.d.mts +3 -3
- package/dist/mcp-server/pr-body.mjs +11 -11
- package/dist/mcp-server/pr-input.d.mts +3 -1
- package/dist/mcp-server/pr-input.mjs +21 -3
- package/dist/mcp-server/tools-pr.mjs +12 -5
- package/dist/retrospective-transcript/as-array.d.mts +1 -0
- package/dist/retrospective-transcript/as-array.mjs +3 -0
- package/dist/retrospective-transcript/command-prefix.d.mts +1 -0
- package/dist/retrospective-transcript/command-prefix.mjs +124 -0
- package/dist/retrospective-transcript/command-segments.d.mts +2 -0
- package/dist/retrospective-transcript/command-segments.mjs +54 -0
- package/dist/retrospective-transcript/index.d.mts +5 -0
- package/dist/retrospective-transcript/index.mjs +5 -0
- package/dist/retrospective-transcript/resolve-transcript-file.d.mts +10 -0
- package/dist/retrospective-transcript/resolve-transcript-file.mjs +10 -0
- package/dist/retrospective-transcript/transcript-lines.d.mts +22 -0
- package/dist/retrospective-transcript/transcript-lines.mjs +74 -0
- package/docs/mcp-server.md +12 -7
- package/package.json +2 -6
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
|
-
|
|
2
|
-
export
|
|
3
|
-
export
|
|
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
|
-
|
|
2
|
-
|
|
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 =
|
|
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
|
|
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
|
|
16
|
-
* (default {@link defaultNoSourceStatement}) instead of `- None`. Pure;
|
|
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
|
-
|
|
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
|
|
13
|
-
* (default {@link defaultNoSourceStatement}) instead of `- None`. Pure;
|
|
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.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
/**
|
|
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.
|
|
32
|
-
throw new Error('noSourceIssue
|
|
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
|
-
'
|
|
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
|
|
31
|
-
'
|
|
32
|
-
'passed to the policy, which may allow it
|
|
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 @@
|
|
|
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,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
|
+
}
|
package/docs/mcp-server.md
CHANGED
|
@@ -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
|
|
290
|
-
|
|
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
|
|
323
|
-
`relatedIssues`
|
|
324
|
-
`- No source issue — <reason>` instead of `- None
|
|
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.
|
|
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
|
},
|