vouchington-tooling 0.45.0 → 0.46.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.
Files changed (88) hide show
  1. package/README.md +10 -52
  2. package/dist/agent-blackboard/feedback-audit-source.d.mts +4 -10
  3. package/dist/agent-blackboard/feedback-audit-source.mjs +7 -18
  4. package/dist/agent-blackboard/feedback-compose-audit.mjs +6 -14
  5. package/dist/agent-blackboard/feedback-compose.d.mts +1 -4
  6. package/dist/agent-blackboard/feedback-compose.mjs +2 -5
  7. package/dist/agent-blackboard/feedback-fields.mjs +1 -1
  8. package/dist/agent-blackboard/feedback-identity.mjs +1 -1
  9. package/dist/agent-blackboard/feedback-journal-audit.d.mts +6 -8
  10. package/dist/agent-blackboard/feedback-journal-audit.mjs +9 -10
  11. package/dist/agent-blackboard/feedback-journal-sandbox.d.mts +2 -2
  12. package/dist/agent-blackboard/feedback-journal-sandbox.mjs +2 -2
  13. package/dist/agent-blackboard/feedback-outbox-rejected.mjs +1 -1
  14. package/dist/agent-blackboard/feedback-outbox.mjs +2 -2
  15. package/dist/agent-blackboard/index.d.mts +1 -1
  16. package/dist/audit-journal/ci-failures.d.mts +6 -0
  17. package/dist/{session-friction → audit-journal}/ci-failures.mjs +3 -9
  18. package/dist/{session-friction → audit-journal}/journal.d.mts +0 -1
  19. package/dist/{session-friction → audit-journal}/journal.mjs +1 -1
  20. package/dist/audit-journal/session-id.d.mts +1 -0
  21. package/dist/{session-friction → audit-journal}/session-id.mjs +0 -5
  22. package/dist/{session-friction → audit-journal}/text.d.mts +1 -4
  23. package/dist/{session-friction → audit-journal}/text.mjs +4 -22
  24. package/dist/audit-journal/types.d.mts +17 -0
  25. package/dist/cli/commands/gha-runtime-audit.mjs +2 -0
  26. package/dist/gh-cli/env.d.mts +9 -0
  27. package/dist/gh-cli/env.mjs +12 -0
  28. package/dist/gh-cli/exec.d.mts +7 -4
  29. package/dist/gh-cli/exec.mjs +7 -5
  30. package/dist/gh-cli/index.d.mts +1 -0
  31. package/dist/gh-cli/index.mjs +1 -0
  32. package/dist/gha-post-review/github.mjs +2 -1
  33. package/dist/index.d.mts +3 -3
  34. package/dist/index.mjs +2 -2
  35. package/dist/mcp-server/context.d.mts +2 -0
  36. package/dist/mcp-server/dispatch.d.mts +2 -0
  37. package/dist/mcp-server/dispatch.mjs +1 -0
  38. package/dist/mcp-server/harness.test-helpers.d.mts +1 -0
  39. package/dist/mcp-server/harness.test-helpers.mjs +1 -0
  40. package/dist/mcp-server/pr-agent.d.mts +12 -0
  41. package/dist/mcp-server/pr-agent.mjs +34 -0
  42. package/dist/mcp-server/pr-body.d.mts +2 -0
  43. package/dist/mcp-server/pr-body.mjs +1 -2
  44. package/dist/mcp-server/pr-gh.mjs +2 -1
  45. package/dist/mcp-server/pr-input.d.mts +2 -1
  46. package/dist/mcp-server/pr-input.mjs +1 -1
  47. package/dist/mcp-server/pr-live.d.mts +1 -0
  48. package/dist/mcp-server/pr-live.mjs +1 -1
  49. package/dist/mcp-server/pr-prepare.mjs +25 -4
  50. package/dist/mcp-server/server.mjs +1 -0
  51. package/dist/mcp-server/tools-pr.mjs +8 -12
  52. package/dist/{session-friction → private-files}/lock.mjs +3 -2
  53. package/dist/private-files/log-file.d.mts +3 -0
  54. package/dist/private-files/log-file.mjs +53 -0
  55. package/dist/{session-friction → private-files}/utf8.mjs +1 -1
  56. package/dist/retrospective-facts/exec.mjs +2 -1
  57. package/docs/mcp-server.md +18 -4
  58. package/package.json +1 -6
  59. package/dist/session-friction/ci-failures.d.mts +0 -13
  60. package/dist/session-friction/classify.d.mts +0 -2
  61. package/dist/session-friction/classify.mjs +0 -98
  62. package/dist/session-friction/command-segments.d.mts +0 -3
  63. package/dist/session-friction/command-segments.mjs +0 -94
  64. package/dist/session-friction/coverage-ledger.d.mts +0 -10
  65. package/dist/session-friction/coverage-ledger.mjs +0 -75
  66. package/dist/session-friction/directory.d.mts +0 -1
  67. package/dist/session-friction/directory.mjs +0 -71
  68. package/dist/session-friction/index.d.mts +0 -6
  69. package/dist/session-friction/index.mjs +0 -5
  70. package/dist/session-friction/log-file.d.mts +0 -7
  71. package/dist/session-friction/log-file.mjs +0 -95
  72. package/dist/session-friction/log.d.mts +0 -8
  73. package/dist/session-friction/log.mjs +0 -163
  74. package/dist/session-friction/normalize.d.mts +0 -1
  75. package/dist/session-friction/normalize.mjs +0 -81
  76. package/dist/session-friction/report.d.mts +0 -16
  77. package/dist/session-friction/report.mjs +0 -91
  78. package/dist/session-friction/sandbox.d.mts +0 -3
  79. package/dist/session-friction/sandbox.mjs +0 -43
  80. package/dist/session-friction/session-id.d.mts +0 -2
  81. package/dist/session-friction/types.d.mts +0 -80
  82. /package/dist/{session-friction → audit-journal}/audit-block.d.mts +0 -0
  83. /package/dist/{session-friction → audit-journal}/audit-block.mjs +0 -0
  84. /package/dist/{session-friction → audit-journal}/audit-fit.d.mts +0 -0
  85. /package/dist/{session-friction → audit-journal}/audit-fit.mjs +0 -0
  86. /package/dist/{session-friction → audit-journal}/types.mjs +0 -0
  87. /package/dist/{session-friction → private-files}/lock.d.mts +0 -0
  88. /package/dist/{session-friction → private-files}/utf8.d.mts +0 -0
@@ -1,16 +1,19 @@
1
1
  /** A text-mode command runner: argv in, stdout out. Rejects on a non-zero exit. */
2
2
  export type RunTextCommand = (args: string[]) => Promise<string>;
3
3
  /** The `child_process.execFile` shape `createCommandRunner` wraps — injectable for tests. */
4
- export type ExecFileText = (command: string, args: string[]) => Promise<{
4
+ export type ExecFileText = (command: string, args: string[], options?: {
5
+ env: NodeJS.ProcessEnv;
6
+ }) => Promise<{
5
7
  stdout: string;
6
8
  }>;
7
9
  /**
8
10
  * Builds a `RunTextCommand` bound to a fixed binary. The default `exec` wraps `execFile` via
9
11
  * `promisify` so a test can inject a fake without spawning a real process; `runGh`/`runGit` below
10
- * are this factory applied to the two binaries this module cares about.
12
+ * are this factory applied to the two binaries this module cares about. `buildEnv` maps the
13
+ * parent environment to the one the child is spawned with (identity by default).
11
14
  */
12
- export declare function createCommandRunner(command: string, exec?: ExecFileText): RunTextCommand;
13
- /** Runs `gh` and returns its stdout. */
15
+ export declare function createCommandRunner(command: string, exec?: ExecFileText, buildEnv?: (env: NodeJS.ProcessEnv) => NodeJS.ProcessEnv): RunTextCommand;
16
+ /** Runs `gh` and returns its stdout, with `CLICOLOR_FORCE` removed so `--json` output stays plain. */
14
17
  export declare const runGh: RunTextCommand;
15
18
  /** Runs `git` and returns its stdout. */
16
19
  export declare const runGit: RunTextCommand;
@@ -1,18 +1,20 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
+ import { ghSpawnEnv } from "./env.mjs";
3
4
  const execFileAsync = promisify(execFile);
4
5
  /**
5
6
  * Builds a `RunTextCommand` bound to a fixed binary. The default `exec` wraps `execFile` via
6
7
  * `promisify` so a test can inject a fake without spawning a real process; `runGh`/`runGit` below
7
- * are this factory applied to the two binaries this module cares about.
8
+ * are this factory applied to the two binaries this module cares about. `buildEnv` maps the
9
+ * parent environment to the one the child is spawned with (identity by default).
8
10
  */
9
- export function createCommandRunner(command, exec = (cmd, args) => execFileAsync(cmd, args)) {
11
+ export function createCommandRunner(command, exec = (cmd, args, options) => execFileAsync(cmd, args, { ...options, encoding: 'utf8' }), buildEnv = (env) => env) {
10
12
  return async (args) => {
11
- const { stdout } = await exec(command, args);
13
+ const { stdout } = await exec(command, args, { env: buildEnv(process.env) });
12
14
  return stdout;
13
15
  };
14
16
  }
15
- /** Runs `gh` and returns its stdout. */
16
- export const runGh = createCommandRunner('gh');
17
+ /** Runs `gh` and returns its stdout, with `CLICOLOR_FORCE` removed so `--json` output stays plain. */
18
+ export const runGh = createCommandRunner('gh', undefined, ghSpawnEnv);
17
19
  /** Runs `git` and returns its stdout. */
18
20
  export const runGit = createCommandRunner('git');
@@ -1,4 +1,5 @@
1
1
  export { createCommandRunner, runGh, runGit } from './exec.mts';
2
+ export { ghSpawnEnv } from './env.mts';
2
3
  export type { ExecFileText, RunTextCommand } from './exec.mts';
3
4
  export { DiffCommandError, processDiffCommand } from './diff.mts';
4
5
  export type { DiffBlockCallback, DiffCommand } from './diff.mts';
@@ -1,4 +1,5 @@
1
1
  export { createCommandRunner, runGh, runGit } from "./exec.mjs";
2
+ export { ghSpawnEnv } from "./env.mjs";
2
3
  export { DiffCommandError, processDiffCommand } from "./diff.mjs";
3
4
  export { GITHUB_BODY_MAX_CHARACTERS, validateGitHubBodyLength } from "./body-length.mjs";
4
5
  export { assertHeadPushed, buildGhPrCreateArgs, createPullRequest, DetachedHeadError, HeadNotPushedError, HeadOutOfDateError, resolveHeadBranch, } from "./pr-create.mjs";
@@ -1,5 +1,6 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { appendFileSync } from 'node:fs';
3
+ import { ghSpawnEnv } from "../gh-cli/env.mjs";
3
4
  import { ReviewPayloadError } from "../gha-review-payload/index.mjs";
4
5
  import { readRegularReviewPayload } from "../gha-review-payload/index.mjs";
5
6
  import { createActionsClaudeTokenIo, withClaudeAppToken } from "./claude-token.mjs";
@@ -11,7 +12,7 @@ export function createGhExec(exec = execFileSync) {
11
12
  return (args, options) => exec('gh', [...args], {
12
13
  encoding: 'utf8',
13
14
  input: options?.input,
14
- env: options?.env ?? process.env,
15
+ env: ghSpawnEnv(options?.env ?? process.env),
15
16
  }).trim();
16
17
  }
17
18
  export function writePostedOutput(posted, commentCount, outputPath = process.env.GITHUB_OUTPUT) {
package/dist/index.d.mts CHANGED
@@ -8,8 +8,8 @@ export { runRetrospectiveFacts } from './retrospective-facts/index.mts';
8
8
  export type { CommandExecutor, CommandResult, RetrospectiveFactsOptions, } from './retrospective-facts/index.mts';
9
9
  export { appendJournal, assertSessionId, cleanupSnapshotPartitions, partitionSnapshot, probeBlackboard, readJournal, resolveBlackboardConnection, } from './agent-blackboard/index.mts';
10
10
  export type { BlackboardConnection, SnapshotChecksum, SnapshotCleanupReceipt, SnapshotCleanupOptions, SnapshotCounts, SnapshotManifest, SnapshotPartition, SnapshotPartitionOptions, SnapshotPartitionResult, SnapshotSelection, } from './agent-blackboard/index.mts';
11
- export { buildSessionFrictionReport, classifyFrictionObservation, FRICTION_LOG_MAX_EVENTS, isConformingCiFailureBlock, normalizeCommandPrefix, readFrictionLog, recordFriction, } from './session-friction/index.mts';
12
- export type { FrictionEvent, FrictionEventKind, FrictionLogOptions, FrictionLogReadResult, FrictionObservation, JournalEntry, JournalLoader, JournalLoadResult, PermissionRequestObservation, SessionFrictionReport, SessionFrictionReportOptions, ToolResultObservation, } from './session-friction/index.mts';
11
+ export { isConformingCiFailureBlock } from './audit-journal/ci-failures.mts';
12
+ export type { JournalEntry, JournalLoader, JournalLoadResult } from './audit-journal/types.mts';
13
13
  export { EphemeralListenerAttemptsExhaustedError, isRunnerReservedPort, listenOnRunnerUnreservedEphemeralPort, loadRunnerPortPolicy, runnerPortPolicy, validateRunnerPortPolicy, } from './runner-port-policy/index.mts';
14
14
  export type { EphemeralListenerOptions, RunnerPortPolicy } from './runner-port-policy/index.mts';
15
15
  export { extractCreateTableMetadata, extractDefaultFunction, extractFuncCallArgColumnNames, initSqlAst, lineOfUtf8ByteOffset, MissingSqlAstParserError, parseSql, } from './sql-ast/index.mts';
@@ -95,7 +95,7 @@ export { validateResolvedPinDelta } from './swift-resolved-pin-delta/index.mts';
95
95
  export type { ResolvedDocument, ResolvedPin, ValidateResolvedPinDeltaOptions, } from './swift-resolved-pin-delta/index.mts';
96
96
  export { DEFAULT_MAX_DIAGNOSTIC_REPORTS, DEFAULT_MAX_FORMATTED_DIAGNOSTIC_REPORTS, formatDiagnosticReportSummaries, HARD_MAX_DIAGNOSTIC_REPORTS, readDiagnosticReportSummaries, summarizeDiagnosticReport, } from './vitest-diagnostics/index.mts';
97
97
  export type { DiagnosticReportLimitOptions, DiagnosticReportSummary, } from './vitest-diagnostics/index.mts';
98
- export { createCommandRunner, runGh, runGit } from './gh-cli/index.mts';
98
+ export { createCommandRunner, ghSpawnEnv, runGh, runGit } from './gh-cli/index.mts';
99
99
  export type { ExecFileText, RunTextCommand } from './gh-cli/index.mts';
100
100
  export { DiffCommandError, processDiffCommand } from './gh-cli/index.mts';
101
101
  export type { DiffBlockCallback, DiffCommand } from './gh-cli/index.mts';
package/dist/index.mjs CHANGED
@@ -4,7 +4,7 @@ export { HARNESS_IDS, inspectHarnessEnvironment, selectHarnessSession, } from ".
4
4
  export { codexChildren, codexIdentity, computeTranscriptFacts, formatTranscriptFacts, formatUnavailable, resolveTranscriptFile, runRetrospectiveTranscript, } from "./retrospective-transcript/index.mjs";
5
5
  export { runRetrospectiveFacts } from "./retrospective-facts/index.mjs";
6
6
  export { appendJournal, assertSessionId, cleanupSnapshotPartitions, partitionSnapshot, probeBlackboard, readJournal, resolveBlackboardConnection, } from "./agent-blackboard/index.mjs";
7
- export { buildSessionFrictionReport, classifyFrictionObservation, FRICTION_LOG_MAX_EVENTS, isConformingCiFailureBlock, normalizeCommandPrefix, readFrictionLog, recordFriction, } from "./session-friction/index.mjs";
7
+ export { isConformingCiFailureBlock } from "./audit-journal/ci-failures.mjs";
8
8
  export { EphemeralListenerAttemptsExhaustedError, isRunnerReservedPort, listenOnRunnerUnreservedEphemeralPort, loadRunnerPortPolicy, runnerPortPolicy, validateRunnerPortPolicy, } from "./runner-port-policy/index.mjs";
9
9
  export { extractCreateTableMetadata, extractDefaultFunction, extractFuncCallArgColumnNames, initSqlAst, lineOfUtf8ByteOffset, MissingSqlAstParserError, parseSql, } from "./sql-ast/index.mjs";
10
10
  export { dollarQuoteEnd, lineOf, maskSqlQuotedText, readDollarQuoteDelimiter, readStringLiteral, splitSqlStatements, sqlFragments, stripSqlComments, } from "./sql-scanner/index.mjs";
@@ -52,7 +52,7 @@ export { normalizeSwiftSource } from "./swift-semantic-equal/index.mjs";
52
52
  export { isSwiftCodeOffset, parseUniqueSwiftBinaryTargetChecksum, } from "./swift-source-offset/index.mjs";
53
53
  export { validateResolvedPinDelta } from "./swift-resolved-pin-delta/index.mjs";
54
54
  export { DEFAULT_MAX_DIAGNOSTIC_REPORTS, DEFAULT_MAX_FORMATTED_DIAGNOSTIC_REPORTS, formatDiagnosticReportSummaries, HARD_MAX_DIAGNOSTIC_REPORTS, readDiagnosticReportSummaries, summarizeDiagnosticReport, } from "./vitest-diagnostics/index.mjs";
55
- export { createCommandRunner, runGh, runGit } from "./gh-cli/index.mjs";
55
+ export { createCommandRunner, ghSpawnEnv, runGh, runGit } from "./gh-cli/index.mjs";
56
56
  export { DiffCommandError, processDiffCommand } from "./gh-cli/index.mjs";
57
57
  export { GITHUB_BODY_MAX_CHARACTERS, validateGitHubBodyLength } from "./gh-cli/index.mjs";
58
58
  export { assertHeadPushed, buildGhPrCreateArgs, createPullRequest, DetachedHeadError, HeadNotPushedError, HeadOutOfDateError, resolveHeadBranch, } from "./gh-cli/index.mjs";
@@ -21,6 +21,8 @@ export type ToolContext = {
21
21
  runGit: RunTextCommand;
22
22
  /** Test seam: replaces locating and running gh. */
23
23
  runGh?: GhRunner;
24
+ /** The connected MCP client's `clientInfo.name`, once `initialize` has completed. */
25
+ clientName?: () => string | undefined;
24
26
  /** Test seam: replaces the operating system user and host name. */
25
27
  device?: () => {
26
28
  user: string;
@@ -16,6 +16,8 @@ export type ServerEnvironment = {
16
16
  spawnPolicy?: PolicySpawner;
17
17
  /** Test seam: replaces locating and running gh. */
18
18
  runGh?: GhRunner;
19
+ /** The connected MCP client's `clientInfo.name`, once `initialize` has completed. */
20
+ clientName?: () => string | undefined;
19
21
  /** Test seam: replaces the operating system user and host name. */
20
22
  device?: () => {
21
23
  user: string;
@@ -67,6 +67,7 @@ export async function callTool(name, rawArguments, environment) {
67
67
  runGit: environment.runGit,
68
68
  snapshots: environment.snapshots ?? PROCESS_SNAPSHOTS,
69
69
  ...(environment.runGh === undefined ? {} : { runGh: environment.runGh }),
70
+ ...(environment.clientName === undefined ? {} : { clientName: environment.clientName }),
70
71
  ...(environment.device === undefined ? {} : { device: environment.device }),
71
72
  ...(environment.spawnPolicy === undefined ? {} : { spawnPolicy: environment.spawnPolicy }),
72
73
  // The client resolves from this package's install, like the SDK, never from the worktree.
@@ -23,6 +23,7 @@ export declare function harness(fixture: RepoFixture, options?: FakeOptions & {
23
23
  host: string;
24
24
  };
25
25
  snapshots?: SnapshotRegistry;
26
+ clientName?: () => string | undefined;
26
27
  }): Harness;
27
28
  export declare function textOf(result: ToolCallResult): string;
28
29
  export declare function jsonOf(result: ToolCallResult): Record<string, unknown>;
@@ -28,6 +28,7 @@ export function harness(fixture, options = {}) {
28
28
  ...(options.runGh === undefined ? {} : { runGh: options.runGh }),
29
29
  ...(options.device === undefined ? {} : { device: options.device }),
30
30
  ...(options.snapshots === undefined ? {} : { snapshots: options.snapshots }),
31
+ ...(options.clientName === undefined ? {} : { clientName: options.clientName }),
31
32
  };
32
33
  return { fake, call: (name, args) => callTool(name, args, environment) };
33
34
  }
@@ -0,0 +1,12 @@
1
+ import { type HarnessId } from '../agent-harness-identity/index.mts';
2
+ /**
3
+ * The harness that launched this server, from the launch environment first and the MCP client's
4
+ * `initialize` `clientInfo.name` second. Neither comes from a tool argument.
5
+ */
6
+ export declare function deriveAgent(env: NodeJS.ProcessEnv, clientName?: string): HarnessId | undefined;
7
+ /**
8
+ * The text of the `Agent:` line before the session. The agent is the derived harness; a caller
9
+ * `agent` that does not name it, or any `model`, is shown as caller-claimed and never replaces it.
10
+ * With nothing derivable the agent is `unknown`.
11
+ */
12
+ export declare function agentLabel(derived: HarnessId | undefined, claimedAgent: string | undefined, claimedModel: string | undefined): string;
@@ -0,0 +1,34 @@
1
+ import { inspectHarnessEnvironment } from "../agent-harness-identity/index.mjs";
2
+ const PRECEDENCE = ['codex', 'claude', 'cursor', 'grok'];
3
+ /**
4
+ * The harness that launched this server, from the launch environment first and the MCP client's
5
+ * `initialize` `clientInfo.name` second. Neither comes from a tool argument.
6
+ */
7
+ export function deriveAgent(env, clientName) {
8
+ const found = inspectHarnessEnvironment(env);
9
+ const present = {
10
+ codex: Boolean(found.codex.sessionId),
11
+ claude: found.claude.compatMode || Boolean(found.claude.sessionId),
12
+ cursor: found.cursor.agentMarker || Boolean(found.cursor.sessionId),
13
+ grok: found.grok.agentMarker || found.grok.hookEvent || Boolean(found.grok.sessionId),
14
+ };
15
+ const fromEnv = PRECEDENCE.find((id) => present[id]);
16
+ if (fromEnv !== undefined)
17
+ return fromEnv;
18
+ const name = clientName?.toLowerCase() ?? '';
19
+ return PRECEDENCE.find((id) => name.includes(id));
20
+ }
21
+ /**
22
+ * The text of the `Agent:` line before the session. The agent is the derived harness; a caller
23
+ * `agent` that does not name it, or any `model`, is shown as caller-claimed and never replaces it.
24
+ * With nothing derivable the agent is `unknown`.
25
+ */
26
+ export function agentLabel(derived, claimedAgent, claimedModel) {
27
+ const notes = [];
28
+ const claim = claimedAgent?.trim();
29
+ if (claim && !(derived !== undefined && claim.toLowerCase().includes(derived)))
30
+ notes.push(`caller-claimed agent: ${claim}`);
31
+ if (claimedModel)
32
+ notes.push(`caller-claimed model: ${claimedModel}`);
33
+ return `${derived ?? 'unknown'}${notes.length ? ` (${notes.join('; ')})` : ''}`;
34
+ }
@@ -7,6 +7,8 @@ export type PullRequestProvenance = {
7
7
  host: string;
8
8
  /** The checkout label, see {@link worktreeLabel}. */
9
9
  worktree: string;
10
+ /** The Agent line text before the session, see `agentLabel`. */
11
+ agent: string;
10
12
  };
11
13
  /**
12
14
  * The pull request body: the Summary, Impact, Related issues and Details sections followed by the
@@ -21,7 +21,6 @@ export function renderPullRequestBody(input, provenance, noSourceStatement) {
21
21
  ? '- None'
22
22
  : (noSourceStatement ?? defaultNoSourceStatement(input.noSourceIssue.reason)),
23
23
  ];
24
- const agent = input.model === undefined ? input.agent : `${input.agent} (${input.model})`;
25
24
  return [
26
25
  '## Summary',
27
26
  '',
@@ -40,7 +39,7 @@ export function renderPullRequestBody(input, provenance, noSourceStatement) {
40
39
  input.details.join('\n\n'),
41
40
  '',
42
41
  `Workspace setup: ${input.workspaceSetup}`,
43
- `Agent: ${agent} session ${provenance.sessionId}`,
42
+ `Agent: ${provenance.agent} session ${provenance.sessionId}`,
44
43
  `Device: ${provenance.user}@${provenance.host.toLowerCase()}`,
45
44
  `Worktree: ${provenance.worktree}`,
46
45
  '',
@@ -3,6 +3,7 @@ import { constants } from 'node:fs';
3
3
  import { access, stat } from 'node:fs/promises';
4
4
  import { delimiter, isAbsolute, join } from 'node:path';
5
5
  import { promisify } from 'node:util';
6
+ import { ghSpawnEnv } from "../gh-cli/env.mjs";
6
7
  const execFileAsync = promisify(execFile);
7
8
  const FALLBACKS = ['/opt/homebrew/bin/gh', '/usr/local/bin/gh', '/usr/bin/gh'];
8
9
  const MAX_BUFFER_BYTES = 16_000_000;
@@ -36,7 +37,7 @@ export async function locateGh(env, fallbacks = FALLBACKS) {
36
37
  * GH_REPO, which would point gh at a repository other than the validated worktree's.
37
38
  */
38
39
  export function ghEnvironment(env) {
39
- const { AGENT_BLACKBOARD_URL: _url, AGENT_BLACKBOARD_TOKEN: _token, GH_REPO: _repo, ...rest } = env;
40
+ const { AGENT_BLACKBOARD_URL: _url, AGENT_BLACKBOARD_TOKEN: _token, GH_REPO: _repo, ...rest } = ghSpawnEnv(env);
40
41
  return rest;
41
42
  }
42
43
  /** A runner that locates gh once, on first use, from the server's environment. */
@@ -16,7 +16,8 @@ export type PullRequestInput = {
16
16
  };
17
17
  details: string[];
18
18
  base: string;
19
- agent: string;
19
+ /** The caller's claim only; the rendered Agent line comes from `agentLabel`. */
20
+ agent?: string;
20
21
  model?: string;
21
22
  /** The trusted mode, see {@link trustedMode}; never the caller's word alone. */
22
23
  mode: PullRequestMode;
@@ -70,7 +70,7 @@ export function readPullRequestInput(args, env) {
70
70
  ...(noSourceIssue === undefined ? {} : { noSourceIssue }),
71
71
  details: readDetails(args),
72
72
  base: optionalString(args, 'base') ?? 'main',
73
- agent: singleLine(args, 'agent'),
73
+ ...(args.agent === undefined ? {} : { agent: singleLine(args, 'agent') }),
74
74
  ...(model === undefined ? {} : { model }),
75
75
  mode: trustedMode(env, requested),
76
76
  // It renders on its own provenance line; a newline would forge the lines after it.
@@ -2,6 +2,7 @@ import type { RunTextCommand } from '../gh-cli/exec.mts';
2
2
  export type LivePullRequest = {
3
3
  body: string;
4
4
  headRefName: string;
5
+ headRefOid: string;
5
6
  baseRefName: string;
6
7
  state: string;
7
8
  };
@@ -1,5 +1,5 @@
1
1
  import { findShepherdJournal } from "./pr-journal.mjs";
2
- const FIELDS = ['body', 'headRefName', 'baseRefName', 'state'];
2
+ const FIELDS = ['body', 'headRefName', 'headRefOid', 'baseRefName', 'state'];
3
3
  /** The live pull request as gh reports it, whatever its state. */
4
4
  export async function readLivePullRequest(gh, pr) {
5
5
  const output = await gh(['pr', 'view', String(pr), '--json', FIELDS.join(',')]);
@@ -1,6 +1,7 @@
1
1
  import { hostname, userInfo } from 'node:os';
2
2
  import { validateGitHubBodyLength } from "../gh-cli/body-length.mjs";
3
3
  import { assertPolicyAllowed } from "./context.mjs";
4
+ import { agentLabel, deriveAgent } from "./pr-agent.mjs";
4
5
  import { renderPullRequestBody, worktreeLabel } from "./pr-body.mjs";
5
6
  import { createGhRunner, ghEnvironment } from "./pr-gh.mjs";
6
7
  import { checkTitleLength, readPullRequestInput } from "./pr-input.mjs";
@@ -41,6 +42,20 @@ async function liveFor(kind, context, pr) {
41
42
  ? livePullRequest(ghIn(context), pr)
42
43
  : readLivePullRequest(ghIn(context), pr);
43
44
  }
45
+ /**
46
+ * The head to validate. An update accepts the worktree branch, or an alias branch whose HEAD is
47
+ * the live head commit, and then uses the live head name; anything else is rejected.
48
+ */
49
+ async function headFor(kind, git, live, pr, worktreeHead) {
50
+ if (worktreeHead === undefined)
51
+ return live.headRefName;
52
+ if (live === undefined || kind !== 'update' || live.headRefName === worktreeHead)
53
+ return worktreeHead;
54
+ const sha = (await git(['rev-parse', 'HEAD'])).trim();
55
+ if (sha !== live.headRefOid)
56
+ throw new Error(`pull request ${pr} has head ${live.headRefName} at ${live.headRefOid}, not the worktree branch ${worktreeHead} at ${sha}`);
57
+ return live.headRefName;
58
+ }
44
59
  /** Renders the body, preserves the live journal for an update, and runs the policy validator. */
45
60
  export async function prepare(args, context, kind, pr) {
46
61
  assertPolicyAllowed(context);
@@ -53,14 +68,15 @@ export async function prepare(args, context, kind, pr) {
53
68
  const live = await liveFor(kind, context, pr);
54
69
  // A read-only validation of an existing pull request judges the head that pull request has.
55
70
  const worktreeHead = live !== undefined && kind === 'validate' ? undefined : await currentBranch(git);
56
- const head = worktreeHead ?? live.headRefName;
71
+ // The local branch is checked before any diagnostic or alias resolution can echo or drop it.
72
+ if (worktreeHead !== undefined && secrets.some((secret) => worktreeHead.includes(secret)))
73
+ throw new Error('the worktree branch name contains a server credential: rename the branch');
74
+ const head = await headFor(kind, git, live, pr, worktreeHead);
57
75
  // Branch names are refs passed to gh as is: redacting one would name a branch that does not exist.
58
76
  if (secrets.some((secret) => head.includes(secret)))
59
77
  throw new Error('the worktree branch name contains a server credential: rename the branch');
60
78
  if (secrets.some((secret) => args.base?.includes(secret)))
61
79
  throw new Error('base contains a server credential: use another base branch');
62
- if (live !== undefined && kind === 'update' && live.headRefName !== head)
63
- throw new Error(`pull request ${pr} has head ${live.headRefName}, not the worktree branch ${head}`);
64
80
  const base = live === undefined
65
81
  ? given.base
66
82
  : liveBase(pr, live, redactCredentials(args.base, secrets));
@@ -70,7 +86,12 @@ export async function prepare(args, context, kind, pr) {
70
86
  throw new Error(`head branch ${head} must differ from base ${base}`);
71
87
  const device = (context.device ?? (() => ({ user: userInfo().username, host: hostname() })))();
72
88
  const statement = await noSourceStatement(policyFor(context), input);
73
- let body = renderPullRequestBody(input, { sessionId: context.sessionId, ...device, worktree: await label(context, git) }, statement);
89
+ let body = renderPullRequestBody(input, {
90
+ sessionId: context.sessionId,
91
+ ...device,
92
+ worktree: await label(context, git),
93
+ agent: agentLabel(deriveAgent(context.env, context.clientName?.()), input.agent, input.model),
94
+ }, statement);
74
95
  if (live !== undefined)
75
96
  body = preserveShepherdJournal(body, live.body);
76
97
  body = redactCredentials(body, secrets);
@@ -15,6 +15,7 @@ const INSTRUCTIONS = 'Journals agent-blackboard feedback for a vouchington consu
15
15
  */
16
16
  export function createMcpServer(sdk, environment, version) {
17
17
  const mcp = new sdk.McpServer({ name: SERVER_NAME, version }, { instructions: INSTRUCTIONS });
18
+ environment.clientName ??= () => mcp.server.getClientVersion()?.name;
18
19
  mcp.server.registerCapabilities({ tools: {} });
19
20
  mcp.server.setRequestHandler(sdk.ListToolsRequestSchema, async () => ({ tools: [...TOOLS] }));
20
21
  mcp.server.setRequestHandler(sdk.CallToolRequestSchema, async (request) => callTool(request.params.name, request.params.arguments, environment));
@@ -41,8 +41,12 @@ const FIELDS = {
41
41
  description: 'Markdown paragraphs or bullets, joined by blank lines.',
42
42
  },
43
43
  base: { type: 'string', minLength: 1, description: 'Base branch. Default: main.' },
44
- agent: text('Agent name for the Agent line.'),
45
- model: text('Optional model name, rendered in parentheses after the agent.'),
44
+ agent: text('Optional, and never trusted. The server derives the Agent line from its own launch ' +
45
+ 'environment (harness markers) and the MCP client name; a value that does not name the ' +
46
+ 'derived agent is rendered as "caller-claimed", and with nothing derivable the agent is ' +
47
+ '"unknown".'),
48
+ model: text('Optional, and never trusted. The server cannot derive the model, so it is rendered as ' +
49
+ '"caller-claimed model: <model>" in parentheses after the agent.'),
46
50
  mode: {
47
51
  enum: ['interactive', 'autonomous'],
48
52
  description: 'Optional. The server derives the mode from its own launch environment and fails closed: ' +
@@ -51,15 +55,7 @@ const FIELDS = {
51
55
  },
52
56
  workspaceSetup: text('Text for the Workspace setup line, such as how the worktree was prepared.'),
53
57
  };
54
- const REQUIRED = [
55
- 'title',
56
- 'summary',
57
- 'impact',
58
- 'relatedIssues',
59
- 'details',
60
- 'agent',
61
- 'workspaceSetup',
62
- ];
58
+ const REQUIRED = ['title', 'summary', 'impact', 'relatedIssues', 'details', 'workspaceSetup'];
63
59
  const RENDERING = 'The server renders the body from the structured input (Summary, Impact, Related issues, ' +
64
60
  'Details, then Workspace setup, Agent, Device and Worktree lines) and runs the checkout ' +
65
61
  'policy validatePullRequest export in a child process; the head branch is the ' +
@@ -79,7 +75,7 @@ export const PR_TOOLS = [
79
75
  "repository's stack tooling. Returns { number, url, warnings }.", openWorld(ADDITIVE), FIELDS, REQUIRED),
80
76
  tool('pr_update', "Replaces an open pull request's title and body. " +
81
77
  RENDERING +
82
- "The pull request's head branch must be the worktree branch. The live Shepherd Journal is " +
78
+ "The pull request's head must be the worktree branch, or the worktree HEAD must be the pull request's head commit (an alias branch); the live head name is then used. The live Shepherd Journal is " +
83
79
  'preserved and nothing else is carried over from the live body. Validation errors block ' +
84
80
  'the update. Returns { number, url, warnings }.', openWorld(MUTATING), { ...FIELDS, pr: PR_NUMBER }, [...REQUIRED, 'pr']),
85
81
  ];
@@ -131,6 +131,7 @@ function removeStaleLock(lockPath) {
131
131
  return true;
132
132
  }
133
133
  catch {
134
+ /* v8 ignore next -- requires an unexpected filesystem failure during stale-lock recovery. */
134
135
  return false;
135
136
  }
136
137
  });
@@ -174,7 +175,7 @@ export function withFileLock(path, action) {
174
175
  throw statusError;
175
176
  }
176
177
  if (!lockStatus.isFile())
177
- throw new Error('session-friction log lock must be a regular file');
178
+ throw new Error('log lock must be a regular file');
178
179
  if (removeStaleLock(lockPath))
179
180
  continue;
180
181
  waitForLock();
@@ -189,5 +190,5 @@ export function withFileLock(path, action) {
189
190
  unlinkSync(lockPath);
190
191
  }
191
192
  }
192
- throw new Error(`could not acquire session-friction log lock for ${basename(path)}`);
193
+ throw new Error(`could not acquire file lock for ${basename(path)}`);
193
194
  }
@@ -0,0 +1,3 @@
1
+ export declare function openLogFile(path: string, flags: number): number;
2
+ export declare function readLogContent(descriptor: number, maximumBytes: number): string;
3
+ export declare function writeAll(descriptor: number, value: string): void;
@@ -0,0 +1,53 @@
1
+ import { closeSync, constants, fstatSync, openSync, readSync, writeSync } from 'node:fs';
2
+ import { decodeUtf8 } from "./utf8.mjs";
3
+ export function openLogFile(path, flags) {
4
+ let descriptor;
5
+ try {
6
+ descriptor = openSync(path, flags | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
7
+ }
8
+ catch (error) {
9
+ if (error &&
10
+ typeof error === 'object' &&
11
+ 'code' in error &&
12
+ (error.code === 'ELOOP' || error.code === 'EISDIR' || error.code === 'ENOTDIR'))
13
+ throw new Error('log file must be a regular file');
14
+ throw error;
15
+ }
16
+ try {
17
+ const status = fstatSync(descriptor);
18
+ if (!status.isFile() || status.nlink !== 1)
19
+ throw new Error('log file must be a regular file');
20
+ return descriptor;
21
+ }
22
+ catch (error) {
23
+ closeSync(descriptor);
24
+ throw error;
25
+ }
26
+ }
27
+ export function readLogContent(descriptor, maximumBytes) {
28
+ if (fstatSync(descriptor).size > maximumBytes)
29
+ throw new Error('log file is too large');
30
+ const buffer = Buffer.alloc(maximumBytes + 1);
31
+ let length = 0;
32
+ while (length < buffer.length) {
33
+ const bytes = readSync(descriptor, buffer, length, buffer.length - length, length);
34
+ if (bytes === 0)
35
+ break;
36
+ length += bytes;
37
+ }
38
+ /* v8 ignore next -- detects external growth after the descriptor size check. */
39
+ if (length > maximumBytes)
40
+ throw new Error('log file is too large');
41
+ return decodeUtf8(buffer.subarray(0, length));
42
+ }
43
+ export function writeAll(descriptor, value) {
44
+ const buffer = Buffer.from(value);
45
+ let offset = 0;
46
+ while (offset < buffer.length) {
47
+ const written = writeSync(descriptor, buffer, offset, buffer.length - offset);
48
+ /* v8 ignore next -- a zero-byte synchronous file write is an external I/O failure. */
49
+ if (written === 0)
50
+ throw new Error('log file write made no progress');
51
+ offset += written;
52
+ }
53
+ }
@@ -3,6 +3,6 @@ export function decodeUtf8(value) {
3
3
  return new TextDecoder('utf-8', { fatal: true }).decode(value);
4
4
  }
5
5
  catch {
6
- throw new Error('session-friction log is not valid UTF-8');
6
+ throw new Error('log file is not valid UTF-8');
7
7
  }
8
8
  }
@@ -1,6 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { ghSpawnEnv } from "../gh-cli/env.mjs";
2
3
  export const shell = (command, args) => new Promise((resolve) => {
3
- const child = spawn(command, args);
4
+ const child = command === 'gh' ? spawn(command, args, { env: ghSpawnEnv() }) : spawn(command, args);
4
5
  let stdout = '';
5
6
  let stderr = '';
6
7
  child.stdout.setEncoding('utf8');
@@ -287,7 +287,7 @@ retrospective tools. All three take `sessionId` (required) and `worktree` (optio
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
289
  (`[{ number, relation: "closes" | "refs", reason }]`, may be empty), optional `noSourceIssue`
290
- (`{ reason }`, see below), `details` (string array), `base` (default `main`), `agent`, optional
290
+ (`{ reason }`, see below), `details` (string array), `base` (default `main`), optional `agent`, optional
291
291
  `model`, optional `mode` (`interactive` | `autonomous`) and `workspaceSetup`. `pr_update` also
292
292
  requires `pr`; `pr_validate` accepts an optional `pr`.
293
293
 
@@ -305,6 +305,18 @@ argument can only narrow `interactive` to `autonomous` (it never widens), so an
305
305
  cannot unlock an interactive-only policy exception. The policy child receives no env markers, so it
306
306
  relies on this value.
307
307
 
308
+ ### Agent provenance
309
+
310
+ The `Agent:` line is derived by the server, never taken from the caller. The agent is the harness
311
+ that launched the server: `CODEX_THREAD_ID` (codex), `CLAUDECODE=1` or `CLAUDE_CODE_SESSION_ID`
312
+ (claude), `CURSOR_AGENT` or `CURSOR_SESSION_ID` (cursor), `GROK_AGENT`, `GROK_HOOK_EVENT` or
313
+ `GROK_SESSION_ID` (grok), in that order; failing those, the MCP client's `initialize`
314
+ `clientInfo.name` when it contains one of those names. The server has no source for the model.
315
+ `agent` is optional: a value naming the derived agent is ignored, any other value is rendered as
316
+ `(caller-claimed agent: <value>)`, and `model` as `(caller-claimed model: <value>)`. When no agent
317
+ is derivable the line says `unknown`. Making `agent` optional is compatible with existing callers,
318
+ which may keep sending it.
319
+
308
320
  ### Pull requests with no source issue
309
321
 
310
322
  Pass `noSourceIssue: { reason }` (a single line) with an empty `relatedIssues`; a non-empty
@@ -335,7 +347,7 @@ rule) and `pullRequestDefaults`.
335
347
  <details joined by blank lines>
336
348
 
337
349
  Workspace setup: <workspaceSetup>
338
- Agent: <agent>[ (<model>)] session <sessionId>
350
+ Agent: <derived agent | unknown>[ (caller-claimed ...)] session <sessionId>
339
351
  Device: <os user>@<lowercased hostname>
340
352
  Worktree: <path after "/worktrees/", "main" for the main worktree, else the directory name>
341
353
  ```
@@ -357,8 +369,10 @@ with the repository's stack tooling instead.
357
369
  and `project`. It checks the head is pushed and current, writes the body to a mode 0600 temp file
358
370
  (removed afterwards) and runs `gh pr create --draft` with the labels, `--milestone` and
359
371
  `--project`. Returns `{ number, url, warnings }`.
360
- - `pr_update` (mutating): reads `gh pr view <pr> --json body,headRefName,baseRefName,state`,
361
- rejects a pull request that is not `OPEN` or whose head is not the worktree branch, and validates
372
+ - `pr_update` (mutating): reads `gh pr view <pr> --json body,headRefName,headRefOid,baseRefName,state`,
373
+ rejects a pull request that is not `OPEN` or whose head is neither the worktree branch nor the
374
+ worktree `HEAD` commit (an alias branch at the live head SHA is accepted and the live head name is
375
+ used), and validates
362
376
  against the live base (an explicit `base` must equal it). It renders, appends the live Shepherd
363
377
  Journal (a `<details><summary>Shepherd Journal</summary>` block, or a legacy `## Shepherd Journal`
364
378
  section to the next H2 or the end; examples in fenced code blocks are ignored), validates with
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vouchington-tooling",
3
- "version": "0.45.0",
3
+ "version": "0.46.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": {
@@ -71,11 +71,6 @@
71
71
  "default": "./dist/agent-blackboard/index.mjs"
72
72
  },
73
73
  "./mcp-server/bin": "./bin/vouchington-mcp.mjs",
74
- "./session-friction": {
75
- "types": "./dist/session-friction/index.d.mts",
76
- "import": "./dist/session-friction/index.mjs",
77
- "default": "./dist/session-friction/index.mjs"
78
- },
79
74
  "./sql-ast": {
80
75
  "types": "./dist/sql-ast/index.d.mts",
81
76
  "import": "./dist/sql-ast/index.mjs",
@@ -1,13 +0,0 @@
1
- import type { FrictionLogReadResult, JournalEntry } from './types.mts';
2
- import { type AuditRedactor } from './text.mts';
3
- export declare function isConformingCiFailureBlock(markdown: string): boolean;
4
- export declare function getConformingGroups(entries: Iterable<JournalEntry>, redact?: AuditRedactor, maxBytes?: number): string[];
5
- export declare function incompleteCiSection(markdownBlocks: string[]): string;
6
- export declare function buildCiFailuresSection(sessionId: string, journal: {
7
- status: 'ok';
8
- markdownBlocks: string[];
9
- truncated: boolean;
10
- } | {
11
- status: 'unreachable';
12
- diagnostic: string;
13
- }, frictionStatus: FrictionLogReadResult['status'] | 'journal-only'): string;
@@ -1,2 +0,0 @@
1
- import type { FrictionEvent, FrictionObservation } from './types.mts';
2
- export declare function classifyFrictionObservation(observation: FrictionObservation): Omit<FrictionEvent, 'timestamp'> | null;