vouchington-tooling 0.25.0 → 0.27.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 (34) hide show
  1. package/README.md +4 -1
  2. package/dist/agent-blackboard/feedback-canonical.d.mts +7 -0
  3. package/dist/agent-blackboard/feedback-canonical.mjs +8 -0
  4. package/dist/agent-blackboard/feedback-delivery.mjs +8 -6
  5. package/dist/agent-blackboard/feedback-online.mjs +11 -4
  6. package/dist/agent-blackboard/feedback-outbox.d.mts +8 -1
  7. package/dist/agent-blackboard/feedback-outbox.mjs +17 -4
  8. package/dist/agent-blackboard/feedback-types.d.mts +4 -0
  9. package/dist/agent-blackboard/index.d.mts +1 -1
  10. package/dist/agent-blackboard/index.mjs +1 -1
  11. package/dist/mcp-server/context.d.mts +1 -2
  12. package/dist/mcp-server/dispatch.mjs +6 -12
  13. package/dist/mcp-server/fake-blackboard.test-helpers.d.mts +4 -0
  14. package/dist/mcp-server/fake-blackboard.test-helpers.mjs +7 -1
  15. package/dist/mcp-server/handlers-journal.d.mts +1 -2
  16. package/dist/mcp-server/handlers-journal.mjs +38 -29
  17. package/dist/mcp-server/handlers-outbox.d.mts +11 -0
  18. package/dist/mcp-server/handlers-outbox.mjs +45 -0
  19. package/dist/mcp-server/harness.test-helpers.d.mts +6 -1
  20. package/dist/mcp-server/harness.test-helpers.mjs +14 -2
  21. package/dist/mcp-server/server.mjs +5 -1
  22. package/dist/mcp-server/tools.mjs +15 -11
  23. package/dist/pnpm-install/pnpm-install-fake-pnpm.test-helpers.mjs +2 -2
  24. package/docs/mcp-server.md +65 -14
  25. package/package.json +1 -1
  26. package/skills/blackboard/SKILL.md +69 -22
  27. package/skills/manifest.json +27 -21
  28. package/skills/postgres-node-performance-tuning/references/performance-patterns.md +22 -0
  29. package/skills/postgres-partitioning-uuid-v7/SKILL.md +3 -0
  30. package/skills/postgres-schema-design/SKILL.md +21 -0
  31. package/skills/postgres-schema-design/references/naming.md +34 -0
  32. package/skills/postgres-schema-design/references/relations.md +46 -0
  33. package/skills/postgres-schema-design/references/table-shapes.md +56 -0
  34. package/skills/retrospective-distill/SKILL.md +43 -14
package/README.md CHANGED
@@ -554,7 +554,10 @@ Identity, content, and archived-session conflicts fail in either mode while pres
554
554
  record; they are not reported as transient pending delivery. Replay retains permanently conflicted
555
555
  records and continues to deliver unrelated pending records; transient failures stop that replay.
556
556
  Verified delivery retains its receipt when local cleanup fails, returning
557
- `cleanupDiagnostic` with value `"outbox-cleanup-failed"`. A retained or crash-reappearing record is safe to replay with exact dedup.
557
+ `cleanupDiagnostic` with value `"outbox-cleanup-failed"`. A retained or crash-reappearing record is
558
+ safe to replay: an event is identified by session, `sourceEventId`, and content, and a duplicate that
559
+ differs only in `timestamp` is the same event, so it reports the stored record's `timestamp` and
560
+ receipt and the retained duplicate is removed.
558
561
  Use `vouchington agent-blackboard journal flush --outbox-directory PATH` to replay unchanged pending
559
562
  records after connectivity returns.
560
563
 
@@ -1 +1,8 @@
1
+ import type { FeedbackEnvelope } from './feedback-types.mts';
1
2
  export declare function canonicalFeedback(value: unknown): string;
3
+ /**
4
+ * The identity of a feedback event is its session, `sourceEventId`, and content. The `timestamp` is
5
+ * assigned by the server on each attempt, so a retry that differs only in `timestamp` is the same
6
+ * event and is left out of the comparison.
7
+ */
8
+ export declare function canonicalFeedbackEvent(envelope: FeedbackEnvelope): string;
@@ -2,6 +2,14 @@ import { isObject } from './snapshot-partition-guards.mjs';
2
2
  export function canonicalFeedback(value) {
3
3
  return JSON.stringify(ordered(value));
4
4
  }
5
+ /**
6
+ * The identity of a feedback event is its session, `sourceEventId`, and content. The `timestamp` is
7
+ * assigned by the server on each attempt, so a retry that differs only in `timestamp` is the same
8
+ * event and is left out of the comparison.
9
+ */
10
+ export function canonicalFeedbackEvent(envelope) {
11
+ return canonicalFeedback(Object.fromEntries(Object.entries(envelope).filter(([key]) => key !== 'timestamp')));
12
+ }
5
13
  function ordered(value) {
6
14
  if (Array.isArray(value))
7
15
  return value.map(ordered);
@@ -18,13 +18,14 @@ export async function writeFeedback(input) {
18
18
  }
19
19
  if (!input.outboxDirectory)
20
20
  throw new Error('interactive feedback requires an explicit durable outbox directory');
21
- persistFeedbackOutbox(input.outboxDirectory, { identity: input.identity, envelope });
21
+ // A retained record for the same event keeps its own timestamp and is the one delivered.
22
+ const record = persistFeedbackOutbox(input.outboxDirectory, {
23
+ identity: input.identity,
24
+ envelope,
25
+ });
22
26
  try {
23
- const receipt = await deliverFeedbackOnline({ ...input, envelope });
24
- const cleanup = removeFeedbackOutbox(input.outboxDirectory, {
25
- identity: input.identity,
26
- envelope,
27
- });
27
+ const receipt = await deliverFeedbackOnline({ ...input, envelope: record.envelope });
28
+ const cleanup = removeFeedbackOutbox(input.outboxDirectory, record);
28
29
  return { status: 'delivered', sourceEventId: envelope.sourceEventId, ...cleanup, receipt };
29
30
  }
30
31
  catch (error) {
@@ -34,6 +35,7 @@ export async function writeFeedback(input) {
34
35
  return {
35
36
  status: 'pending',
36
37
  sourceEventId: envelope.sourceEventId,
38
+ timestamp: record.envelope.timestamp,
37
39
  pendingCount: feedbackOutboxStatus(input.outboxDirectory).pendingCount,
38
40
  diagnostic,
39
41
  };
@@ -1,6 +1,6 @@
1
1
  import { feedbackDeadline } from './feedback-deadline.mjs';
2
2
  import { FeedbackDeliveryError, feedbackDiagnostic } from './feedback-online-error.mjs';
3
- import { canonicalFeedback } from './feedback-canonical.mjs';
3
+ import { canonicalFeedback, canonicalFeedbackEvent } from './feedback-canonical.mjs';
4
4
  import { loadClient, resolveBlackboardConnection } from './client.mjs';
5
5
  import { normalizeRepositories, validateFeedbackEnvelope } from './feedback-codec.mjs';
6
6
  import { validateFeedbackIdentity } from './feedback-identity.mjs';
@@ -48,13 +48,18 @@ async function findEvent(entries, envelope, expectedCreatedAt) {
48
48
  catch {
49
49
  throw new FeedbackDeliveryError('event-conflict');
50
50
  }
51
- if (canonicalFeedback(entry.data) !== canonicalFeedback(envelope) ||
51
+ // Same event means same content; the stored `timestamp` may differ from this attempt's.
52
+ if (canonicalFeedbackEvent(entry.data) !== canonicalFeedbackEvent(envelope) ||
52
53
  typeof entry.createdAt !== 'string')
53
54
  throw new FeedbackDeliveryError('event-conflict');
54
55
  if (++matching > 1 && expectedCreatedAt !== undefined)
55
56
  throw new FeedbackDeliveryError('event-conflict');
56
- if (expectedCreatedAt === undefined || entry.createdAt === expectedCreatedAt)
57
- found = { createdAt: entry.createdAt };
57
+ // Duplicates of one event (a late write beside a retry) report the earliest stored record, so
58
+ // every retry returns the same timestamp.
59
+ if (expectedCreatedAt === undefined
60
+ ? found === undefined || entry.createdAt < found.createdAt
61
+ : entry.createdAt === expectedCreatedAt)
62
+ found = { createdAt: entry.createdAt, timestamp: entry.data.timestamp };
58
63
  }
59
64
  return found;
60
65
  }
@@ -108,6 +113,7 @@ export async function deliverFeedbackOnline(input, fresh = false) {
108
113
  sessionId: input.identity.sessionId,
109
114
  sourceEventId: input.envelope.sourceEventId,
110
115
  createdAt: existing.createdAt,
116
+ timestamp: existing.timestamp,
111
117
  verified: true,
112
118
  };
113
119
  const appended = await entries.append({
@@ -127,6 +133,7 @@ export async function deliverFeedbackOnline(input, fresh = false) {
127
133
  sessionId: input.identity.sessionId,
128
134
  sourceEventId: input.envelope.sourceEventId,
129
135
  createdAt: confirmed.createdAt,
136
+ timestamp: confirmed.timestamp,
130
137
  verified: true,
131
138
  };
132
139
  }
@@ -1,7 +1,14 @@
1
1
  import type { FeedbackOutboxRecord, FeedbackOutboxStatus } from './feedback-types.mts';
2
2
  export declare function feedbackOutboxStatus(directory: string): FeedbackOutboxStatus;
3
3
  export declare function listFeedbackOutbox(directory: string): FeedbackOutboxRecord[];
4
- export declare function persistFeedbackOutbox(directory: string, record: FeedbackOutboxRecord): number;
4
+ /** Reads the retained records without creating the outbox directory. */
5
+ export declare function readFeedbackOutbox(directory: string): FeedbackOutboxRecord[];
6
+ /**
7
+ * Retains the record unless the same event is already retained, in which case that record stays:
8
+ * the event is the session, `sourceEventId`, and content, and the earlier `timestamp` wins. The
9
+ * returned record is the one to deliver.
10
+ */
11
+ export declare function persistFeedbackOutbox(directory: string, record: FeedbackOutboxRecord): FeedbackOutboxRecord;
5
12
  export declare function removeFeedbackOutbox(directory: string, record: FeedbackOutboxRecord): {
6
13
  pendingCount: number;
7
14
  cleanupDiagnostic?: 'outbox-cleanup-failed';
@@ -1,4 +1,4 @@
1
- import { canonicalFeedback } from './feedback-canonical.mjs';
1
+ import { canonicalFeedback, canonicalFeedbackEvent } from './feedback-canonical.mjs';
2
2
  import { createHash, randomUUID } from 'node:crypto';
3
3
  import { closeSync, constants, fsyncSync, fstatSync, openSync, readdirSync, renameSync, unlinkSync, } from 'node:fs';
4
4
  import { isAbsolute, join, resolve } from 'node:path';
@@ -83,6 +83,18 @@ export function listFeedbackOutbox(directory) {
83
83
  const path = outboxDirectory(directory, true);
84
84
  return withFileLock(join(path, '.records'), () => records(path).map((item) => item.record));
85
85
  }
86
+ /** Reads the retained records without creating the outbox directory. */
87
+ export function readFeedbackOutbox(directory) {
88
+ const path = outboxDirectory(directory, false);
89
+ if (!ensurePrivateDirectory(path, false))
90
+ return [];
91
+ return withFileLock(join(path, '.records'), () => records(path).map((item) => item.record));
92
+ }
93
+ /**
94
+ * Retains the record unless the same event is already retained, in which case that record stays:
95
+ * the event is the session, `sourceEventId`, and content, and the earlier `timestamp` wins. The
96
+ * returned record is the one to deliver.
97
+ */
86
98
  export function persistFeedbackOutbox(directory, record) {
87
99
  validateFeedbackIdentity(record.identity);
88
100
  validateFeedbackEnvelope(record.envelope);
@@ -93,9 +105,10 @@ export function persistFeedbackOutbox(directory, record) {
93
105
  const existing = current.find((item) => item.path === join(path, name));
94
106
  const serialized = JSON.stringify(record);
95
107
  if (existing) {
96
- if (canonicalFeedback(existing.record) !== canonicalFeedback(record))
108
+ if (canonicalFeedback(existing.record.identity) !== canonicalFeedback(record.identity) ||
109
+ canonicalFeedbackEvent(existing.record.envelope) !== canonicalFeedbackEvent(record.envelope))
97
110
  throw new Error('feedback source event conflicts with a retained unsent record');
98
- return current.length;
111
+ return existing.record;
99
112
  }
100
113
  const bytes = Buffer.byteLength(serialized);
101
114
  if (bytes > RECORD_MAX_BYTES ||
@@ -128,7 +141,7 @@ export function persistFeedbackOutbox(directory, record) {
128
141
  catch { }
129
142
  throw error;
130
143
  }
131
- return current.length + 1;
144
+ return record;
132
145
  });
133
146
  }
134
147
  export function removeFeedbackOutbox(directory, record) {
@@ -31,6 +31,8 @@ export type FeedbackReceipt = {
31
31
  sessionId: string;
32
32
  sourceEventId: string;
33
33
  createdAt: string;
34
+ /** The `timestamp` of the stored entry, which may be earlier than the attempt that confirmed it. */
35
+ timestamp: string;
34
36
  verified: true;
35
37
  };
36
38
  export type FeedbackDiagnostic = 'blackboard-unavailable' | 'client-unavailable' | 'configuration-invalid' | 'authentication-rejected' | 'identity-conflict' | 'readback-unconfirmed' | 'event-conflict' | 'archived-session' | 'delivery-timeout';
@@ -43,6 +45,8 @@ export type FeedbackDeliveryResult = {
43
45
  } | {
44
46
  status: 'pending';
45
47
  sourceEventId: string;
48
+ /** The `timestamp` of the retained record that a later flush delivers. */
49
+ timestamp: string;
46
50
  pendingCount: number;
47
51
  diagnostic: FeedbackDiagnostic;
48
52
  };
@@ -19,7 +19,7 @@ export type { BlackboardConnection, BlackboardClientModule, BlackboardClientDepe
19
19
  export type * from './feedback-types.mts';
20
20
  export { createFeedbackEnvelope, validateFeedbackEnvelope } from './feedback-codec.mts';
21
21
  export { writeFeedback, verifyFreshFeedback, flushFeedbackOutbox, FeedbackDeliveryError, } from './feedback-delivery.mts';
22
- export { feedbackOutboxStatus } from './feedback-outbox.mts';
22
+ export { feedbackOutboxStatus, readFeedbackOutbox } from './feedback-outbox.mts';
23
23
  export { composeRetrospective } from './feedback-compose.mts';
24
24
  export type { RetrospectiveCompositionInput, FeedbackAssessment } from './feedback-compose.mts';
25
25
  export type { JournalAuditOptions } from './feedback-journal-audit.mts';
@@ -61,5 +61,5 @@ export function formatJournalEntries(sessionId, entries) {
61
61
  export { resolveBlackboardConnection } from './client.mjs';
62
62
  export { createFeedbackEnvelope, validateFeedbackEnvelope } from './feedback-codec.mjs';
63
63
  export { writeFeedback, verifyFreshFeedback, flushFeedbackOutbox, FeedbackDeliveryError, } from './feedback-delivery.mjs';
64
- export { feedbackOutboxStatus } from './feedback-outbox.mjs';
64
+ export { feedbackOutboxStatus, readFeedbackOutbox } from './feedback-outbox.mjs';
65
65
  export { composeRetrospective } from './feedback-compose.mjs';
@@ -8,8 +8,7 @@ type ToolContext = {
8
8
  env: NodeJS.ProcessEnv;
9
9
  dependencies: BlackboardClientDependencies;
10
10
  };
11
- type ToolResult = string | Record<string, unknown>;
12
- export type ToolHandler = (args: Args, context: ToolContext) => Promise<ToolResult>;
11
+ export type ToolHandler = (args: Args, context: ToolContext) => Promise<Record<string, unknown>>;
13
12
  /**
14
13
  * The consumer convention for the interactive outbox. It is derived, never accepted from the
15
14
  * caller, so a credentialed, unsandboxed server cannot be pointed at an arbitrary directory.
@@ -2,7 +2,8 @@ import { join } from 'node:path';
2
2
  import { FeedbackDeliveryError } from '../agent-blackboard/index.mjs';
3
3
  import { optionalString, rejectUnknown, requireObject } from './args.mjs';
4
4
  import { readSessionId } from './context.mjs';
5
- import { journalAppend, journalEntries, outboxFlush, outboxStatus } from './handlers-journal.mjs';
5
+ import { journalAppend, journalEntries } from './handlers-journal.mjs';
6
+ import { outboxFlush, outboxStatus } from './handlers-outbox.mjs';
6
7
  import { sessionArchive, sessionEnsure, snapshotExport } from './handlers-session.mjs';
7
8
  import { TOOLS } from './tools.mjs';
8
9
  import { resolveWorktree } from './worktree.mjs';
@@ -17,14 +18,14 @@ const HANDLERS = {
17
18
  };
18
19
  const DIAGNOSTIC_HINTS = {
19
20
  'identity-conflict': 'the session already exists with a different parent, agent, or version',
20
- 'event-conflict': 'sourceEventId was already used with different content or timestamp; use a new sourceEventId',
21
+ 'event-conflict': 'sourceEventId was already used with different content; use a new sourceEventId',
21
22
  'archived-session': 'the session is archived; write to a new session',
22
23
  'authentication-rejected': 'the blackboard rejected AGENT_BLACKBOARD_TOKEN',
23
24
  'blackboard-unavailable': 'the blackboard could not be reached or returned an error',
24
25
  'configuration-invalid': 'AGENT_BLACKBOARD_URL or AGENT_BLACKBOARD_TOKEN is missing or invalid',
25
26
  'client-unavailable': 'agent-blackboard is not installed in the worktree',
26
- 'readback-unconfirmed': 'the write could not be confirmed; retry with the same sourceEventId and timestamp',
27
- 'delivery-timeout': 'delivery timed out; retry with the same sourceEventId and timestamp',
27
+ 'readback-unconfirmed': 'the write could not be confirmed; retry the same call (same sourceEventId and content)',
28
+ 'delivery-timeout': 'delivery timed out; retry the same call (same sourceEventId and content)',
28
29
  };
29
30
  function describeError(error) {
30
31
  if (error instanceof FeedbackDeliveryError)
@@ -54,14 +55,7 @@ export async function callTool(name, rawArguments, environment) {
54
55
  // The client resolves from the validated worktree, never from the server's own location.
55
56
  dependencies: { ...environment.blackboard, resolveFrom: join(worktree, 'package.json') },
56
57
  });
57
- return {
58
- content: [
59
- {
60
- type: 'text',
61
- text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
62
- },
63
- ],
64
- };
58
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
65
59
  }
66
60
  catch (error) {
67
61
  return { isError: true, content: [{ type: 'text', text: describeError(error) }] };
@@ -4,6 +4,7 @@ export declare const BLACKBOARD_ENV: {
4
4
  AGENT_BLACKBOARD_TOKEN: string;
5
5
  };
6
6
  type StoredEntry = {
7
+ sessionId?: string;
7
8
  createdAt: string;
8
9
  data: unknown;
9
10
  };
@@ -21,6 +22,8 @@ export type FakeOptions = {
21
22
  };
22
23
  entries?: StoredEntry[];
23
24
  entriesError?: unknown;
25
+ /** An entry another caller writes just before the next append, after that append's read. */
26
+ lateEntry?: StoredEntry;
24
27
  };
25
28
  export type FakeBlackboard = {
26
29
  dependencies: BlackboardClientDependencies;
@@ -28,6 +31,7 @@ export type FakeBlackboard = {
28
31
  ensure: unknown[];
29
32
  patch: unknown[];
30
33
  append: unknown[];
34
+ get: unknown[];
31
35
  archive: string[];
32
36
  export: unknown[];
33
37
  };
@@ -15,6 +15,7 @@ export function fakeBlackboard(options = {}) {
15
15
  ensure: [],
16
16
  patch: [],
17
17
  append: [],
18
+ get: [],
18
19
  archive: [],
19
20
  export: [],
20
21
  };
@@ -44,9 +45,13 @@ export function fakeBlackboard(options = {}) {
44
45
  }
45
46
  if (options.omitArchive)
46
47
  delete Sessions.prototype.archive;
48
+ let lateEntry = options.lateEntry;
47
49
  class Entries {
48
50
  async append(input) {
49
51
  calls.append.push(input);
52
+ if (lateEntry)
53
+ stored.push(lateEntry);
54
+ lateEntry = undefined;
50
55
  const entry = {
51
56
  createdAt: `2026-01-01T00:00:${String(stored.length).padStart(2, '0')}.000Z`,
52
57
  data: input.data,
@@ -54,7 +59,8 @@ export function fakeBlackboard(options = {}) {
54
59
  stored.push(entry);
55
60
  return entry;
56
61
  }
57
- async *get() {
62
+ async *get(input) {
63
+ calls.get.push(input);
58
64
  if (options.entriesError !== undefined)
59
65
  throw options.entriesError;
60
66
  yield* stored;
@@ -1,5 +1,4 @@
1
1
  import { type ToolHandler } from './context.mts';
2
2
  export declare const journalAppend: ToolHandler;
3
+ /** Every entry as the client returns it, oldest first. Nothing is filtered or rewritten. */
3
4
  export declare const journalEntries: ToolHandler;
4
- export declare const outboxStatus: ToolHandler;
5
- export declare const outboxFlush: ToolHandler;
@@ -1,6 +1,7 @@
1
- import { appendJournalMarkdown, feedbackOutboxStatus, flushFeedbackOutbox, formatJournalEntries, readJournal, } from '../agent-blackboard/index.mjs';
1
+ import { appendJournalMarkdown, readJournal, } from '../agent-blackboard/index.mjs';
2
2
  import { optionalRecord, optionalString, requiredChoice, requiredString, requiredStringArray, rejectUnknown, } from './args.mjs';
3
3
  import { outboxDirectory, readIdentity } from './context.mjs';
4
+ import { outboxCounts } from './handlers-outbox.mjs';
4
5
  const MODES = ['interactive', 'autonomous'];
5
6
  const OUTCOMES = [
6
7
  'in-progress',
@@ -39,43 +40,51 @@ function readCoverage(args) {
39
40
  export const journalAppend = async (args, context) => {
40
41
  const identity = readIdentity(args, context.sessionId);
41
42
  const mode = requiredChoice(args, 'mode', MODES);
42
- // The envelope includes the timestamp, so a retry of one sourceEventId must reuse it. Fixing the
43
- // default here and returning it lets the caller do that.
44
- const timestamp = optionalString(args, 'timestamp') ?? new Date().toISOString();
43
+ const sourceEventId = requiredString(args, 'sourceEventId');
45
44
  const category = optionalString(args, 'category');
46
- const result = await appendJournalMarkdown({
47
- ...identity,
48
- mode,
49
- // Interactive delivery needs a durable outbox; autonomous delivery forbids one.
50
- ...(mode === 'interactive' ? { outboxDirectory: outboxDirectory(context.worktree) } : {}),
45
+ const entry = {
51
46
  markdown: requiredString(args, 'markdown'),
52
- sourceEventId: requiredString(args, 'sourceEventId'),
53
47
  workOutcome: requiredChoice(args, 'workOutcome', OUTCOMES),
54
48
  repositories: requiredStringArray(args, 'repositories'),
55
49
  feedbackCoverage: readCoverage(args),
56
- timestamp,
50
+ };
51
+ // Interactive delivery needs a durable outbox; autonomous delivery forbids one.
52
+ const directory = mode === 'interactive' ? outboxDirectory(context.worktree) : undefined;
53
+ const result = await appendJournalMarkdown({
54
+ ...identity,
55
+ ...entry,
56
+ mode,
57
+ sourceEventId,
58
+ ...(directory === undefined ? {} : { outboxDirectory: directory }),
57
59
  ...(category === undefined ? {} : { category }),
58
60
  env: context.env,
59
61
  dependencies: context.dependencies,
60
62
  });
61
- return { sessionId: context.sessionId, timestamp, ...result };
63
+ // The server owns the timestamp: a first append takes the current time, and a retry of the same
64
+ // event (same session, sourceEventId, and content) reports the one already stored or retained.
65
+ const timestamp = result.status === 'delivered' ? result.receipt.timestamp : result.timestamp;
66
+ // Autonomous delivery never uses an outbox, so it has nothing pending. Interactive delivery
67
+ // reports the outbox as it is after delivery, split into the caller's session and the worktree.
68
+ const counts = mode === 'interactive'
69
+ ? outboxCounts(context.worktree, context.sessionId)
70
+ : { pendingCount: 0, worktreePendingCount: 0 };
71
+ return { sessionId: context.sessionId, ...result, timestamp, ...counts };
62
72
  };
63
- export const journalEntries = async (_args, context) => formatJournalEntries(context.sessionId, await readJournal(context.sessionId, context.env, context.dependencies));
64
- export const outboxStatus = async (_args, context) => ({
73
+ // `Entries.get` documents no order, and `createdAt` is a service-generated ISO 8601 UTC time whose
74
+ // text order is time order. An entry without a usable `createdAt` sorts first rather than throwing.
75
+ function createdAtOf(entry) {
76
+ const createdAt = entry?.createdAt;
77
+ return typeof createdAt === 'string' ? createdAt : '';
78
+ }
79
+ function byCreatedAt(left, right) {
80
+ const leftAt = createdAtOf(left);
81
+ const rightAt = createdAtOf(right);
82
+ if (leftAt === rightAt)
83
+ return 0;
84
+ return leftAt < rightAt ? -1 : 1;
85
+ }
86
+ /** Every entry as the client returns it, oldest first. Nothing is filtered or rewritten. */
87
+ export const journalEntries = async (_args, context) => ({
65
88
  sessionId: context.sessionId,
66
- ...feedbackOutboxStatus(outboxDirectory(context.worktree)),
89
+ entries: (await readJournal(context.sessionId, context.env, context.dependencies)).toSorted(byCreatedAt),
67
90
  });
68
- export const outboxFlush = async (_args, context) => {
69
- const directory = outboxDirectory(context.worktree);
70
- // Flushing creates the outbox directory, so an empty or absent outbox is reported as is.
71
- if (feedbackOutboxStatus(directory).pendingCount === 0)
72
- return { sessionId: context.sessionId, status: 'empty', pendingCount: 0, deliveredCount: 0 };
73
- return {
74
- sessionId: context.sessionId,
75
- ...(await flushFeedbackOutbox({
76
- directory,
77
- env: context.env,
78
- dependencies: context.dependencies,
79
- })),
80
- };
81
- };
@@ -0,0 +1,11 @@
1
+ import { type ToolHandler } from './context.mts';
2
+ /**
3
+ * The outbox belongs to the worktree, not to one session, so two counts are reported. `pendingCount`
4
+ * is the caller's session only; `worktreePendingCount` is every session's records.
5
+ */
6
+ export declare function outboxCounts(worktree: string, sessionId: string): {
7
+ pendingCount: number;
8
+ worktreePendingCount: number;
9
+ };
10
+ export declare const outboxStatus: ToolHandler;
11
+ export declare const outboxFlush: ToolHandler;
@@ -0,0 +1,45 @@
1
+ import { flushFeedbackOutbox, readFeedbackOutbox } from '../agent-blackboard/index.mjs';
2
+ import { outboxDirectory } from './context.mjs';
3
+ /**
4
+ * The outbox belongs to the worktree, not to one session, so two counts are reported. `pendingCount`
5
+ * is the caller's session only; `worktreePendingCount` is every session's records.
6
+ */
7
+ export function outboxCounts(worktree, sessionId) {
8
+ // A read that never creates the directory, so asking about an empty outbox leaves no trace.
9
+ const records = readFeedbackOutbox(outboxDirectory(worktree));
10
+ return {
11
+ pendingCount: records.filter((record) => record.identity.sessionId === sessionId).length,
12
+ worktreePendingCount: records.length,
13
+ };
14
+ }
15
+ function sessionStatus(pendingCount) {
16
+ return pendingCount === 0 ? 'empty' : 'pending';
17
+ }
18
+ export const outboxStatus = async (_args, context) => {
19
+ const counts = outboxCounts(context.worktree, context.sessionId);
20
+ return { sessionId: context.sessionId, status: sessionStatus(counts.pendingCount), ...counts };
21
+ };
22
+ export const outboxFlush = async (_args, context) => {
23
+ // Flushing creates the outbox directory, so an empty or absent outbox is reported as is.
24
+ if (outboxCounts(context.worktree, context.sessionId).worktreePendingCount === 0)
25
+ return {
26
+ sessionId: context.sessionId,
27
+ status: 'empty',
28
+ pendingCount: 0,
29
+ worktreePendingCount: 0,
30
+ deliveredCount: 0,
31
+ };
32
+ // The flush delivers every session's records; only the counts below are per session.
33
+ const flushed = await flushFeedbackOutbox({
34
+ directory: outboxDirectory(context.worktree),
35
+ env: context.env,
36
+ dependencies: context.dependencies,
37
+ });
38
+ const counts = outboxCounts(context.worktree, context.sessionId);
39
+ return {
40
+ sessionId: context.sessionId,
41
+ ...flushed,
42
+ status: sessionStatus(counts.pendingCount),
43
+ ...counts,
44
+ };
45
+ };
@@ -29,5 +29,10 @@ export declare const JOURNAL_ARGS: {
29
29
  sources: string[];
30
30
  droppedCount: number;
31
31
  };
32
- timestamp: string;
32
+ };
33
+ /** The time `useFixedClock` pins, which is the timestamp the server mints for a first append. */
34
+ export declare const NOW = "2026-01-01T00:00:00.000Z";
35
+ /** Pins `Date` only, so timers and child processes keep running. */
36
+ export declare function useFixedClock(): {
37
+ advance: (milliseconds: number) => void;
33
38
  };
@@ -1,5 +1,5 @@
1
1
  import { join } from 'node:path';
2
- import { afterEach, beforeEach } from 'vitest';
2
+ import { afterEach, beforeEach, vi } from 'vitest';
3
3
  import { callTool } from './dispatch.mjs';
4
4
  import { BLACKBOARD_ENV, fakeBlackboard, } from './fake-blackboard.test-helpers.mjs';
5
5
  import { createRepoFixture } from './git-fixture.test-helpers.mjs';
@@ -44,5 +44,17 @@ export const JOURNAL_ARGS = {
44
44
  workOutcome: 'success',
45
45
  repositories: ['owner/repo'],
46
46
  feedbackCoverage: { status: 'complete', sources: ['tool-result'], droppedCount: 0 },
47
- timestamp: '2026-01-01T00:00:00.000Z',
48
47
  };
48
+ /** The time `useFixedClock` pins, which is the timestamp the server mints for a first append. */
49
+ export const NOW = '2026-01-01T00:00:00.000Z';
50
+ /** Pins `Date` only, so timers and child processes keep running. */
51
+ export function useFixedClock() {
52
+ beforeEach(() => {
53
+ vi.useFakeTimers({ toFake: ['Date'] });
54
+ vi.setSystemTime(new Date(NOW));
55
+ });
56
+ afterEach(() => {
57
+ vi.useRealTimers();
58
+ });
59
+ return { advance: (milliseconds) => vi.setSystemTime(new Date(Date.now() + milliseconds)) };
60
+ }
@@ -3,7 +3,11 @@ import { TOOLS } from './tools.mjs';
3
3
  export const SERVER_NAME = 'vouchington-tooling';
4
4
  const INSTRUCTIONS = 'Journals agent-blackboard feedback for a vouchington consumer. Every tool takes an explicit ' +
5
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.';
6
+ 'repository this server was launched from). Hosts prefix the tool names differently: Claude ' +
7
+ 'Code mcp__vouchington-tooling__<tool>, Codex mcp__vouchington_tooling__<tool>, Grok ' +
8
+ 'vouchington-tooling__<tool> (through search_tool and use_tool), Cursor the vouchington-tooling ' +
9
+ 'namespace (through GetDynamicTools and CallDynamicTool). Search for journal_append before ' +
10
+ 'concluding this server is unavailable. Tool errors are actionable; do not fall back to a CLI.';
7
11
  /**
8
12
  * Builds the server. The tool list and call handlers go on the low-level `server` because the
9
13
  * high-level `registerTool` path needs zod schemas, and this package keeps zod out of its own code.
@@ -59,7 +59,13 @@ const IDENTITY_REQUIRED = ['parentSessionId', 'agent', 'version'];
59
59
  export const TOOLS = [
60
60
  tool('journal_append', 'Appends one journal entry to the caller session and returns the verified read-back receipt. ' +
61
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, {
62
+ 'envelope in the worktree outbox when the blackboard is unreachable. The server owns the ' +
63
+ 'entry timestamp and returns it. An event is identified by sessionId, sourceEventId, and ' +
64
+ 'content, never by timestamp: to retry, repeat the identical call, and a retry of an ' +
65
+ 'event already stored or retained reports the stored timestamp instead of a new one. The ' +
66
+ 'same sourceEventId with different content is an event-conflict. pendingCount counts ' +
67
+ 'unsent records of the caller sessionId; worktreePendingCount counts those of every ' +
68
+ 'session in the worktree outbox (both 0 in autonomous mode, which never uses an outbox).', ADDITIVE, {
63
69
  ...IDENTITY,
64
70
  mode: { enum: ['interactive', 'autonomous'], description: 'Delivery mode.' },
65
71
  markdown: { type: 'string', description: 'Journal note as markdown text (max 12000 bytes).' },
@@ -82,11 +88,6 @@ export const TOOLS = [
82
88
  additionalProperties: false,
83
89
  description: 'Explicit coverage; sources default to [] and droppedCount to 0.',
84
90
  },
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
91
  category: { type: 'string', description: 'Optional context; never changes entry type.' },
91
92
  }, [
92
93
  ...IDENTITY_REQUIRED,
@@ -97,11 +98,14 @@ export const TOOLS = [
97
98
  'repositories',
98
99
  'feedbackCoverage',
99
100
  ]),
100
- tool('journal_entries', 'Returns the journal entries of one session, oldest first, as markdown.', READ_ONLY),
101
- tool('outbox_status', 'Reports how many unsent journal records the worktree outbox retains. sessionId identifies ' +
102
- 'the caller; the count covers the whole outbox.', READ_ONLY),
103
- tool('outbox_flush', 'Delivers retained outbox records and returns the remaining count. sessionId identifies the ' +
104
- 'caller; the flush covers every record in the worktree outbox.', IDEMPOTENT_ADDITIVE),
101
+ tool('journal_entries', 'Returns every entry of one session, oldest first, as JSON: { sessionId, entries }. Each ' +
102
+ 'entry is exactly as the blackboard returns it ({ sessionId, createdAt, data }), including ' +
103
+ 'non-journal types such as retrospective and legacy entries without a feedback envelope.', READ_ONLY),
104
+ tool('outbox_status', 'Reports unsent journal records in the worktree outbox. pendingCount and status (empty or ' +
105
+ 'pending) cover the caller sessionId only; worktreePendingCount covers every session.', READ_ONLY),
106
+ tool('outbox_flush', 'Delivers every retained record in the worktree outbox, whichever session wrote it, and ' +
107
+ 'returns deliveredCount for the whole flush. pendingCount and status cover the remaining ' +
108
+ 'records of the caller sessionId; worktreePendingCount covers every session.', IDEMPOTENT_ADDITIVE),
105
109
  tool('session_ensure', 'Idempotently creates a session, or verifies that an existing one has the same identity. ' +
106
110
  'Returns the session id and whether it was created.', IDEMPOTENT_ADDITIVE, IDENTITY, IDENTITY_REQUIRED),
107
111
  tool('snapshot_export', 'Exports non-archived sessions into a private local JSONL file chosen by the blackboard ' +
@@ -7,7 +7,7 @@ if [ "\${1:-}" = m ]; then
7
7
  exit 0
8
8
  fi
9
9
  if [ "\${1:-}" = --version ]; then
10
- printf '%s\\n' "\${PNPM_VERSION:-11.0.0}"
10
+ printf '%s\\n' "\${PNPM_VERSION:-1.0.0}"
11
11
  exit 0
12
12
  fi
13
13
  printf '%s\\n' "$*" >> "$PNPM_LOG"
@@ -23,7 +23,7 @@ write_modules() {
23
23
  print_release_age_violation() {
24
24
  printf '%s\\n' '✗ Lockfile failed supply-chain policy check (1 entries in 0.1s)'
25
25
  printf '%s\\n' '[ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION] 1 lockfile entries failed verification:'
26
- printf '%s\\n' ' undici@8.10.0 was published at 2026-08-03T15:06:33.000Z, within the minimumReleaseAge cutoff (2026-08-02T04:48:10.357Z)'
26
+ printf '%s\\n' ' fixture-package@1.0.0 was published at 2026-08-03T15:06:33.000Z, within the minimumReleaseAge cutoff (2026-08-02T04:48:10.357Z)'
27
27
  }
28
28
  if [ "\${PNPM_FAIL_CALL:-0}" = "$calls" ]; then
29
29
  if [ -n "\${PNPM_FAIL_RELEASE_AGE:-}" ]; then print_release_age_violation; fi