vouchington-tooling 0.41.0 → 0.42.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 (42) hide show
  1. package/bin/policy-runner.mjs +26 -0
  2. package/dist/agent-blackboard/feedback-codec.d.mts +1 -0
  3. package/dist/agent-blackboard/feedback-codec.mjs +1 -1
  4. package/dist/cli/usage.d.mts +1 -1
  5. package/dist/cli/usage.mjs +2 -1
  6. package/dist/mcp-server/context.d.mts +11 -2
  7. package/dist/mcp-server/context.mjs +14 -1
  8. package/dist/mcp-server/dispatch.d.mts +3 -0
  9. package/dist/mcp-server/dispatch.mjs +5 -0
  10. package/dist/mcp-server/handlers-journal.d.mts +14 -1
  11. package/dist/mcp-server/handlers-journal.mjs +14 -9
  12. package/dist/mcp-server/handlers-retrospective.d.mts +12 -0
  13. package/dist/mcp-server/handlers-retrospective.mjs +154 -0
  14. package/dist/mcp-server/harness.test-helpers.d.mts +2 -0
  15. package/dist/mcp-server/harness.test-helpers.mjs +1 -0
  16. package/dist/mcp-server/journal-read.d.mts +9 -0
  17. package/dist/mcp-server/journal-read.mjs +33 -0
  18. package/dist/mcp-server/policy-config.d.mts +6 -0
  19. package/dist/mcp-server/policy-config.mjs +60 -0
  20. package/dist/mcp-server/policy-run.d.mts +40 -0
  21. package/dist/mcp-server/policy-run.mjs +116 -0
  22. package/dist/mcp-server/policy-spawn.d.mts +24 -0
  23. package/dist/mcp-server/policy-spawn.mjs +97 -0
  24. package/dist/mcp-server/retrospective-facts-input.d.mts +6 -0
  25. package/dist/mcp-server/retrospective-facts-input.mjs +25 -0
  26. package/dist/mcp-server/retrospective-front-matter.d.mts +22 -0
  27. package/dist/mcp-server/retrospective-front-matter.mjs +88 -0
  28. package/dist/mcp-server/retrospective-input.d.mts +29 -0
  29. package/dist/mcp-server/retrospective-input.mjs +145 -0
  30. package/dist/mcp-server/retrospective-resolve.d.mts +15 -0
  31. package/dist/mcp-server/retrospective-resolve.mjs +41 -0
  32. package/dist/mcp-server/tools-retrospective.d.mts +2 -0
  33. package/dist/mcp-server/tools-retrospective.mjs +48 -0
  34. package/dist/mcp-server/tools-schema.d.mts +54 -0
  35. package/dist/mcp-server/tools-schema.mjs +70 -0
  36. package/dist/mcp-server/tools.d.mts +1 -18
  37. package/dist/mcp-server/tools.mjs +4 -70
  38. package/dist/mcp-server/worktree.d.mts +11 -4
  39. package/dist/mcp-server/worktree.mjs +31 -6
  40. package/docs/mcp-server.md +90 -16
  41. package/package.json +1 -1
  42. package/skills/retrospective/SKILL.md +5 -2
@@ -0,0 +1,48 @@
1
+ import { ADDITIVE, COVERAGE, IDENTITY, IDENTITY_REQUIRED, ID_PATTERN, OUTCOMES, READ_ONLY, tool, } from "./tools-schema.mjs";
2
+ export const RETROSPECTIVE_TOOLS = [
3
+ tool('retrospective_check', 'Reports whether the session already holds a retrospective: { sessionId, saved, entries } ' +
4
+ 'where each entry is { sourceEventId, timestamp, issues, prs } for an entry of type ' +
5
+ 'retrospective, oldest first. sourceEventId narrows the result. A session that was never ' +
6
+ 'created reports saved: false.', READ_ONLY, { sourceEventId: { type: 'string', pattern: ID_PATTERN, description: 'Only this event id.' } }),
7
+ tool('retrospective_save', 'Saves the session retrospective after the checkout policy accepts it, and returns the same ' +
8
+ 'verified receipt as journal_append (status, receipt, timestamp, pendingCount, ' +
9
+ 'worktreePendingCount) plus policy.warnings. Pass exactly one of markdown (a finished ' +
10
+ 'document opening with the front matter composeRetrospective writes: date, issues, prs ' +
11
+ 'and optionally session_id, description, work_outcome, feedback_coverage, repositories, ' +
12
+ 'each line key: <JSON>; any that repeat an argument must equal it) or compose (the retrospective ' +
13
+ 'composer input: narrative, date, issues, prs, description, facts, transcript, tools, ' +
14
+ 'architecture, optional knownSensitiveValues; the server adds the rest). compose.transcript ' +
15
+ 'takes only { sessionId?, harnessSessionId? } (sessionId must be the call sessionId; the ' +
16
+ 'transcript is found by harnessSessionId, the harness session UUID, else by sessionId when ' +
17
+ 'that is a UUID, in the default session roots of the worktree) or { status, reason } to report it ' +
18
+ 'unassessed; any path override is rejected. The server redacts its blackboard token ' +
19
+ 'from the document before the policy sees it. dryRun returns ' +
20
+ '{ markdown, policy: { warnings } } and writes nothing. The worktree package.json may ' +
21
+ 'declare "vouchington-tooling": { "policy": "<module>" }; its validateRetrospective export ' +
22
+ 'runs in a child process, and any error it reports rejects the save with nothing written. ' +
23
+ 'Policy tools need a server launched inside a repository worktree. The server owns the ' +
24
+ 'timestamp. sourceEventId defaults to retrospective-<sessionId> (retrospective-<sha256 hex of the ' +
25
+ 'sessionId> when that exceeds 256 bytes); saving changed content ' +
26
+ 'under the same sourceEventId is an event-conflict, so a revision needs a new ' +
27
+ 'sourceEventId. Repeat an identical markdown call to retry. compose regenerates the document, ' +
28
+ 'so deliver a retained compose save with outbox_flush instead of repeating it. Counts ' +
29
+ 'follow journal_append.', ADDITIVE, {
30
+ ...IDENTITY,
31
+ mode: { enum: ['interactive', 'autonomous'], description: 'Delivery mode.' },
32
+ workOutcome: { enum: OUTCOMES, description: 'Outcome of the work being reported.' },
33
+ feedbackCoverage: COVERAGE,
34
+ repositories: {
35
+ type: 'array',
36
+ items: { type: 'string' },
37
+ minItems: 1,
38
+ description: 'Exact owner/name repositories; stored sorted, deduplicated and lowercase. Default: ' +
39
+ 'the front matter repositories, else the policy pullRequestDefaults repositories; ' +
40
+ 'an error when none.',
41
+ },
42
+ markdown: { type: 'string', description: 'Finished retrospective markdown.' },
43
+ compose: { type: 'object', description: 'Composer input; exclusive with markdown.' },
44
+ sourceEventId: { type: 'string', pattern: ID_PATTERN, description: 'Stable event id.' },
45
+ category: { type: 'string', description: 'Optional context; never changes entry type.' },
46
+ dryRun: { type: 'boolean', description: 'Compose and validate only; write nothing.' },
47
+ }, [...IDENTITY_REQUIRED, 'mode', 'workOutcome', 'feedbackCoverage']),
48
+ ];
@@ -0,0 +1,54 @@
1
+ export 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 ID_PATTERN = "^[A-Za-z0-9._:-]+$";
19
+ export declare const PARENT_SESSION_ID: JsonSchema;
20
+ export declare const AGENT: JsonSchema;
21
+ export declare const VERSION: JsonSchema;
22
+ export declare const OUTCOMES: string[];
23
+ export declare const COVERAGE: JsonSchema;
24
+ export declare const READ_ONLY: {
25
+ readOnlyHint: boolean;
26
+ destructiveHint: boolean;
27
+ idempotentHint: boolean;
28
+ openWorldHint: boolean;
29
+ };
30
+ export declare const ADDITIVE: {
31
+ readOnlyHint: boolean;
32
+ destructiveHint: boolean;
33
+ idempotentHint: boolean;
34
+ openWorldHint: boolean;
35
+ };
36
+ export declare const IDEMPOTENT_ADDITIVE: {
37
+ readOnlyHint: boolean;
38
+ destructiveHint: boolean;
39
+ idempotentHint: boolean;
40
+ openWorldHint: boolean;
41
+ };
42
+ export declare const MUTATING: {
43
+ readOnlyHint: boolean;
44
+ destructiveHint: boolean;
45
+ idempotentHint: boolean;
46
+ openWorldHint: boolean;
47
+ };
48
+ export declare function tool(name: string, description: string, annotations: ToolDefinition['annotations'], properties?: Record<string, JsonSchema>, required?: string[]): ToolDefinition;
49
+ export declare const IDENTITY: {
50
+ parentSessionId: JsonSchema;
51
+ agent: JsonSchema;
52
+ version: JsonSchema;
53
+ };
54
+ export declare const IDENTITY_REQUIRED: string[];
@@ -0,0 +1,70 @@
1
+ export 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 the top level of a git worktree (`git rev-parse --show-toplevel`), not a ' +
11
+ 'subdirectory. Defaults to the launch directory worktree; required when the server was ' +
12
+ 'launched outside a git worktree.',
13
+ };
14
+ export const PARENT_SESSION_ID = {
15
+ type: ['string', 'null'],
16
+ pattern: ID_PATTERN,
17
+ maxLength: 256,
18
+ description: 'Direct parent session id, or null for a root session.',
19
+ };
20
+ export const AGENT = { type: 'string', maxLength: 128, description: 'Agent name.' };
21
+ export const VERSION = { type: 'string', maxLength: 128, description: 'Agent version.' };
22
+ export const OUTCOMES = [
23
+ 'in-progress',
24
+ 'success',
25
+ 'failure',
26
+ 'cancelled',
27
+ 'timed-out',
28
+ 'no-change',
29
+ 'policy-refusal',
30
+ 'unknown',
31
+ ];
32
+ const COVERAGE_STATUSES = ['complete', 'partial', 'unavailable', 'not-assessed', 'not-started'];
33
+ export const COVERAGE = {
34
+ type: 'object',
35
+ properties: {
36
+ status: { enum: COVERAGE_STATUSES },
37
+ sources: { type: 'array', items: { type: 'string' } },
38
+ droppedCount: { type: 'integer', minimum: 0 },
39
+ },
40
+ required: ['status'],
41
+ additionalProperties: false,
42
+ description: 'Explicit coverage; sources default to [] and droppedCount to 0.',
43
+ };
44
+ export const READ_ONLY = flags(true, false, true);
45
+ export const ADDITIVE = flags(false, false, false);
46
+ export const IDEMPOTENT_ADDITIVE = flags(false, false, true);
47
+ export const MUTATING = flags(false, true, true);
48
+ function flags(readOnly, destructive, idempotent) {
49
+ return {
50
+ readOnlyHint: readOnly,
51
+ destructiveHint: destructive,
52
+ idempotentHint: idempotent,
53
+ openWorldHint: false,
54
+ };
55
+ }
56
+ export function tool(name, description, annotations, properties = {}, required = []) {
57
+ return {
58
+ name,
59
+ description,
60
+ inputSchema: {
61
+ type: 'object',
62
+ properties: { sessionId: SESSION_ID, worktree: WORKTREE, ...properties },
63
+ required: ['sessionId', ...required],
64
+ additionalProperties: false,
65
+ },
66
+ annotations,
67
+ };
68
+ }
69
+ export const IDENTITY = { parentSessionId: PARENT_SESSION_ID, agent: AGENT, version: VERSION };
70
+ export const IDENTITY_REQUIRED = ['parentSessionId', 'agent', 'version'];
@@ -1,19 +1,2 @@
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
- };
1
+ import { type ToolDefinition } from './tools-schema.mts';
18
2
  export declare const TOOLS: readonly ToolDefinition[];
19
- export {};
@@ -1,62 +1,5 @@
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 the top level of a git worktree (`git rev-parse --show-toplevel`), not a ' +
11
- 'subdirectory. Defaults to the launch directory worktree; required when the server was ' +
12
- 'launched outside a git worktree.',
13
- };
14
- const PARENT_SESSION_ID = {
15
- type: ['string', 'null'],
16
- pattern: ID_PATTERN,
17
- maxLength: 256,
18
- description: 'Direct parent session id, or null for a root session.',
19
- };
20
- const AGENT = { type: 'string', maxLength: 128, description: 'Agent name.' };
21
- const VERSION = { type: 'string', maxLength: 128, description: 'Agent version.' };
22
- const OUTCOMES = [
23
- 'in-progress',
24
- 'success',
25
- 'failure',
26
- 'cancelled',
27
- 'timed-out',
28
- 'no-change',
29
- 'policy-refusal',
30
- 'unknown',
31
- ];
32
- const COVERAGE_STATUSES = ['complete', 'partial', 'unavailable', 'not-assessed', 'not-started'];
33
- const READ_ONLY = flags(true, false, true);
34
- const ADDITIVE = flags(false, false, false);
35
- const IDEMPOTENT_ADDITIVE = flags(false, false, true);
36
- const MUTATING = flags(false, true, true);
37
- function flags(readOnly, destructive, idempotent) {
38
- return {
39
- readOnlyHint: readOnly,
40
- destructiveHint: destructive,
41
- idempotentHint: idempotent,
42
- openWorldHint: false,
43
- };
44
- }
45
- function tool(name, description, annotations, properties = {}, required = []) {
46
- return {
47
- name,
48
- description,
49
- inputSchema: {
50
- type: 'object',
51
- properties: { sessionId: SESSION_ID, worktree: WORKTREE, ...properties },
52
- required: ['sessionId', ...required],
53
- additionalProperties: false,
54
- },
55
- annotations,
56
- };
57
- }
58
- const IDENTITY = { parentSessionId: PARENT_SESSION_ID, agent: AGENT, version: VERSION };
59
- const IDENTITY_REQUIRED = ['parentSessionId', 'agent', 'version'];
1
+ import { ADDITIVE, AGENT, COVERAGE, IDEMPOTENT_ADDITIVE, IDENTITY, IDENTITY_REQUIRED, ID_PATTERN, MUTATING, OUTCOMES, PARENT_SESSION_ID, READ_ONLY, VERSION, tool, } from "./tools-schema.mjs";
2
+ import { RETROSPECTIVE_TOOLS } from "./tools-retrospective.mjs";
60
3
  export const TOOLS = [
61
4
  tool('journal_append', 'Appends one journal entry to the caller session and returns the verified read-back receipt. ' +
62
5
  'Nothing is written unless every required field validates. Interactive mode retains the ' +
@@ -78,17 +21,7 @@ export const TOOLS = [
78
21
  minItems: 1,
79
22
  description: 'Exact owner/name repositories this entry concerns.',
80
23
  },
81
- feedbackCoverage: {
82
- type: 'object',
83
- properties: {
84
- status: { enum: COVERAGE_STATUSES },
85
- sources: { type: 'array', items: { type: 'string' } },
86
- droppedCount: { type: 'integer', minimum: 0 },
87
- },
88
- required: ['status'],
89
- additionalProperties: false,
90
- description: 'Explicit coverage; sources default to [] and droppedCount to 0.',
91
- },
24
+ feedbackCoverage: COVERAGE,
92
25
  category: { type: 'string', description: 'Optional context; never changes entry type.' },
93
26
  }, [
94
27
  ...IDENTITY_REQUIRED,
@@ -112,6 +45,7 @@ export const TOOLS = [
112
45
  'as sourceEventId and diagnostic.', IDEMPOTENT_ADDITIVE),
113
46
  tool('session_ensure', 'Idempotently creates a session, or verifies that an existing one has the same identity. ' +
114
47
  'Returns the session id and whether it was created.', IDEMPOTENT_ADDITIVE, IDENTITY, IDENTITY_REQUIRED),
48
+ ...RETROSPECTIVE_TOOLS,
115
49
  tool('snapshot_export', 'Exports non-archived sessions into a private local JSONL file chosen by the blackboard ' +
116
50
  'client. Returns the path, counts, checksum, manifest, and cleanupToken; never the records. ' +
117
51
  'sessionId identifies the caller.', ADDITIVE, {
@@ -11,12 +11,19 @@ export declare const runIsolatedGit: RunTextCommand;
11
11
  * or an unsafe repository, is thrown with the cause rather than reported as "outside a worktree".
12
12
  */
13
13
  export declare function launchWorktreeRoot(cwd: string, runGit: RunTextCommand): Promise<string | undefined>;
14
+ /**
15
+ * The worktree paths in `git worktree list --porcelain -z` output: NUL-terminated fields, an empty
16
+ * field ending each record, so a path containing a newline cannot forge a record.
17
+ */
18
+ export declare function parseWorktreeList(output: string): string[];
14
19
  /**
15
20
  * Resolves the worktree a tool call may act on. It must be an absolute path that, after
16
- * resolving symlinks, is exactly the top level of a git worktree (of any repository on the
17
- * machine). A subdirectory, a relative or nonexistent path, and a non-git directory are rejected.
18
- * Git is asked on every call, so worktrees created after the server started are accepted and
19
- * removed ones are not. Without `requested`, the launch worktree is used when there is one.
21
+ * resolving symlinks, is exactly the top level of a git worktree. A subdirectory, a relative or
22
+ * nonexistent path, and a non-git directory are rejected. When the server was launched inside a
23
+ * repository, the path must also be one of that repository's worktrees (primary or linked), so an
24
+ * unrelated `git init` directory is refused. A server launched outside any repository accepts the
25
+ * top level of any worktree. Git is asked on every call, so worktrees created after the server
26
+ * started are accepted and removed ones are not. Without `requested`, the launch worktree is used.
20
27
  */
21
28
  export declare function resolveWorktree(input: {
22
29
  launchRoot: string | undefined;
@@ -52,12 +52,33 @@ export async function launchWorktreeRoot(cwd, runGit) {
52
52
  }
53
53
  return realpath(output.trim());
54
54
  }
55
+ /**
56
+ * The worktree paths in `git worktree list --porcelain -z` output: NUL-terminated fields, an empty
57
+ * field ending each record, so a path containing a newline cannot forge a record.
58
+ */
59
+ export function parseWorktreeList(output) {
60
+ return output
61
+ .split('\0')
62
+ .filter((field) => field.startsWith('worktree '))
63
+ .map((field) => field.slice('worktree '.length));
64
+ }
65
+ /**
66
+ * The real paths of every worktree of the repository containing `root` (the primary worktree and
67
+ * each linked one).
68
+ */
69
+ async function repositoryWorktrees(root, runGit) {
70
+ const output = await runGit(['-C', root, 'worktree', 'list', '--porcelain', '-z']);
71
+ const paths = parseWorktreeList(output).map(canonical);
72
+ return new Set((await Promise.all(paths)).filter((path) => path !== undefined));
73
+ }
55
74
  /**
56
75
  * Resolves the worktree a tool call may act on. It must be an absolute path that, after
57
- * resolving symlinks, is exactly the top level of a git worktree (of any repository on the
58
- * machine). A subdirectory, a relative or nonexistent path, and a non-git directory are rejected.
59
- * Git is asked on every call, so worktrees created after the server started are accepted and
60
- * removed ones are not. Without `requested`, the launch worktree is used when there is one.
76
+ * resolving symlinks, is exactly the top level of a git worktree. A subdirectory, a relative or
77
+ * nonexistent path, and a non-git directory are rejected. When the server was launched inside a
78
+ * repository, the path must also be one of that repository's worktrees (primary or linked), so an
79
+ * unrelated `git init` directory is refused. A server launched outside any repository accepts the
80
+ * top level of any worktree. Git is asked on every call, so worktrees created after the server
81
+ * started are accepted and removed ones are not. Without `requested`, the launch worktree is used.
61
82
  */
62
83
  export async function resolveWorktree(input) {
63
84
  const { launchRoot, requested, runGit } = input;
@@ -68,8 +89,12 @@ export async function resolveWorktree(input) {
68
89
  if (!isAbsolute(path))
69
90
  throw new Error('worktree must be an absolute path to a git worktree');
70
91
  const target = await canonical(path);
71
- if (target !== undefined && (await launchWorktreeRoot(target, runGit)) === target)
72
- return target;
92
+ if (target !== undefined && (await launchWorktreeRoot(target, runGit)) === target) {
93
+ if (launchRoot === undefined || (await repositoryWorktrees(launchRoot, runGit)).has(target))
94
+ return target;
95
+ throw new Error(`worktree ${path} is not a worktree of the repository this server was launched in ` +
96
+ `(${launchRoot}); \`git worktree list\` there names the worktrees it may act on`);
97
+ }
73
98
  throw new Error(`worktree ${path} is not the top level of a git worktree: it must exist and be the ` +
74
99
  'directory `git rev-parse --show-toplevel` reports, not a subdirectory of it');
75
100
  }
@@ -6,8 +6,8 @@ a tool argument; there is no temp file, `--file`, or replay command. The server
6
6
  existing `vouchington-tooling/agent-blackboard` exports and adds no provider protocol.
7
7
 
8
8
  Phase 1 of [vouchington-tooling#324](https://github.com/vouchington/vouchington-tooling/issues/324)
9
- covers the journal, outbox, session, and snapshot tools below. Retrospective and pull request tools
10
- are later phases.
9
+ covers the journal, outbox, session, and snapshot tools below. Phase 2 adds the retrospective tools
10
+ and the policy contract they run. Pull request tools are a later phase.
11
11
 
12
12
  ## Install
13
13
 
@@ -82,15 +82,17 @@ Every tool takes an explicit `sessionId` (`^[A-Za-z0-9._:-]+$`, at most 256 char
82
82
  server never infers, and an optional `worktree`. Unknown arguments are rejected. Failures come back
83
83
  as MCP tool errors (`isError: true`) with an actionable message rather than crashing the server.
84
84
 
85
- | Tool | Arguments besides `sessionId` and `worktree` | Returns |
86
- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
87
- | `journal_append` | `parentSessionId`, `agent`, `version`, `mode` (`interactive` or `autonomous`), `markdown`, `sourceEventId`, `workOutcome`, `repositories`, `feedbackCoverage`, optional `category` | `sessionId`, `timestamp`, delivery `status` (`delivered` with a verified receipt, or `pending`), `pendingCount`, `worktreePendingCount` |
88
- | `journal_entries` | none | `sessionId` and `entries`, every entry oldest first |
89
- | `outbox_status` | none | `sessionId`, `status`, `pendingCount`, `worktreePendingCount`, `rejectedCount`, `worktreeRejectedCount` |
90
- | `outbox_flush` | none | `sessionId`, `status`, `pendingCount`, `worktreePendingCount`, `rejectedCount`, `worktreeRejectedCount`, `deliveredCount`, `rejected` (when any) |
91
- | `session_ensure` | `parentSessionId`, `agent`, `version` | `sessionId`, `status` (`created` or `exists`), `archived` |
92
- | `snapshot_export` | optional `agent`, `version`, `parentSessionId`, `data`, `dataArrayContains`, `inactiveForHours` | `sessionId`, `path`, `counts`, `checksum`, `manifest`, `cleanupToken` |
93
- | `session_archive` | none | `sessionId`, `archived: true` |
85
+ | Tool | Arguments besides `sessionId` and `worktree` | Returns |
86
+ | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
87
+ | `journal_append` | `parentSessionId`, `agent`, `version`, `mode` (`interactive` or `autonomous`), `markdown`, `sourceEventId`, `workOutcome`, `repositories`, `feedbackCoverage`, optional `category` | `sessionId`, `timestamp`, delivery `status` (`delivered` with a verified receipt, or `pending`), `pendingCount`, `worktreePendingCount` |
88
+ | `journal_entries` | none | `sessionId` and `entries`, every entry oldest first |
89
+ | `outbox_status` | none | `sessionId`, `status`, `pendingCount`, `worktreePendingCount`, `rejectedCount`, `worktreeRejectedCount` |
90
+ | `outbox_flush` | none | `sessionId`, `status`, `pendingCount`, `worktreePendingCount`, `rejectedCount`, `worktreeRejectedCount`, `deliveredCount`, `rejected` (when any) |
91
+ | `session_ensure` | `parentSessionId`, `agent`, `version` | `sessionId`, `status` (`created` or `exists`), `archived` |
92
+ | `retrospective_check` | optional `sourceEventId` | `sessionId`, `saved`, `entries` (`sourceEventId`, `timestamp`, `issues`, `prs` of each retrospective, oldest first) |
93
+ | `retrospective_save` | `parentSessionId`, `agent`, `version`, `mode`, `workOutcome`, `feedbackCoverage`, exactly one of `markdown` or `compose`, optional `repositories`, `sourceEventId`, `category`, `dryRun` | as `journal_append` plus `policy.warnings`; with `dryRun`, `markdown` and `policy.warnings` |
94
+ | `snapshot_export` | optional `agent`, `version`, `parentSessionId`, `data`, `dataArrayContains`, `inactiveForHours` | `sessionId`, `path`, `counts`, `checksum`, `manifest`, `cleanupToken` |
95
+ | `session_archive` | none | `sessionId`, `archived: true` |
94
96
 
95
97
  `journal_append` validates the whole feedback envelope with `validateFeedbackEnvelope` before
96
98
  anything is written, so a missing required field writes nothing. It returns the verified read-back
@@ -163,20 +165,92 @@ is unavailable.
163
165
  The server sends the same list in its MCP `instructions`, so a harness that surfaces server
164
166
  instructions shows it to the agent.
165
167
 
168
+ ## Retrospective tools and the policy contract
169
+
170
+ `retrospective_check` reads the session through the same client as `journal_entries` and reports the
171
+ entries whose envelope `type` is `retrospective`. A session that was never created (the blackboard
172
+ answers 404) reports `saved: false`.
173
+
174
+ `retrospective_save` builds a `retrospective` envelope and writes it exactly as `journal_append`
175
+ does (same receipt, `pendingCount`, `worktreePendingCount`, outbox rules, and server-owned
176
+ timestamp; a `timestamp` argument is rejected). The document comes from `markdown` or from
177
+ `compose`, the input of `composeRetrospective` (`narrative`, `date`, `issues`, `prs`,
178
+ `description`, `facts`, `transcript`, `tools`, `architecture`, optional `knownSensitiveValues`;
179
+ unknown keys are rejected, and the journal audit reads through the server's own client).
180
+ `compose.transcript` accepts only `{ sessionId?, harnessSessionId? }` (`sessionId` must equal the
181
+ call's `sessionId`) or `{ status, reason }` for an unassessed transcript. The transcript is found by
182
+ `harnessSessionId`, the harness's own session UUID, else by the call's `sessionId` when that is a
183
+ UUID; otherwise the call fails rather than reporting the transcript unavailable. When the server's environment names its harness session (`CLAUDE_CODE_SESSION_ID`, `CODEX_THREAD_ID`, and so on), only that session's transcript is read (after `/clear` or a thread switch, reconnect the server, or pass `{ status, reason }`); `jsonlPath`, `projectsDir`, `codexSessionsDir`,
184
+ `grokSessionsDir`, and `cwd` are rejected, and the server reads the transcript from the worktree
185
+ and its default session roots. The server's blackboard token, and any `knownSensitiveValues`, are
186
+ redacted from the document before the policy sees it or the envelope is stored. Facts commands
187
+ are killed after 120 seconds. `compose.facts` takes `pr`, `branch`, `noPr`, or `{ status, reason }`;
188
+ `repo` is rejected, so the server's GitHub token never queries a repository other than the
189
+ worktree's. Facts run `git` and `gh` in the worktree as the server's user, honoring its
190
+ repository configuration (which can select helpers that run code), so the worktree, including
191
+ `.git/config`, must be as trusted as the policy module. `dryRun`
192
+ runs every check and returns the document without writing.
193
+
194
+ The document must open with the front matter `composeRetrospective` writes: lines of
195
+ `key: <JSON value>` between two `---` lines. Recognized keys are `date`, `issues`, `prs` (required),
196
+ `session_id`, `description`, `work_outcome`, `feedback_coverage` (`status`, `sources`,
197
+ `droppedCount`), and `repositories`; anything else, a duplicate, or a malformed line is rejected.
198
+ `session_id`, `work_outcome`, `feedback_coverage`, and `repositories` must equal the arguments when
199
+ present. `repositories` resolves from the argument, else the front matter, else the policy's
200
+ `pullRequestDefaults()` result, else an error, and is stored sorted, deduplicated, and lowercase.
201
+ The default `sourceEventId` is `retrospective-<sessionId>`, or `retrospective-<sha256 hex of the
202
+ sessionId>` when that would exceed 256 bytes. Re-saving identical content returns the
203
+ stored receipt and timestamp. Changed content under the same `sourceEventId` is an
204
+ `event-conflict`; a revision needs a new `sourceEventId`. `compose` regenerates the document from
205
+ the live journal and repository, so repeating a `compose` call is not a retry: when an interactive
206
+ save is retained in the outbox, deliver it with `outbox_flush`.
207
+
208
+ ### Policy module
209
+
210
+ The consumer's root `package.json` may declare
211
+ `"vouchington-tooling": { "policy": "dev/agent-policy.mts" }`. The path must be relative and, after
212
+ resolving symlinks, stay inside the worktree. Without a declaration the policy checks are skipped.
213
+ Policy tools also require a server launched inside a repository worktree on macOS or Linux (they
214
+ refuse to run on Windows, where the server cannot reap a policy's descendants by process group),
215
+ and refuse to run when `AGENT_BLACKBOARD_TOKEN`, `GH_TOKEN`, or `GITHUB_TOKEN` is set to a value
216
+ shorter than four characters, which could not be redacted from policy output. Each call runs one named
217
+ export in a fresh child process (`bin/policy-runner.mjs`, started with the server's own Node), never
218
+ inside the server, with the worktree as its working directory and an environment of only `HOME`,
219
+ `PATH` (`/usr/bin`, `/bin`, `/usr/local/bin`, `/opt/homebrew/bin`, the Node directory, and the
220
+ directory of the first `gh` on the server's `PATH`, deduplicated), and `GH_TOKEN` / `GITHUB_TOKEN` when the server has
221
+ them. Blackboard credentials and `NODE_OPTIONS` are not passed. The child still runs as the
222
+ server's user, so this is not a sandbox: on Linux it could read the server's environment from
223
+ `/proc/<server pid>/environ`. Treat the policy module as trusted code. After the runner exits, its
224
+ whole process group is killed, so nothing it started outlives the call. The runner reads
225
+ `{ policyVersion: 1, export, input }` on stdin and writes `{ ok: true, result }` (or
226
+ `{ ok: false, error }`) on stdout; a missing export is skipped.
227
+
228
+ | Export | Input | Result | Timeout |
229
+ | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | ------- |
230
+ | `validateRetrospective` | `sessionId`, `parentSessionId`, `mode`, `workOutcome`, `feedbackCoverage`, `repositories`, `markdown`, `sourceEventId`, `metadata` (`date`, `issues`, `prs`, `description`) | `{ errors: string[], warnings?: string[] }` | 30 s |
231
+ | `pullRequestDefaults` | `{}` | `{ repositories: string[] }` | 120 s |
232
+ | `validatePullRequest` | reserved for the pull request tools | `{ errors: string[], warnings?: string[] }` | 120 s |
233
+
234
+ Any `errors` reject the save and write nothing. A non-zero exit, a timeout (reported as
235
+ `policy <export> timed out after <N>s`), output over 1 MB, malformed JSON, or a result of the wrong
236
+ shape is an error, never a pass.
237
+
166
238
  ## Worktree validation
167
239
 
168
240
  `worktree` is checked on every call. It must be an absolute path that, after resolving symlinks,
169
241
  equals the top level of a git worktree, that is, what `git rev-parse --show-toplevel` prints when
170
- run in it. Any git worktree on the machine is accepted, including one of a repository unrelated to
171
- the launch directory, so the tools can act on every checkout the user can reach. Worktrees created
172
- after launch are accepted and removed ones are refused. Rejected: a subdirectory of a worktree, a
242
+ run in it. When the server was launched inside a repository, the real path must also equal one of
243
+ the `worktree` entries of `git worktree list --porcelain` run in the launch root (the primary
244
+ worktree or a linked one), so an unrelated repository is refused. A server launched outside any
245
+ repository accepts the top level of any git worktree for the journal tools, but refuses the policy
246
+ tools. Worktrees created after launch are accepted and removed ones are refused. Rejected: a subdirectory of a worktree, a
173
247
  relative or nonexistent path, and a directory that is not in a git worktree. Git runs without
174
248
  `GIT_*` variables, so `GIT_DIR` cannot redirect the check, and with `LC_ALL=C` and no `LANGUAGE`, so
175
249
  its "not a git repository" diagnostic is never translated.
176
250
 
177
251
  `worktree` only chooses where the outbox lives. It is not a trust boundary for loading code: the
178
- server never imports modules from it, and a tool that needs to must first get a repository allowlist
179
- from the machine registration.
252
+ server never imports modules from it. The policy tools run the declared module only in a child
253
+ process without blackboard credentials, as described above.
180
254
 
181
255
  When `worktree` is omitted, the launch directory's worktree top level is used if the launch
182
256
  directory is inside one, and it is validated like any explicit path. If the server was launched
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vouchington-tooling",
3
- "version": "0.41.0",
3
+ "version": "0.42.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": {
@@ -14,7 +14,8 @@ local `AGENTS.md`, and journal guidance first.
14
14
 
15
15
  1. Read the agent's own journal oldest-first and check for an existing retrospective. Capture a
16
16
  material new observation before filing a follow-up issue. Preserve entry-level repository tags.
17
- 2. Generate routine repository and transcript facts with the approved composer. Keep exact factual
17
+ 2. Generate routine repository and transcript facts with the approved composer: pass `compose` to
18
+ the `retrospective_save` MCP tool and preview the composition with `dryRun`, never a CLI. Keep exact factual
18
19
  section markers generated, preserve unavailable counts, and never replace missing evidence with
19
20
  invented zero statistics. Do not paste raw collectors' output or manually reconstruct facts.
20
21
  3. Record work outcome, feedback coverage, and delivery independently. Success, failure,
@@ -30,7 +31,9 @@ local `AGENTS.md`, and journal guidance first.
30
31
  5. Keep the top five findings or improvements in the human prose. The bounded durable evidence can
31
32
  retain additional relevant findings; a prose limit is not permission to silently discard them.
32
33
  Separate recurring root causes from one-off noise and preserve existing issue references.
33
- 6. Save through the shared validated writer. Interactive outages use the approved bounded private
34
+ 6. Check for an existing retrospective with the `retrospective_check` MCP tool and save with
35
+ `retrospective_save`, never a CLI. A revision of changed content needs a new `sourceEventId`.
36
+ Interactive outages use the approved bounded private
34
37
  outbox with visible pending count; failed persistence blocks capture. Autonomous reporting stays
35
38
  online and never uses that outbox to authorize an attempt or completion. Report the acknowledged
36
39
  record/receipt, or the actual pending/blocked state and next replay action.