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.
- package/README.md +4 -1
- package/dist/agent-blackboard/feedback-canonical.d.mts +7 -0
- package/dist/agent-blackboard/feedback-canonical.mjs +8 -0
- package/dist/agent-blackboard/feedback-delivery.mjs +8 -6
- package/dist/agent-blackboard/feedback-online.mjs +11 -4
- package/dist/agent-blackboard/feedback-outbox.d.mts +8 -1
- package/dist/agent-blackboard/feedback-outbox.mjs +17 -4
- package/dist/agent-blackboard/feedback-types.d.mts +4 -0
- package/dist/agent-blackboard/index.d.mts +1 -1
- package/dist/agent-blackboard/index.mjs +1 -1
- package/dist/mcp-server/context.d.mts +1 -2
- package/dist/mcp-server/dispatch.mjs +6 -12
- package/dist/mcp-server/fake-blackboard.test-helpers.d.mts +4 -0
- package/dist/mcp-server/fake-blackboard.test-helpers.mjs +7 -1
- package/dist/mcp-server/handlers-journal.d.mts +1 -2
- package/dist/mcp-server/handlers-journal.mjs +38 -29
- package/dist/mcp-server/handlers-outbox.d.mts +11 -0
- package/dist/mcp-server/handlers-outbox.mjs +45 -0
- package/dist/mcp-server/harness.test-helpers.d.mts +6 -1
- package/dist/mcp-server/harness.test-helpers.mjs +14 -2
- package/dist/mcp-server/server.mjs +5 -1
- package/dist/mcp-server/tools.mjs +15 -11
- package/dist/pnpm-install/pnpm-install-fake-pnpm.test-helpers.mjs +2 -2
- package/docs/mcp-server.md +65 -14
- package/package.json +1 -1
- package/skills/blackboard/SKILL.md +69 -22
- package/skills/manifest.json +27 -21
- package/skills/postgres-node-performance-tuning/references/performance-patterns.md +22 -0
- package/skills/postgres-partitioning-uuid-v7/SKILL.md +3 -0
- package/skills/postgres-schema-design/SKILL.md +21 -0
- package/skills/postgres-schema-design/references/naming.md +34 -0
- package/skills/postgres-schema-design/references/relations.md +46 -0
- package/skills/postgres-schema-design/references/table-shapes.md +56 -0
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
27
|
-
'delivery-timeout': 'delivery timed out; retry
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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).
|
|
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.'
|
|
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
|
|
101
|
-
|
|
102
|
-
'
|
|
103
|
-
tool('
|
|
104
|
-
'
|
|
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:-
|
|
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' '
|
|
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
|