vouchington-tooling 0.24.1 → 0.26.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 (65) hide show
  1. package/README.md +43 -0
  2. package/dist/agent-blackboard/client.d.mts +5 -0
  3. package/dist/agent-blackboard/client.mjs +1 -1
  4. package/dist/agent-blackboard/feedback-audit-source.d.mts +13 -0
  5. package/dist/agent-blackboard/feedback-audit-source.mjs +30 -0
  6. package/dist/agent-blackboard/feedback-compose.d.mts +5 -1
  7. package/dist/agent-blackboard/feedback-compose.mjs +8 -18
  8. package/dist/agent-blackboard/feedback-journal-audit.d.mts +20 -0
  9. package/dist/agent-blackboard/feedback-journal-audit.mjs +29 -0
  10. package/dist/agent-blackboard/feedback-journal-sandbox.d.mts +5 -0
  11. package/dist/agent-blackboard/feedback-journal-sandbox.mjs +40 -0
  12. package/dist/agent-blackboard/index.d.mts +9 -8
  13. package/dist/agent-blackboard/index.mjs +7 -34
  14. package/dist/agent-blackboard/journal.d.mts +21 -0
  15. package/dist/agent-blackboard/journal.mjs +45 -0
  16. package/dist/agent-blackboard/session-tools.d.mts +36 -0
  17. package/dist/agent-blackboard/session-tools.mjs +41 -0
  18. package/dist/cli/commands/mcp.d.mts +5 -0
  19. package/dist/cli/commands/mcp.mjs +18 -0
  20. package/dist/cli/index.mjs +5 -5
  21. package/dist/cli/installed-version.d.mts +1 -0
  22. package/dist/cli/installed-version.mjs +5 -0
  23. package/dist/cli/parse-forwarded-commands.d.mts +3 -0
  24. package/dist/cli/parse-forwarded-commands.mjs +2 -0
  25. package/dist/cli/parse.d.mts +3 -0
  26. package/dist/cli/usage.d.mts +1 -1
  27. package/dist/cli/usage.mjs +6 -0
  28. package/dist/mcp-server/args.d.mts +9 -0
  29. package/dist/mcp-server/args.mjs +48 -0
  30. package/dist/mcp-server/context.d.mts +19 -0
  31. package/dist/mcp-server/context.mjs +23 -0
  32. package/dist/mcp-server/dispatch.d.mts +18 -0
  33. package/dist/mcp-server/dispatch.mjs +62 -0
  34. package/dist/mcp-server/fake-blackboard.test-helpers.d.mts +39 -0
  35. package/dist/mcp-server/fake-blackboard.test-helpers.mjs +75 -0
  36. package/dist/mcp-server/git-fixture.test-helpers.d.mts +16 -0
  37. package/dist/mcp-server/git-fixture.test-helpers.mjs +35 -0
  38. package/dist/mcp-server/handlers-journal.d.mts +6 -0
  39. package/dist/mcp-server/handlers-journal.mjs +98 -0
  40. package/dist/mcp-server/handlers-session.d.mts +4 -0
  41. package/dist/mcp-server/handlers-session.mjs +47 -0
  42. package/dist/mcp-server/harness.test-helpers.d.mts +33 -0
  43. package/dist/mcp-server/harness.test-helpers.mjs +48 -0
  44. package/dist/mcp-server/run.d.mts +20 -0
  45. package/dist/mcp-server/run.mjs +21 -0
  46. package/dist/mcp-server/sdk.d.mts +17 -0
  47. package/dist/mcp-server/sdk.mjs +33 -0
  48. package/dist/mcp-server/server.d.mts +9 -0
  49. package/dist/mcp-server/server.mjs +17 -0
  50. package/dist/mcp-server/tools.d.mts +19 -0
  51. package/dist/mcp-server/tools.mjs +120 -0
  52. package/dist/mcp-server/worktree.d.mts +18 -0
  53. package/dist/mcp-server/worktree.mjs +58 -0
  54. package/dist/session-friction/audit-block.d.mts +4 -0
  55. package/dist/session-friction/audit-block.mjs +45 -0
  56. package/dist/session-friction/ci-failures.d.mts +2 -1
  57. package/dist/session-friction/ci-failures.mjs +10 -46
  58. package/dist/session-friction/journal.d.mts +14 -0
  59. package/dist/session-friction/journal.mjs +66 -0
  60. package/dist/session-friction/report.mjs +10 -74
  61. package/docs/mcp-server.md +134 -0
  62. package/package.json +7 -2
  63. package/skills/blackboard/SKILL.md +59 -22
  64. package/skills/manifest.json +19 -19
  65. package/skills/retrospective-distill/SKILL.md +37 -13
@@ -0,0 +1,48 @@
1
+ import { join } from 'node:path';
2
+ import { afterEach, beforeEach } from 'vitest';
3
+ import { callTool } from './dispatch.mjs';
4
+ import { BLACKBOARD_ENV, fakeBlackboard, } from './fake-blackboard.test-helpers.mjs';
5
+ import { createRepoFixture } from './git-fixture.test-helpers.mjs';
6
+ import { runIsolatedGit } from './worktree.mjs';
7
+ /** A fresh pair of real repositories for every test. */
8
+ export function useRepoFixture() {
9
+ let fixture;
10
+ beforeEach(() => {
11
+ fixture = createRepoFixture();
12
+ });
13
+ afterEach(() => fixture?.cleanup());
14
+ return () => fixture;
15
+ }
16
+ export function outboxPath(worktree) {
17
+ return join(worktree, '.local', 'blackboard-outbox');
18
+ }
19
+ /** Calls tools against a repository fixture with the blackboard client faked. */
20
+ export function harness(fixture, options = {}) {
21
+ const fake = fakeBlackboard(options);
22
+ const environment = {
23
+ launchRoot: fixture.main,
24
+ env: options.env ?? BLACKBOARD_ENV,
25
+ runGit: runIsolatedGit,
26
+ blackboard: fake.dependencies,
27
+ };
28
+ return { fake, call: (name, args) => callTool(name, args, environment) };
29
+ }
30
+ export function textOf(result) {
31
+ return result.content.map((part) => part.text).join('\n');
32
+ }
33
+ export function jsonOf(result) {
34
+ return JSON.parse(textOf(result));
35
+ }
36
+ export const JOURNAL_ARGS = {
37
+ sessionId: 'native:owner',
38
+ parentSessionId: null,
39
+ agent: 'codex',
40
+ version: '1',
41
+ mode: 'autonomous',
42
+ markdown: '## Finding\nThe build cache was cold.',
43
+ sourceEventId: 'journal:1',
44
+ workOutcome: 'success',
45
+ repositories: ['owner/repo'],
46
+ feedbackCoverage: { status: 'complete', sources: ['tool-result'], droppedCount: 0 },
47
+ timestamp: '2026-01-01T00:00:00.000Z',
48
+ };
@@ -0,0 +1,20 @@
1
+ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
2
+ import type { BlackboardClientDependencies } from '../agent-blackboard/client.mts';
3
+ import type { RunTextCommand } from '../gh-cli/exec.mts';
4
+ import { type SdkImporter } from './sdk.mts';
5
+ export type RunMcpServerOptions = {
6
+ cwd: string;
7
+ env: NodeJS.ProcessEnv;
8
+ version: string;
9
+ importSdk?: SdkImporter;
10
+ runGit?: RunTextCommand;
11
+ /** Defaults to stdio. Tests pass one half of an in-memory pair. */
12
+ transport?: Transport;
13
+ /** Test seam: replaces how the consumer's `agent-blackboard` client is loaded. */
14
+ blackboard?: BlackboardClientDependencies;
15
+ };
16
+ /**
17
+ * Starts the server and resolves once it is connected. Nothing on this path may write to stdout:
18
+ * stdout is the protocol channel, so diagnostics go to stderr.
19
+ */
20
+ export declare function runMcpServer(options: RunMcpServerOptions): Promise<void>;
@@ -0,0 +1,21 @@
1
+ import { loadMcpSdk } from './sdk.mjs';
2
+ import { createMcpServer } from './server.mjs';
3
+ import { launchWorktreeRoot, runIsolatedGit } from './worktree.mjs';
4
+ /**
5
+ * Starts the server and resolves once it is connected. Nothing on this path may write to stdout:
6
+ * stdout is the protocol channel, so diagnostics go to stderr.
7
+ */
8
+ export async function runMcpServer(options) {
9
+ const runGit = options.runGit ?? runIsolatedGit;
10
+ const sdk = await loadMcpSdk(options.importSdk);
11
+ const launchRoot = await launchWorktreeRoot(options.cwd, runGit);
12
+ const environment = {
13
+ launchRoot,
14
+ env: options.env,
15
+ runGit,
16
+ ...(options.blackboard === undefined ? {} : { blackboard: options.blackboard }),
17
+ };
18
+ const server = createMcpServer(sdk, environment, options.version);
19
+ /* v8 ignore next -- the stdio default is exercised by the spawned-process test */
20
+ await server.connect(options.transport ?? new sdk.StdioServerTransport());
21
+ }
@@ -0,0 +1,17 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import type { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
4
+ /** The slice of `@modelcontextprotocol/sdk` this server uses, loaded only when `mcp` runs. */
5
+ export type McpSdk = {
6
+ McpServer: typeof McpServer;
7
+ StdioServerTransport: typeof StdioServerTransport;
8
+ CallToolRequestSchema: typeof CallToolRequestSchema;
9
+ ListToolsRequestSchema: typeof ListToolsRequestSchema;
10
+ };
11
+ export type SdkImporter = (specifier: string) => Promise<unknown>;
12
+ export declare const MISSING_SDK_MESSAGE: string;
13
+ /**
14
+ * Imports the SDK lazily. A static import would make every `vouchington` command fail on a
15
+ * consumer that never installed the optional peer.
16
+ */
17
+ export declare function loadMcpSdk(importSdk?: SdkImporter): Promise<McpSdk>;
@@ -0,0 +1,33 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import { isMissingModuleError } from '../agent-blackboard/client.mjs';
10
+ const SDK_PACKAGE = '@modelcontextprotocol/sdk';
11
+ export const MISSING_SDK_MESSAGE = `vouchington mcp needs ${SDK_PACKAGE} (and its zod peer), which is an optional peer ` +
12
+ `dependency of vouchington-tooling and is not installed. Install it next to ` +
13
+ `vouchington-tooling: pnpm add -D ${SDK_PACKAGE} zod`;
14
+ const defaultImporter = (specifier) => import(__rewriteRelativeImportExtension(specifier));
15
+ /**
16
+ * Imports the SDK lazily. A static import would make every `vouchington` command fail on a
17
+ * consumer that never installed the optional peer.
18
+ */
19
+ export async function loadMcpSdk(importSdk = defaultImporter) {
20
+ try {
21
+ const [server, stdio, types] = await Promise.all([
22
+ importSdk(`${SDK_PACKAGE}/server/mcp.js`),
23
+ importSdk(`${SDK_PACKAGE}/server/stdio.js`),
24
+ importSdk(`${SDK_PACKAGE}/types.js`),
25
+ ]);
26
+ return { ...server, ...stdio, ...types };
27
+ }
28
+ catch (error) {
29
+ if (isMissingModuleError(error))
30
+ throw new Error(MISSING_SDK_MESSAGE, { cause: error });
31
+ throw error;
32
+ }
33
+ }
@@ -0,0 +1,9 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { type ServerEnvironment } from './dispatch.mts';
3
+ import type { McpSdk } from './sdk.mts';
4
+ export declare const SERVER_NAME = "vouchington-tooling";
5
+ /**
6
+ * Builds the server. The tool list and call handlers go on the low-level `server` because the
7
+ * high-level `registerTool` path needs zod schemas, and this package keeps zod out of its own code.
8
+ */
9
+ export declare function createMcpServer(sdk: McpSdk, environment: ServerEnvironment, version: string): McpServer;
@@ -0,0 +1,17 @@
1
+ import { callTool } from './dispatch.mjs';
2
+ import { TOOLS } from './tools.mjs';
3
+ export const SERVER_NAME = 'vouchington-tooling';
4
+ const INSTRUCTIONS = 'Journals agent-blackboard feedback for a vouchington consumer. Every tool takes an explicit ' +
5
+ 'sessionId and an optional worktree (an absolute path listed by `git worktree list` for the ' +
6
+ 'repository this server was launched from). Tool errors are actionable; do not fall back to a CLI.';
7
+ /**
8
+ * Builds the server. The tool list and call handlers go on the low-level `server` because the
9
+ * high-level `registerTool` path needs zod schemas, and this package keeps zod out of its own code.
10
+ */
11
+ export function createMcpServer(sdk, environment, version) {
12
+ const mcp = new sdk.McpServer({ name: SERVER_NAME, version }, { instructions: INSTRUCTIONS });
13
+ mcp.server.registerCapabilities({ tools: {} });
14
+ mcp.server.setRequestHandler(sdk.ListToolsRequestSchema, async () => ({ tools: [...TOOLS] }));
15
+ mcp.server.setRequestHandler(sdk.CallToolRequestSchema, async (request) => callTool(request.params.name, request.params.arguments, environment));
16
+ return mcp;
17
+ }
@@ -0,0 +1,19 @@
1
+ type JsonSchema = Record<string, unknown>;
2
+ export type ToolDefinition = {
3
+ name: string;
4
+ description: string;
5
+ inputSchema: {
6
+ type: 'object';
7
+ properties: Record<string, JsonSchema>;
8
+ required: string[];
9
+ additionalProperties: false;
10
+ };
11
+ annotations: {
12
+ readOnlyHint: boolean;
13
+ destructiveHint: boolean;
14
+ idempotentHint: boolean;
15
+ openWorldHint: boolean;
16
+ };
17
+ };
18
+ export declare const TOOLS: readonly ToolDefinition[];
19
+ export {};
@@ -0,0 +1,120 @@
1
+ const ID_PATTERN = '^[A-Za-z0-9._:-]+$';
2
+ const SESSION_ID = {
3
+ type: 'string',
4
+ pattern: ID_PATTERN,
5
+ maxLength: 256,
6
+ description: 'Explicit session id (URL-safe). Never inferred by the server.',
7
+ };
8
+ const WORKTREE = {
9
+ type: 'string',
10
+ description: 'Absolute path of a worktree listed by `git worktree list` for the repository the server ' +
11
+ 'was launched from. Defaults to the launch worktree.',
12
+ };
13
+ const PARENT_SESSION_ID = {
14
+ type: ['string', 'null'],
15
+ pattern: ID_PATTERN,
16
+ maxLength: 256,
17
+ description: 'Direct parent session id, or null for a root session.',
18
+ };
19
+ const AGENT = { type: 'string', maxLength: 128, description: 'Agent name.' };
20
+ const VERSION = { type: 'string', maxLength: 128, description: 'Agent version.' };
21
+ const OUTCOMES = [
22
+ 'in-progress',
23
+ 'success',
24
+ 'failure',
25
+ 'cancelled',
26
+ 'timed-out',
27
+ 'no-change',
28
+ 'policy-refusal',
29
+ 'unknown',
30
+ ];
31
+ const COVERAGE_STATUSES = ['complete', 'partial', 'unavailable', 'not-assessed', 'not-started'];
32
+ const READ_ONLY = flags(true, false, true);
33
+ const ADDITIVE = flags(false, false, false);
34
+ const IDEMPOTENT_ADDITIVE = flags(false, false, true);
35
+ const MUTATING = flags(false, true, true);
36
+ function flags(readOnly, destructive, idempotent) {
37
+ return {
38
+ readOnlyHint: readOnly,
39
+ destructiveHint: destructive,
40
+ idempotentHint: idempotent,
41
+ openWorldHint: false,
42
+ };
43
+ }
44
+ function tool(name, description, annotations, properties = {}, required = []) {
45
+ return {
46
+ name,
47
+ description,
48
+ inputSchema: {
49
+ type: 'object',
50
+ properties: { sessionId: SESSION_ID, worktree: WORKTREE, ...properties },
51
+ required: ['sessionId', ...required],
52
+ additionalProperties: false,
53
+ },
54
+ annotations,
55
+ };
56
+ }
57
+ const IDENTITY = { parentSessionId: PARENT_SESSION_ID, agent: AGENT, version: VERSION };
58
+ const IDENTITY_REQUIRED = ['parentSessionId', 'agent', 'version'];
59
+ export const TOOLS = [
60
+ tool('journal_append', 'Appends one journal entry to the caller session and returns the verified read-back receipt. ' +
61
+ 'Nothing is written unless every required field validates. Interactive mode retains the ' +
62
+ 'envelope in the worktree outbox when the blackboard is unreachable.', ADDITIVE, {
63
+ ...IDENTITY,
64
+ mode: { enum: ['interactive', 'autonomous'], description: 'Delivery mode.' },
65
+ markdown: { type: 'string', description: 'Journal note as markdown text (max 12000 bytes).' },
66
+ sourceEventId: { type: 'string', pattern: ID_PATTERN, description: 'Stable event id.' },
67
+ workOutcome: { enum: OUTCOMES, description: 'Outcome of the work being reported.' },
68
+ repositories: {
69
+ type: 'array',
70
+ items: { type: 'string' },
71
+ minItems: 1,
72
+ description: 'Exact owner/name repositories this entry concerns.',
73
+ },
74
+ feedbackCoverage: {
75
+ type: 'object',
76
+ properties: {
77
+ status: { enum: COVERAGE_STATUSES },
78
+ sources: { type: 'array', items: { type: 'string' } },
79
+ droppedCount: { type: 'integer', minimum: 0 },
80
+ },
81
+ required: ['status'],
82
+ additionalProperties: false,
83
+ description: 'Explicit coverage; sources default to [] and droppedCount to 0.',
84
+ },
85
+ timestamp: {
86
+ type: 'string',
87
+ description: 'Optional ISO 8601 time; defaults to now and is returned. Reuse the same sourceEventId ' +
88
+ 'and timestamp when retrying, because the whole envelope must match.',
89
+ },
90
+ category: { type: 'string', description: 'Optional context; never changes entry type.' },
91
+ }, [
92
+ ...IDENTITY_REQUIRED,
93
+ 'mode',
94
+ 'markdown',
95
+ 'sourceEventId',
96
+ 'workOutcome',
97
+ 'repositories',
98
+ 'feedbackCoverage',
99
+ ]),
100
+ tool('journal_entries', 'Returns every entry of one session, oldest first, as JSON: { sessionId, entries }. Each ' +
101
+ 'entry is exactly as the blackboard returns it ({ sessionId, createdAt, data }), including ' +
102
+ 'non-journal types such as retrospective and legacy entries without a feedback envelope.', READ_ONLY),
103
+ tool('outbox_status', 'Reports how many unsent journal records the worktree outbox retains. sessionId identifies ' +
104
+ 'the caller; the count covers the whole outbox.', READ_ONLY),
105
+ tool('outbox_flush', 'Delivers retained outbox records and returns the remaining count. sessionId identifies the ' +
106
+ 'caller; the flush covers every record in the worktree outbox.', IDEMPOTENT_ADDITIVE),
107
+ tool('session_ensure', 'Idempotently creates a session, or verifies that an existing one has the same identity. ' +
108
+ 'Returns the session id and whether it was created.', IDEMPOTENT_ADDITIVE, IDENTITY, IDENTITY_REQUIRED),
109
+ tool('snapshot_export', 'Exports non-archived sessions into a private local JSONL file chosen by the blackboard ' +
110
+ 'client. Returns the path, counts, checksum, manifest, and cleanupToken; never the records. ' +
111
+ 'sessionId identifies the caller.', ADDITIVE, {
112
+ agent: AGENT,
113
+ version: VERSION,
114
+ parentSessionId: PARENT_SESSION_ID,
115
+ data: { type: 'object', description: 'Top-level session data fields to match exactly.' },
116
+ dataArrayContains: { type: 'object', description: 'Exact string membership in arrays.' },
117
+ inactiveForHours: { type: 'number', exclusiveMinimum: 0 },
118
+ }),
119
+ tool('session_archive', 'Marks a session as distilled. Its metadata becomes immutable; entries stay appendable.', MUTATING),
120
+ ];
@@ -0,0 +1,18 @@
1
+ import { type RunTextCommand } from '../gh-cli/exec.mts';
2
+ /** Runs git without inherited `GIT_*` variables so `GIT_DIR` cannot redirect the check. */
3
+ export declare const runIsolatedGit: RunTextCommand;
4
+ /** The worktree root containing `cwd`, or a message that says how to fix the launch directory. */
5
+ export declare function launchWorktreeRoot(cwd: string, runGit: RunTextCommand): Promise<string>;
6
+ /** Paths of the non-bare entries in `git worktree list --porcelain` output. */
7
+ export declare function parseWorktreeList(porcelain: string): string[];
8
+ /**
9
+ * Resolves the worktree a tool call may act on. The list is read from git on every call, so
10
+ * worktrees created after the server started are accepted and removed ones are not. The
11
+ * comparison is exact on real paths: a subdirectory of a worktree, or an unrelated repository,
12
+ * is rejected.
13
+ */
14
+ export declare function resolveWorktree(input: {
15
+ launchRoot: string;
16
+ requested?: string | undefined;
17
+ runGit: RunTextCommand;
18
+ }): Promise<string>;
@@ -0,0 +1,58 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { realpath } from 'node:fs/promises';
3
+ import { isAbsolute } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import { createCommandRunner } from '../gh-cli/exec.mjs';
6
+ import { gitEnv } from '../shared-context/index.mjs';
7
+ const execFileAsync = promisify(execFile);
8
+ /** Runs git without inherited `GIT_*` variables so `GIT_DIR` cannot redirect the check. */
9
+ export const runIsolatedGit = createCommandRunner('git', (command, args) => execFileAsync(command, args, { env: gitEnv() }));
10
+ async function canonical(path) {
11
+ try {
12
+ return await realpath(path);
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ }
18
+ /** The worktree root containing `cwd`, or a message that says how to fix the launch directory. */
19
+ export async function launchWorktreeRoot(cwd, runGit) {
20
+ try {
21
+ return await realpath((await runGit(['-C', cwd, 'rev-parse', '--show-toplevel'])).trim());
22
+ }
23
+ catch (error) {
24
+ throw new Error(`vouchington mcp must be launched inside a git worktree; ${cwd} is not one (${errorText(error)})`);
25
+ }
26
+ }
27
+ /** Paths of the non-bare entries in `git worktree list --porcelain` output. */
28
+ export function parseWorktreeList(porcelain) {
29
+ const paths = [];
30
+ for (const block of porcelain.split(/\r?\n\r?\n/)) {
31
+ const lines = block.split(/\r?\n/);
32
+ const first = lines.find((line) => line.startsWith('worktree '));
33
+ if (first !== undefined && !lines.includes('bare'))
34
+ paths.push(first.slice('worktree '.length));
35
+ }
36
+ return paths;
37
+ }
38
+ /**
39
+ * Resolves the worktree a tool call may act on. The list is read from git on every call, so
40
+ * worktrees created after the server started are accepted and removed ones are not. The
41
+ * comparison is exact on real paths: a subdirectory of a worktree, or an unrelated repository,
42
+ * is rejected.
43
+ */
44
+ export async function resolveWorktree(input) {
45
+ const { launchRoot, requested, runGit } = input;
46
+ if (requested !== undefined && !isAbsolute(requested))
47
+ throw new Error('worktree must be an absolute path to a git worktree of the launch repository');
48
+ const target = requested === undefined ? launchRoot : await canonical(requested);
49
+ const listing = await runGit(['-C', launchRoot, 'worktree', 'list', '--porcelain']);
50
+ const known = (await Promise.all(parseWorktreeList(listing).map(canonical))).filter((path) => path !== undefined);
51
+ if (target !== undefined && known.includes(target))
52
+ return target;
53
+ throw new Error(`worktree ${requested ?? launchRoot} is not a worktree of the repository this server was ` +
54
+ `launched from (${launchRoot}). Registered worktrees: ${known.join(', ')}`);
55
+ }
56
+ function errorText(error) {
57
+ return error instanceof Error ? error.message : String(error);
58
+ }
@@ -0,0 +1,4 @@
1
+ import type { JournalEntry } from './types.mts';
2
+ /** Returns the paste-safe block, or null when the markdown is not exactly one conforming block. */
3
+ export declare function matchAuditBlock(markdown: string, patterns: readonly RegExp[], prefixes: readonly string[]): string | null;
4
+ export declare function conformingBlocks(entries: Iterable<JournalEntry>, match: (markdown: string) => string | null): string[];
@@ -0,0 +1,45 @@
1
+ import { isWellFormedUnicode, markdownAuditText } from './text.mjs';
2
+ const BLANK = /^\s*$/;
3
+ function sanitizeField(line, prefixes) {
4
+ const prefix = prefixes.find((value) => line.startsWith(value));
5
+ /* v8 ignore next -- callers only pass lines that matched a pattern with a known prefix. */
6
+ if (!prefix)
7
+ return markdownAuditText(line) || null;
8
+ const content = markdownAuditText(line.slice(prefix.length));
9
+ return content ? `${prefix}${content}` : null;
10
+ }
11
+ // Consumes exactly one line per pattern (skipping blank lines between them) and nothing else.
12
+ function matchLines(markdown, patterns) {
13
+ if (!isWellFormedUnicode(markdown))
14
+ return null;
15
+ const lines = markdown.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
16
+ let index = 0;
17
+ const fields = [];
18
+ for (const pattern of patterns) {
19
+ while (index < lines.length && BLANK.test(lines[index]))
20
+ index++;
21
+ const line = lines[index];
22
+ if (line === undefined || !pattern.test(line))
23
+ return null;
24
+ fields.push(line);
25
+ index++;
26
+ }
27
+ return lines.slice(index).every((line) => BLANK.test(line)) ? fields : null;
28
+ }
29
+ /** Returns the paste-safe block, or null when the markdown is not exactly one conforming block. */
30
+ export function matchAuditBlock(markdown, patterns, prefixes) {
31
+ const fields = matchLines(markdown, patterns);
32
+ if (!fields)
33
+ return null;
34
+ const safeFields = fields.map((field) => sanitizeField(field, prefixes));
35
+ return safeFields.some((field) => field === null) ? null : safeFields.join('\n');
36
+ }
37
+ export function conformingBlocks(entries, match) {
38
+ return [...entries]
39
+ .flatMap((entry) => {
40
+ const data = entry?.data;
41
+ return data?.type === 'journal' && typeof data.markdown === 'string' ? [data.markdown] : [];
42
+ })
43
+ .map(match)
44
+ .filter((block) => block !== null);
45
+ }
@@ -1,6 +1,7 @@
1
1
  import type { FrictionLogReadResult, JournalEntry } from './types.mts';
2
2
  export declare function isConformingCiFailureBlock(markdown: string): boolean;
3
3
  export declare function getConformingGroups(entries: Iterable<JournalEntry>): string[];
4
+ export declare function incompleteCiSection(markdownBlocks: string[]): string;
4
5
  export declare function buildCiFailuresSection(sessionId: string, journal: {
5
6
  status: 'ok';
6
7
  markdownBlocks: string[];
@@ -8,4 +9,4 @@ export declare function buildCiFailuresSection(sessionId: string, journal: {
8
9
  } | {
9
10
  status: 'unreachable';
10
11
  diagnostic: string;
11
- }, frictionStatus: FrictionLogReadResult['status']): string;
12
+ }, frictionStatus: FrictionLogReadResult['status'] | 'journal-only'): string;
@@ -1,9 +1,9 @@
1
- import { isWellFormedUnicode, markdownAuditText } from './text.mjs';
1
+ import { conformingBlocks, matchAuditBlock } from './audit-block.mjs';
2
+ import { markdownAuditText } from './text.mjs';
2
3
  const GROUP_HEADER = /^- `(recurring|one-off)` — `GitHub Actions` — .*[^\s]$/;
3
4
  const EVIDENCE = /^ {2}- Evidence: .*[^\s]$/;
4
5
  const ROOT_DIAGNOSTIC = /^ {2}- Root diagnostic: .*[^\s]$/;
5
6
  const DISPOSITION = /^ {2}- Disposition: .*[^\s]$/;
6
- const BLANK = /^\s*$/;
7
7
  const CI_FAILURES_HEADER = '## CI Failures';
8
8
  const CI_FAILURE_BLOCK_MAX_BYTES = 10_000;
9
9
  const FIELD_PREFIXES = [
@@ -13,42 +13,8 @@ const FIELD_PREFIXES = [
13
13
  ' - Root diagnostic: ',
14
14
  ' - Disposition: ',
15
15
  ];
16
- function safeField(line) {
17
- const prefix = FIELD_PREFIXES.find((value) => line.startsWith(value));
18
- /* v8 ignore next -- matchBlock currently passes only fields with a known prefix. */
19
- if (!prefix)
20
- return markdownAuditText(line) || null;
21
- const content = markdownAuditText(line.slice(prefix.length));
22
- return content ? `${prefix}${content}` : null;
23
- }
24
16
  function matchBlock(markdown) {
25
- if (!isWellFormedUnicode(markdown))
26
- return null;
27
- const lines = markdown.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
28
- let index = 0;
29
- const consume = (pattern) => {
30
- while (index < lines.length && BLANK.test(lines[index]))
31
- index++;
32
- const line = lines[index];
33
- if (line === undefined || !pattern.test(line))
34
- return null;
35
- index++;
36
- return line;
37
- };
38
- const fields = [
39
- consume(GROUP_HEADER),
40
- consume(EVIDENCE),
41
- consume(ROOT_DIAGNOSTIC),
42
- consume(DISPOSITION),
43
- ];
44
- if (fields.some((field) => field === null))
45
- return null;
46
- if (!lines.slice(index).every((line) => BLANK.test(line)))
47
- return null;
48
- const safeFields = fields.map((field) => safeField(field));
49
- if (safeFields.some((field) => field === null))
50
- return null;
51
- return safeFields.join('\n');
17
+ return matchAuditBlock(markdown, [GROUP_HEADER, EVIDENCE, ROOT_DIAGNOSTIC, DISPOSITION], FIELD_PREFIXES);
52
18
  }
53
19
  export function isConformingCiFailureBlock(markdown) {
54
20
  if (markdown.length > CI_FAILURE_BLOCK_MAX_BYTES ||
@@ -56,16 +22,14 @@ export function isConformingCiFailureBlock(markdown) {
56
22
  return false;
57
23
  return matchBlock(markdown) !== null;
58
24
  }
59
- function journalMarkdown(entries) {
60
- return [...entries].flatMap((entry) => {
61
- const data = entry?.data;
62
- return data?.type === 'journal' && typeof data.markdown === 'string' ? [data.markdown] : [];
63
- });
64
- }
65
25
  export function getConformingGroups(entries) {
66
- return journalMarkdown(entries)
67
- .map(matchBlock)
68
- .filter((block) => block !== null);
26
+ return conformingBlocks(entries, matchBlock);
27
+ }
28
+ export function incompleteCiSection(markdownBlocks) {
29
+ return [
30
+ `${CI_FAILURES_HEADER}\nStatus: unavailable (journal scan incomplete)`,
31
+ ...markdownBlocks,
32
+ ].join('\n\n');
69
33
  }
70
34
  export function buildCiFailuresSection(sessionId, journal, frictionStatus) {
71
35
  if (journal.status === 'unreachable')
@@ -0,0 +1,14 @@
1
+ import type { JournalEntry, JournalLoader } from './types.mts';
2
+ export type JournalScan = {
3
+ status: 'ok';
4
+ entries: JournalEntry[];
5
+ truncated: boolean;
6
+ } | {
7
+ status: 'not-found';
8
+ } | {
9
+ status: 'unreachable';
10
+ diagnostic: string;
11
+ };
12
+ export declare function errorMessage(error: unknown): string;
13
+ /** Loads the session journal through the caller's loader within the shared bounded scan. */
14
+ export declare function scanJournal(sessionId: string, loader: JournalLoader): Promise<JournalScan>;
@@ -0,0 +1,66 @@
1
+ const JOURNAL_ENTRY_MAX_COUNT = 500;
2
+ const JOURNAL_MARKDOWN_MAX_BYTES = 10_000;
3
+ const JOURNAL_TOTAL_MAX_BYTES = 1_000_000;
4
+ function journalData(entry) {
5
+ try {
6
+ const data = entry?.data;
7
+ return data ? { type: data.type, markdown: data.markdown } : undefined;
8
+ }
9
+ catch {
10
+ return 'unreadable';
11
+ }
12
+ }
13
+ async function collectEntries(entries) {
14
+ const result = [];
15
+ let consumed = 0;
16
+ let inspectedBytes = 0;
17
+ let retainedBytes = 0;
18
+ let truncated = false;
19
+ for await (const entry of entries) {
20
+ consumed++;
21
+ const scanned = journalData(entry);
22
+ if (scanned === 'unreadable')
23
+ truncated = true;
24
+ const data = scanned === 'unreadable' ? undefined : scanned;
25
+ const markdown = data?.markdown;
26
+ if (data?.type === 'journal' && typeof markdown !== 'string')
27
+ truncated = true;
28
+ if (data?.type === 'journal' && typeof markdown === 'string') {
29
+ const bytes = Buffer.byteLength(markdown);
30
+ inspectedBytes += bytes;
31
+ if (bytes > JOURNAL_MARKDOWN_MAX_BYTES)
32
+ truncated = true;
33
+ if (bytes <= JOURNAL_MARKDOWN_MAX_BYTES && retainedBytes + bytes <= JOURNAL_TOTAL_MAX_BYTES) {
34
+ result.push({ data: { type: 'journal', markdown } });
35
+ retainedBytes += bytes;
36
+ }
37
+ }
38
+ if (consumed >= JOURNAL_ENTRY_MAX_COUNT ||
39
+ inspectedBytes >= JOURNAL_TOTAL_MAX_BYTES ||
40
+ retainedBytes >= JOURNAL_TOTAL_MAX_BYTES) {
41
+ truncated = true;
42
+ break;
43
+ }
44
+ }
45
+ return { entries: result, truncated };
46
+ }
47
+ export function errorMessage(error) {
48
+ try {
49
+ return error instanceof Error ? error.message : String(error);
50
+ }
51
+ catch {
52
+ return '[unprintable error]';
53
+ }
54
+ }
55
+ /** Loads the session journal through the caller's loader within the shared bounded scan. */
56
+ export async function scanJournal(sessionId, loader) {
57
+ try {
58
+ const loaded = await loader(sessionId);
59
+ if (loaded.status === 'not-found')
60
+ return { status: 'not-found' };
61
+ return { status: 'ok', ...(await collectEntries(loaded.entries)) };
62
+ }
63
+ catch (error) {
64
+ return { status: 'unreachable', diagnostic: errorMessage(error) };
65
+ }
66
+ }