vouchington-tooling 0.23.0 → 0.24.1

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 (61) hide show
  1. package/README.md +76 -4
  2. package/dist/agent-blackboard/client.d.mts +34 -0
  3. package/dist/agent-blackboard/client.mjs +41 -0
  4. package/dist/agent-blackboard/feedback-canonical.d.mts +1 -0
  5. package/dist/agent-blackboard/feedback-canonical.mjs +13 -0
  6. package/dist/agent-blackboard/feedback-codec.d.mts +7 -0
  7. package/dist/agent-blackboard/feedback-codec.mjs +121 -0
  8. package/dist/agent-blackboard/feedback-compose.d.mts +37 -0
  9. package/dist/agent-blackboard/feedback-compose.mjs +109 -0
  10. package/dist/agent-blackboard/feedback-deadline.d.mts +1 -0
  11. package/dist/agent-blackboard/feedback-deadline.mjs +18 -0
  12. package/dist/agent-blackboard/feedback-delivery.d.mts +18 -0
  13. package/dist/agent-blackboard/feedback-delivery.mjs +88 -0
  14. package/dist/agent-blackboard/feedback-fields.d.mts +4 -0
  15. package/dist/agent-blackboard/feedback-fields.mjs +38 -0
  16. package/dist/agent-blackboard/feedback-identity.d.mts +2 -0
  17. package/dist/agent-blackboard/feedback-identity.mjs +29 -0
  18. package/dist/agent-blackboard/feedback-online-error.d.mts +7 -0
  19. package/dist/agent-blackboard/feedback-online-error.mjs +23 -0
  20. package/dist/agent-blackboard/feedback-online.d.mts +2 -0
  21. package/dist/agent-blackboard/feedback-online.mjs +137 -0
  22. package/dist/agent-blackboard/feedback-outbox.d.mts +8 -0
  23. package/dist/agent-blackboard/feedback-outbox.mjs +157 -0
  24. package/dist/agent-blackboard/feedback-types.d.mts +66 -0
  25. package/dist/agent-blackboard/feedback-types.mjs +1 -0
  26. package/dist/agent-blackboard/index.d.mts +16 -39
  27. package/dist/agent-blackboard/index.mjs +35 -81
  28. package/dist/cli/commands/agent-blackboard.mjs +35 -4
  29. package/dist/cli/usage.d.mts +1 -1
  30. package/dist/cli/usage.mjs +3 -1
  31. package/dist/retrospective-facts/coverage.d.mts +6 -0
  32. package/dist/retrospective-facts/coverage.mjs +42 -0
  33. package/dist/retrospective-facts/index.d.mts +3 -0
  34. package/dist/retrospective-facts/index.mjs +18 -1
  35. package/dist/retrospective-transcript/codex.mjs +8 -3
  36. package/dist/retrospective-transcript/index.d.mts +5 -0
  37. package/dist/retrospective-transcript/index.mjs +29 -9
  38. package/dist/session-friction/classify.mjs +25 -14
  39. package/dist/session-friction/coverage-ledger.d.mts +10 -0
  40. package/dist/session-friction/coverage-ledger.mjs +75 -0
  41. package/dist/session-friction/index.d.mts +1 -1
  42. package/dist/session-friction/log-file.d.mts +2 -2
  43. package/dist/session-friction/log-file.mjs +22 -10
  44. package/dist/session-friction/log.mjs +57 -10
  45. package/dist/session-friction/report.mjs +36 -7
  46. package/dist/session-friction/sandbox.mjs +11 -4
  47. package/dist/session-friction/types.d.mts +25 -4
  48. package/dist/workflow-policy/index.d.mts +1 -1
  49. package/dist/workflow-policy/index.mjs +1 -1
  50. package/dist/workflow-policy/permission-declarations.d.mts +14 -0
  51. package/dist/workflow-policy/permission-declarations.mjs +55 -0
  52. package/dist/workflow-policy/permission-inheritance.d.mts +8 -0
  53. package/dist/workflow-policy/permission-inheritance.mjs +88 -0
  54. package/dist/workflow-policy/permission-mismatches.d.mts +4 -1
  55. package/dist/workflow-policy/permission-mismatches.mjs +14 -3
  56. package/docs/workflow-and-compiler.md +18 -1
  57. package/package.json +1 -1
  58. package/skills/blackboard/SKILL.md +34 -14
  59. package/skills/retrospective/SKILL.md +27 -16
  60. package/skills/retrospective-distill/SKILL.md +15 -6
  61. package/skills/stacked-prs/SKILL.md +15 -1
package/README.md CHANGED
@@ -66,7 +66,7 @@ vouchington wait-for-apt-locks
66
66
  vouchington retrospective-transcript --jsonl /path/to/transcript.jsonl
67
67
  vouchington retrospective-facts --pr 49 --repo vouchington/vouchington-infra --raw
68
68
  vouchington agent-blackboard probe
69
- vouchington agent-blackboard journal append --session-id <uuid> --agent codex --version 1 --file note.md --repository vouchington/vouchington-tooling
69
+ vouchington agent-blackboard journal append --session-id <uuid> --agent codex --version 1 --file note.md --repository owner/repo --mode autonomous --source-event-id event:1 --work-outcome in-progress --coverage-status partial --timestamp 2026-01-01T00:00:00.000Z
70
70
  vouchington agent-blackboard journal entries --session-id <uuid>
71
71
  vouchington agent-blackboard snapshot partition --snapshot <snapshot.jsonl> --checksum <sha256> --counts <counts.json>
72
72
  vouchington agent-blackboard snapshot cleanup --snapshot <snapshot.jsonl> --partition-directory <partitions-dir> --receipt <receipt-json>
@@ -147,7 +147,10 @@ as not merged to main, and a missing base is unavailable.
147
147
 
148
148
  Agent Blackboard support is optional: only the `agent-blackboard` subpath and its CLI commands
149
149
  need `agent-blackboard@^0.6.0`. Snapshot cleanup accepts only package-generated temporary paths.
150
- `appendJournal` requires a `repositories: string[]` argument. The CLI accepts one or more
150
+ `appendJournal` and the journal CLI now require explicit mode, stable source-event identity, work
151
+ outcome, feedback coverage, and exact repositories. This is a breaking pre-1.0 API change; adopt the
152
+ shared contract before upgrading consumers. `appendJournal` reads a bounded UTF-8 note and delegates
153
+ to `writeFeedback`, returning delivered or pending state rather than a success-shaped message. The CLI accepts one or more
151
154
  `--repository owner/name` flags; it records the entry's exact repositories and updates the session's
152
155
  cumulative repository list before appending.
153
156
  Programmatic callers launched from a different workspace directory pass their own module URL as
@@ -405,13 +408,19 @@ absolute log directory, host-independent observation, and journal loader; it doe
405
408
  environment variables, install hooks, or connect to a journal service by itself. Invoking
406
409
  `recordFriction` touches the session log even when no event is classified, preserving the
407
410
  difference between an observed clean session and missing evidence. Report markdown keeps backend
408
- diagnostics separate from its paste-safe output. Capture stores at most 500 events per session,
411
+ diagnostics separate from its paste-safe output. Capture stores at most 500 events per session and counts rejected captures in a fixed-size atomic
412
+ sidecar under the same session lock for either the event or byte limit, with visible `truncated`/`droppedCount` metadata. Saturated
413
+ capture reads only the bounded counter; reports also preserve historical overflow markers.
414
+ Interrupted or inconsistent counter updates make coverage unavailable. Malformed records also make the scan partial. It
409
415
  truncates event detail to 1,000 characters, and consumes up to 500 entries from the journal loader
410
416
  when building a report, stopping earlier when its aggregate 1 MB inspected-byte budget is reached.
411
417
  Bounded journal scans that stop before exhaustion are reported as incomplete rather than clean.
412
418
  Report liveness inherits the caller-supplied journal loader, which must bound its own I/O and yields.
413
419
  Log reads are capped at 2 MB, journal Markdown at 10,000 bytes per entry,
414
- and rendered audit fields at 120 escaped characters. The supplied log directory must be dedicated
420
+ and rendered audit fields at 120 escaped characters. Permission requests record requested outcomes; explicit result observations may record approved
421
+ or denied outcomes. A decision and simultaneous tool failure are both retained and counted in
422
+ the report. Escalation detail without a decision remains unknown. Localhost connection
423
+ refusal is an ambiguous failure and does not establish a sandbox cause. The supplied log directory must be dedicated
415
424
  to session-friction; existing directories must already be owner-only, while newly created
416
425
  directories and log files are enforced as owner-only when recording. Reads use a fixed bounded
417
426
  buffer that can detect growth one byte beyond the documented 2 MB cap.
@@ -506,3 +515,66 @@ links declared prerequisites. Without a manifest, it links the conventional
506
515
  outside either root, and existing non-matching destinations.
507
516
  `targetRoot` must already exist as a physical directory path: symlinked target roots or ancestors are
508
517
  rejected.
518
+
519
+ ## Validated feedback and delivery
520
+
521
+ Import `createFeedbackEnvelope`, `validateFeedbackEnvelope`, `writeFeedback`, `verifyFreshFeedback`,
522
+ `composeRetrospective`, `feedbackOutboxStatus`, and `flushFeedbackOutbox` from the existing
523
+ `vouchington-tooling/agent-blackboard` subpath. Consumers must run Node 24 or newer and install `agent-blackboard@^0.6.0` explicitly alongside
524
+ `vouchington-tooling`; the provider is not installed by this package. Missing provider installation
525
+ returns `client-unavailable`; missing or invalid connection configuration returns
526
+ `configuration-invalid`, separately from transport outages and authentication rejection.
527
+ The upstream `agent-blackboard` client still owns the
528
+ service protocol; this integration adds no provider schema or service. Supply consumer module
529
+ context through `dependencies.resolveFrom` or inject the client loader at the transport boundary.
530
+
531
+ The version-one envelope retains canonical `markdown` and storage `type: journal | retrospective`.
532
+ It requires exact canonical repositories, stable URL-safe `sourceEventId`, ISO timestamp, explicit
533
+ `workOutcome`, and `feedbackCoverage` with status, sources, and dropped count. Optional category is
534
+ context only. Retrospectives retain valid date and integer/string issue/PR references. Markdown is
535
+ capped at 12 KB and the envelope at 16 KB. Known credentials are redacted; authors remain responsible
536
+ for minimizing summaries and excluding raw logs, transcripts, environment dumps, and undisclosed
537
+ secrets. A bound violation fails visibly without clipping findings.
538
+
539
+ `writeFeedback({identity, envelope, mode, ...})` accepts an exact caller-owned session identity and
540
+ never manufactures native agent identities. Interactive mode requires an absolute private outbox
541
+ directory. The writer persists before transport with atomic rename, file/directory fsync, a stable
542
+ record path, and owner-only permissions. The outbox holds at most 128 records and 2 MB. Saturation,
543
+ corruption, or persistence failure blocks capture and preserves unsent records. Delivered records
544
+ are removed only after matching readback. Pending state includes count and a safe diagnostic;
545
+ interactive CLI exit zero means durable retention, and callers must inspect the printed status to
546
+ know whether delivery was acknowledged. Autonomous errors and unpersisted writes fail nonzero.
547
+ Identity, content, and archived-session conflicts fail in either mode while preserving the unsent
548
+ record; they are not reported as transient pending delivery. Replay retains permanently conflicted
549
+ records and continues to deliver unrelated pending records; transient failures stop that replay.
550
+ Verified delivery retains its receipt when local cleanup fails, returning
551
+ `cleanupDiagnostic` with value `"outbox-cleanup-failed"`. A retained or crash-reappearing record is safe to replay with exact dedup.
552
+ Use `vouchington agent-blackboard journal flush --outbox-directory PATH` to replay unchanged pending
553
+ records after connectivity returns.
554
+
555
+ Autonomous mode prohibits the filesystem outbox. `verifyFreshFeedback` requires a fresh source-event
556
+ write plus readback of its own append receipt and rejects visible duplicate source records. This
557
+ is fresh online reporting evidence, not a distributed execution lease: eventually consistent
558
+ provider reads cannot establish source uniqueness. Trusted controllers
559
+ supply a unique new authorization-probe source ID, preserve their authoritative run/session
560
+ lineage, and atomically claim and revalidate the attempt before execution. Terminal writes keep work outcome separate from coverage and returned delivery state.
561
+ The delivered result includes a verified receipt with session ID, source-event ID and creation time.
562
+ Controllers require this online result together with atomically established attempt ownership
563
+ before execution.
564
+
565
+ Delivery is at least once. Replays pre-read matching source identity/content, accept equivalent
566
+ persistent duplicates, and reject conflicting reuse. Equality ignores object property order while
567
+ preserving array order. Each online operation has a 20-second deadline (`timeoutMs` can shorten it);
568
+ readback scans at most 10,000 records or 2 MB. The SDK does not expose cancellation for in-flight
569
+ requests, so a timed-out write can commit later. That never grants admission or a delivery receipt;
570
+ replay the identical envelope and source ID to verify its actual persistence.
571
+
572
+ `composeRetrospective` invokes the existing fact/transcript collectors with raw output disabled and
573
+ generates required sections and markers. Tool and architectural assessments preserve observation,
574
+ evidence, disposition and tracking reference. None-observed and unassessed states need a scoped
575
+ reason. Complete coverage is rejected when required sources are unavailable or capture is partial.
576
+ Typed collector reports expose availability independently of rendered text. Composition rejects
577
+ aggregate dropped counts below observed friction drops and retains larger caller aggregates without
578
+ double counting. Generated frontmatter carries validated `work_outcome` and `feedback_coverage`
579
+ for consumers to preserve or reject conflicting explicit overrides; trusted mode stays external.
580
+ Human top-five summaries do not authorize dropping additional durable findings.
@@ -0,0 +1,34 @@
1
+ export type BlackboardConnection = {
2
+ baseUrl: string;
3
+ token: string;
4
+ readRetry: Record<string, never>;
5
+ };
6
+ export type BlackboardClientModule = {
7
+ Sessions: new (connection: BlackboardConnection) => {
8
+ ensure(input: unknown): Promise<{
9
+ status: 'created' | 'exists';
10
+ session: {
11
+ data: Record<string, unknown>;
12
+ archivedAt?: string | null;
13
+ };
14
+ }>;
15
+ patch(input: unknown): Promise<unknown>;
16
+ list(input: unknown): Promise<unknown>;
17
+ get(id: string): Promise<unknown>;
18
+ };
19
+ Entries: new (connection: BlackboardConnection) => {
20
+ append(input: unknown): Promise<{
21
+ createdAt: string;
22
+ data: unknown;
23
+ }>;
24
+ get(input: unknown): AsyncIterable<unknown>;
25
+ };
26
+ };
27
+ type BlackboardClientLoader = () => Promise<BlackboardClientModule>;
28
+ export type BlackboardClientDependencies = {
29
+ loadClient?: BlackboardClientLoader;
30
+ resolveFrom?: string | URL;
31
+ };
32
+ export declare function resolveBlackboardConnection(env?: NodeJS.ProcessEnv): BlackboardConnection;
33
+ export declare function loadClient(dependencies?: BlackboardClientDependencies): Promise<BlackboardClientModule>;
34
+ export {};
@@ -0,0 +1,41 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import { createRequire } from 'node:module';
10
+ import { resolve } from 'node:path';
11
+ import { pathToFileURL } from 'node:url';
12
+ export function resolveBlackboardConnection(env = process.env) {
13
+ const baseUrl = env.AGENT_BLACKBOARD_URL;
14
+ const token = env.AGENT_BLACKBOARD_TOKEN;
15
+ if (!baseUrl)
16
+ throw new Error('AGENT_BLACKBOARD_URL is not set');
17
+ if (!token)
18
+ throw new Error('AGENT_BLACKBOARD_TOKEN is not set');
19
+ return { baseUrl, token, readRetry: {} };
20
+ }
21
+ export async function loadClient(dependencies = {}) {
22
+ const loader = dependencies.loadClient ?? (() => defaultClientLoader(dependencies.resolveFrom));
23
+ try {
24
+ return await loader();
25
+ }
26
+ catch (error) {
27
+ if (isMissingModuleError(error))
28
+ throw new Error('agent-blackboard is not installed; install it alongside vouchington-tooling to use this integration', { cause: error });
29
+ throw error;
30
+ }
31
+ }
32
+ async function defaultClientLoader(resolveFrom) {
33
+ const consumerRequire = createRequire(resolveFrom ?? resolve(process.cwd(), 'package.json'));
34
+ const specifier = pathToFileURL(consumerRequire.resolve('agent-blackboard')).href;
35
+ return (await import(__rewriteRelativeImportExtension(specifier)));
36
+ }
37
+ function isMissingModuleError(error) {
38
+ return (error instanceof Error &&
39
+ 'code' in error &&
40
+ (error.code === 'ERR_MODULE_NOT_FOUND' || error.code === 'MODULE_NOT_FOUND'));
41
+ }
@@ -0,0 +1 @@
1
+ export declare function canonicalFeedback(value: unknown): string;
@@ -0,0 +1,13 @@
1
+ import { isObject } from './snapshot-partition-guards.mjs';
2
+ export function canonicalFeedback(value) {
3
+ return JSON.stringify(ordered(value));
4
+ }
5
+ function ordered(value) {
6
+ if (Array.isArray(value))
7
+ return value.map(ordered);
8
+ if (!isObject(value))
9
+ return value;
10
+ return Object.fromEntries(Object.keys(value)
11
+ .sort()
12
+ .map((key) => [key, ordered(value[key])]));
13
+ }
@@ -0,0 +1,7 @@
1
+ export { normalizeRepositories } from './feedback-fields.mts';
2
+ import type { FeedbackEnvelope } from './feedback-types.mts';
3
+ export declare function validateFeedbackEnvelope(value: unknown): asserts value is FeedbackEnvelope;
4
+ export declare function redactFeedbackText(value: string, knownSensitiveValues?: string[]): string;
5
+ export declare function createFeedbackEnvelope(input: FeedbackEnvelope, options?: {
6
+ knownSensitiveValues?: string[];
7
+ }): FeedbackEnvelope;
@@ -0,0 +1,121 @@
1
+ import { normalizeRepositories, validateFeedbackText, validateFeedbackReferences, } from './feedback-fields.mjs';
2
+ export { normalizeRepositories } from './feedback-fields.mjs';
3
+ import { isCount, isObject } from './snapshot-partition-guards.mjs';
4
+ const FEEDBACK_MAX_BYTES = 16_384;
5
+ const OUTCOMES = [
6
+ 'in-progress',
7
+ 'success',
8
+ 'failure',
9
+ 'cancelled',
10
+ 'timed-out',
11
+ 'no-change',
12
+ 'policy-refusal',
13
+ 'unknown',
14
+ ];
15
+ const COVERAGE = ['complete', 'partial', 'unavailable', 'not-assessed', 'not-started'];
16
+ const FIELDS = [
17
+ 'schemaVersion',
18
+ 'type',
19
+ 'sourceEventId',
20
+ 'timestamp',
21
+ 'repositories',
22
+ 'markdown',
23
+ 'workOutcome',
24
+ 'feedbackCoverage',
25
+ 'category',
26
+ 'date',
27
+ 'issues',
28
+ 'prs',
29
+ ];
30
+ export function validateFeedbackEnvelope(value) {
31
+ if (!isObject(value) ||
32
+ value.schemaVersion !== 1 ||
33
+ typeof value.type !== 'string' ||
34
+ !['journal', 'retrospective'].includes(value.type))
35
+ throw new Error('invalid feedback storage type or schemaVersion');
36
+ if (Object.keys(value).some((key) => !FIELDS.includes(key)))
37
+ throw new Error('unsupported feedback field');
38
+ validateFeedbackText(value.sourceEventId, 'sourceEventId', 256);
39
+ if (!/^[A-Za-z0-9._:-]+$/.test(value.sourceEventId))
40
+ throw new Error('sourceEventId must be URL-safe');
41
+ validateFeedbackText(value.timestamp, 'timestamp', 40);
42
+ if (!Number.isFinite(Date.parse(value.timestamp)) ||
43
+ new Date(value.timestamp).toISOString() !== value.timestamp)
44
+ throw new Error('timestamp must be a canonical ISO date-time');
45
+ if (JSON.stringify(value.repositories) !== JSON.stringify(normalizeRepositories(value.repositories)))
46
+ throw new Error('repositories must be canonical');
47
+ validateFeedbackText(value.markdown, 'markdown', 12_000);
48
+ if (typeof value.workOutcome !== 'string' || !OUTCOMES.includes(value.workOutcome))
49
+ throw new Error('workOutcome must be explicit');
50
+ if (!isObject(value.feedbackCoverage) ||
51
+ typeof value.feedbackCoverage.status !== 'string' ||
52
+ !COVERAGE.includes(value.feedbackCoverage.status) ||
53
+ !isCount(value.feedbackCoverage.droppedCount))
54
+ throw new Error('feedbackCoverage must be explicit');
55
+ if (Object.keys(value.feedbackCoverage).some((key) => !['status', 'sources', 'droppedCount'].includes(key)))
56
+ throw new Error('unsupported feedbackCoverage field');
57
+ if (!Array.isArray(value.feedbackCoverage.sources) || value.feedbackCoverage.sources.length > 32)
58
+ throw new Error('coverage sources must be bounded');
59
+ for (const source of value.feedbackCoverage.sources)
60
+ validateFeedbackText(source, 'coverage source', 160);
61
+ if (value.feedbackCoverage.status === 'complete' && value.feedbackCoverage.droppedCount !== 0)
62
+ throw new Error('complete coverage cannot have dropped records');
63
+ if (value.category !== undefined)
64
+ validateFeedbackText(value.category, 'category', 160);
65
+ if (value.type === 'retrospective' || value.date !== undefined) {
66
+ if (typeof value.date !== 'string' ||
67
+ !/^\d{4}-\d{2}-\d{2}$/.test(value.date) ||
68
+ !Number.isFinite(Date.parse(value.date)) ||
69
+ new Date(value.date).toISOString().slice(0, 10) !== value.date)
70
+ throw new Error('date must be a valid YYYY-MM-DD');
71
+ }
72
+ if (value.type === 'retrospective' || value.issues !== undefined)
73
+ validateFeedbackReferences(value.issues, 'issues');
74
+ if (value.type === 'retrospective' || value.prs !== undefined)
75
+ validateFeedbackReferences(value.prs, 'prs');
76
+ if (Buffer.byteLength(JSON.stringify(value)) > FEEDBACK_MAX_BYTES)
77
+ throw new Error('feedback envelope is too large');
78
+ }
79
+ export function redactFeedbackText(value, knownSensitiveValues = []) {
80
+ let result = value;
81
+ for (const secret of knownSensitiveValues)
82
+ if (secret.length >= 4)
83
+ result = result.split(secret).join('[REDACTED]');
84
+ return result
85
+ .replace(/\b(?:abb_sk_|gh[pousr]_|github_pat_|sk-(?:proj-)?)?[A-Za-z0-9_-]{30,}\b/g, (match) => /^(?:abb_sk_|gh[pousr]_|github_pat_|sk-)/.test(match) ? '[REDACTED]' : match)
86
+ .replace(/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, '[REDACTED]')
87
+ .replace(/(Bearer\s+)[^\s]+/gi, '$1[REDACTED]')
88
+ .replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+:[^\s/@]+@/gi, '$1[REDACTED]@')
89
+ .replace(/((?:password|secret|token|api[_-]?key)\s*[=:]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi, '$1[REDACTED]');
90
+ }
91
+ export function createFeedbackEnvelope(input, options = {}) {
92
+ validateFeedbackText(input.markdown, 'markdown', 12_000);
93
+ validateFeedbackText(input.timestamp, 'timestamp', 40);
94
+ const envelope = {
95
+ ...input,
96
+ repositories: normalizeRepositories(input.repositories),
97
+ markdown: redactFeedbackText(input.markdown, options.knownSensitiveValues),
98
+ timestamp: Number.isFinite(Date.parse(input.timestamp))
99
+ ? new Date(input.timestamp).toISOString()
100
+ : input.timestamp,
101
+ feedbackCoverage: {
102
+ ...input.feedbackCoverage,
103
+ sources: input.feedbackCoverage.sources.map((source) => redactFeedbackText(source, options.knownSensitiveValues)),
104
+ },
105
+ ...(input.category === undefined
106
+ ? {}
107
+ : { category: redactFeedbackText(input.category, options.knownSensitiveValues) }),
108
+ ...(input.issues === undefined
109
+ ? {}
110
+ : {
111
+ issues: input.issues.map((ref) => typeof ref === 'string' ? redactFeedbackText(ref, options.knownSensitiveValues) : ref),
112
+ }),
113
+ ...(input.prs === undefined
114
+ ? {}
115
+ : {
116
+ prs: input.prs.map((ref) => typeof ref === 'string' ? redactFeedbackText(ref, options.knownSensitiveValues) : ref),
117
+ }),
118
+ };
119
+ validateFeedbackEnvelope(envelope);
120
+ return envelope;
121
+ }
@@ -0,0 +1,37 @@
1
+ import { type RetrospectiveFactsOptions } from '../retrospective-facts/index.mts';
2
+ import { type ResolveOptions } from '../retrospective-transcript/index.mts';
3
+ import { type SessionFrictionReportOptions } from '../session-friction/index.mts';
4
+ import type { FeedbackCoverage, FeedbackReference, WorkOutcome } from './feedback-types.mts';
5
+ export type FeedbackAssessment = {
6
+ status: 'findings' | 'none-observed' | 'not-assessed' | 'unavailable';
7
+ findings?: Array<{
8
+ observation: string;
9
+ evidence: string;
10
+ disposition: string;
11
+ trackingReference?: string;
12
+ }>;
13
+ reason?: string;
14
+ };
15
+ type Unassessed = {
16
+ status: 'unavailable' | 'not-assessed';
17
+ reason: string;
18
+ };
19
+ export type RetrospectiveCompositionInput = {
20
+ sessionId: string;
21
+ date: string;
22
+ issues: FeedbackReference[];
23
+ prs: FeedbackReference[];
24
+ description: string;
25
+ repositories: string[];
26
+ workOutcome: WorkOutcome;
27
+ feedbackCoverage: FeedbackCoverage;
28
+ narrative: string;
29
+ facts: Omit<RetrospectiveFactsOptions, 'raw'> | Unassessed;
30
+ transcript: ResolveOptions | Unassessed;
31
+ friction?: SessionFrictionReportOptions;
32
+ tools: FeedbackAssessment;
33
+ architecture: FeedbackAssessment;
34
+ knownSensitiveValues?: string[];
35
+ };
36
+ export declare function composeRetrospective(input: RetrospectiveCompositionInput): Promise<string>;
37
+ export {};
@@ -0,0 +1,109 @@
1
+ import { runRetrospectiveFactsReport, } from '../retrospective-facts/index.mjs';
2
+ import { runRetrospectiveTranscriptReport, } from '../retrospective-transcript/index.mjs';
3
+ import { buildSessionFrictionReport, } from '../session-friction/index.mjs';
4
+ import { createFeedbackEnvelope, redactFeedbackText } from './feedback-codec.mjs';
5
+ import { validateFeedbackText } from './feedback-fields.mjs';
6
+ function assessment(title, value) {
7
+ if (!['findings', 'none-observed', 'not-assessed', 'unavailable'].includes(value.status))
8
+ throw new Error('assessment status must be explicit');
9
+ if (value.status !== 'findings' &&
10
+ (typeof value.reason !== 'string' ||
11
+ !value.reason.trim() ||
12
+ Buffer.byteLength(value.reason) > 240))
13
+ throw new Error('assessment reason and inspected scope must be explicit and bounded');
14
+ const findings = value.findings ?? [];
15
+ if (findings.length > 20 || (value.status === 'findings') !== Boolean(findings.length))
16
+ throw new Error('assessment findings must match status and remain bounded');
17
+ const lines = findings.map((finding) => {
18
+ for (const field of [finding.observation, finding.evidence, finding.disposition])
19
+ if (typeof field !== 'string' || !field.trim() || Buffer.byteLength(field) > 1000)
20
+ throw new Error('assessment finding must contain bounded observation, evidence and disposition');
21
+ if (finding.trackingReference !== undefined)
22
+ validateFeedbackText(finding.trackingReference, 'assessment tracking reference', 240);
23
+ return `- ${finding.observation}\n - Evidence: ${finding.evidence}\n - Disposition: ${finding.disposition}${finding.trackingReference ? `\n - Tracking: ${finding.trackingReference}` : ''}`;
24
+ });
25
+ return `## ${title}\nStatus: ${value.status.replaceAll('-', ' ')}${value.reason ? ` (${value.reason})` : ''}\n${lines.join('\n')}`;
26
+ }
27
+ function unavailable(marker, input) {
28
+ if (input.status !== 'unavailable' && input.status !== 'not-assessed')
29
+ throw new Error('unassessed status must be unavailable or not-assessed');
30
+ if (!input.reason.trim() || Buffer.byteLength(input.reason) > 240)
31
+ throw new Error('unavailable reason must be bounded and explicit');
32
+ return `${marker}\nStatus: ${input.status.replaceAll('-', ' ')} (${input.reason})`;
33
+ }
34
+ export async function composeRetrospective(input) {
35
+ const unavailableFacts = 'status' in input.facts
36
+ ? {
37
+ markdown: unavailable('=== Retrospective Facts ===', input.facts),
38
+ coverage: input.facts.status,
39
+ }
40
+ : undefined;
41
+ const unavailableTranscript = 'status' in input.transcript
42
+ ? {
43
+ markdown: unavailable('=== Transcript Facts ===', input.transcript),
44
+ coverage: input.transcript.status,
45
+ }
46
+ : undefined;
47
+ const [facts, transcript, friction] = await Promise.all([
48
+ 'status' in input.facts
49
+ ? Promise.resolve(unavailableFacts)
50
+ : runRetrospectiveFactsReport({ ...input.facts, raw: false }),
51
+ 'status' in input.transcript
52
+ ? Promise.resolve(unavailableTranscript)
53
+ : runRetrospectiveTranscriptReport(input.transcript),
54
+ input.friction
55
+ ? buildSessionFrictionReport(input.sessionId, input.friction)
56
+ : Promise.resolve({
57
+ coverage: {
58
+ journalStatus: 'unavailable',
59
+ frictionStatus: 'absent',
60
+ truncated: false,
61
+ },
62
+ markdown: '## CI Failures\nStatus: unavailable (not assessed)\n\n## Sandbox & Permission Audit\nStatus: unavailable (not assessed)',
63
+ }),
64
+ ]);
65
+ if (input.feedbackCoverage.status === 'complete' &&
66
+ (facts.coverage !== 'complete' ||
67
+ transcript.coverage !== 'complete' ||
68
+ ['not-assessed', 'unavailable'].includes(input.tools.status) ||
69
+ ['not-assessed', 'unavailable'].includes(input.architecture.status) ||
70
+ friction.coverage.journalStatus !== 'complete' ||
71
+ !['empty', 'events'].includes(friction.coverage.frictionStatus) ||
72
+ friction.coverage.truncated))
73
+ throw new Error('complete feedback coverage requires assessed available factual and finding sources');
74
+ if ((friction.coverage.droppedCount ?? 0) > input.feedbackCoverage.droppedCount)
75
+ throw new Error('feedback coverage dropped count must include observed friction drops');
76
+ const markdown = [
77
+ '---',
78
+ `date: ${JSON.stringify(input.date)}`,
79
+ `issues: ${JSON.stringify(input.issues)}`,
80
+ `prs: ${JSON.stringify(input.prs)}`,
81
+ `session_id: ${JSON.stringify(input.sessionId)}`,
82
+ `description: ${JSON.stringify(input.description)}`,
83
+ `work_outcome: ${JSON.stringify(input.workOutcome)}`,
84
+ `feedback_coverage: ${JSON.stringify(input.feedbackCoverage)}`,
85
+ '---',
86
+ '',
87
+ input.narrative,
88
+ `## Outcome\nWork outcome: ${input.workOutcome}\nFeedback coverage: ${input.feedbackCoverage.status}\nDropped records: ${input.feedbackCoverage.droppedCount}`,
89
+ `## Verifiable Facts\n${facts.markdown.trim()}`,
90
+ `## Transcript Facts\n${transcript.markdown.trim()}`,
91
+ friction.markdown,
92
+ assessment('Tool Findings', input.tools),
93
+ assessment('Architecture Findings', input.architecture),
94
+ ].join('\n\n');
95
+ const envelope = createFeedbackEnvelope({
96
+ schemaVersion: 1,
97
+ type: 'retrospective',
98
+ sourceEventId: 'composition-validation',
99
+ timestamp: `${input.date}T00:00:00.000Z`,
100
+ date: input.date,
101
+ issues: input.issues,
102
+ prs: input.prs,
103
+ repositories: input.repositories,
104
+ markdown: redactFeedbackText(markdown, input.knownSensitiveValues),
105
+ workOutcome: input.workOutcome,
106
+ feedbackCoverage: input.feedbackCoverage,
107
+ });
108
+ return envelope.markdown;
109
+ }
@@ -0,0 +1 @@
1
+ export declare function feedbackDeadline<T>(operation: () => Promise<T>, timeoutMs?: number): Promise<T>;
@@ -0,0 +1,18 @@
1
+ import { FeedbackDeliveryError } from './feedback-online-error.mjs';
2
+ export async function feedbackDeadline(operation, timeoutMs = 20_000) {
3
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 20_000)
4
+ throw new Error('feedback timeoutMs must be between 1 and 20000');
5
+ let timer;
6
+ try {
7
+ return await Promise.race([
8
+ operation(),
9
+ new Promise((_, reject) => {
10
+ timer = setTimeout(() => reject(new FeedbackDeliveryError('delivery-timeout')), timeoutMs);
11
+ }),
12
+ ]);
13
+ }
14
+ finally {
15
+ if (timer)
16
+ clearTimeout(timer);
17
+ }
18
+ }
@@ -0,0 +1,18 @@
1
+ import type { BlackboardClientDependencies } from './client.mts';
2
+ import type { FeedbackDeliveryOptions, FeedbackDeliveryResult, FeedbackDiagnostic, FeedbackOnlineOptions } from './feedback-types.mts';
3
+ export { FeedbackDeliveryError } from './feedback-online-error.mts';
4
+ export declare function writeFeedback(input: FeedbackDeliveryOptions): Promise<FeedbackDeliveryResult>;
5
+ export declare function verifyFreshFeedback(input: FeedbackOnlineOptions): Promise<Extract<FeedbackDeliveryResult, {
6
+ status: 'delivered';
7
+ }>>;
8
+ export declare function flushFeedbackOutbox(input: {
9
+ directory: string;
10
+ env?: NodeJS.ProcessEnv;
11
+ dependencies?: BlackboardClientDependencies;
12
+ }): Promise<{
13
+ status: 'empty' | 'pending';
14
+ pendingCount: number;
15
+ deliveredCount: number;
16
+ diagnostic?: FeedbackDiagnostic;
17
+ cleanupDiagnostic?: 'outbox-cleanup-failed';
18
+ }>;
@@ -0,0 +1,88 @@
1
+ import { createFeedbackEnvelope, validateFeedbackEnvelope } from './feedback-codec.mjs';
2
+ import { validateFeedbackIdentity } from './feedback-identity.mjs';
3
+ import { deliverFeedbackOnline } from './feedback-online.mjs';
4
+ import { FeedbackDeliveryError, feedbackDiagnostic } from './feedback-online-error.mjs';
5
+ import { feedbackOutboxStatus, listFeedbackOutbox, persistFeedbackOutbox, removeFeedbackOutbox, } from './feedback-outbox.mjs';
6
+ export { FeedbackDeliveryError } from './feedback-online-error.mjs';
7
+ export async function writeFeedback(input) {
8
+ if (input.mode !== 'interactive' && input.mode !== 'autonomous')
9
+ throw new Error('feedback mode must be explicitly interactive or autonomous');
10
+ validateFeedbackIdentity(input.identity);
11
+ validateFeedbackEnvelope(input.envelope);
12
+ const envelope = prepareEnvelope(input);
13
+ if (input.mode === 'autonomous') {
14
+ if (input.outboxDirectory !== undefined)
15
+ throw new Error('autonomous feedback cannot use a filesystem outbox');
16
+ const receipt = await deliverFeedbackOnline({ ...input, envelope });
17
+ return { status: 'delivered', sourceEventId: envelope.sourceEventId, pendingCount: 0, receipt };
18
+ }
19
+ if (!input.outboxDirectory)
20
+ throw new Error('interactive feedback requires an explicit durable outbox directory');
21
+ persistFeedbackOutbox(input.outboxDirectory, { identity: input.identity, envelope });
22
+ try {
23
+ const receipt = await deliverFeedbackOnline({ ...input, envelope });
24
+ const cleanup = removeFeedbackOutbox(input.outboxDirectory, {
25
+ identity: input.identity,
26
+ envelope,
27
+ });
28
+ return { status: 'delivered', sourceEventId: envelope.sourceEventId, ...cleanup, receipt };
29
+ }
30
+ catch (error) {
31
+ const diagnostic = feedbackDiagnostic(error);
32
+ if (['identity-conflict', 'event-conflict', 'archived-session'].includes(diagnostic))
33
+ throw new FeedbackDeliveryError(diagnostic);
34
+ return {
35
+ status: 'pending',
36
+ sourceEventId: envelope.sourceEventId,
37
+ pendingCount: feedbackOutboxStatus(input.outboxDirectory).pendingCount,
38
+ diagnostic,
39
+ };
40
+ }
41
+ }
42
+ export async function verifyFreshFeedback(input) {
43
+ const envelope = prepareEnvelope(input);
44
+ const receipt = await deliverFeedbackOnline({ ...input, envelope }, true);
45
+ return { status: 'delivered', sourceEventId: envelope.sourceEventId, pendingCount: 0, receipt };
46
+ }
47
+ export async function flushFeedbackOutbox(input) {
48
+ let deliveredCount = 0;
49
+ let diagnostic;
50
+ let cleanupDiagnostic;
51
+ for (const record of listFeedbackOutbox(input.directory)) {
52
+ try {
53
+ await deliverFeedbackOnline({
54
+ ...record,
55
+ ...(input.env === undefined ? {} : { env: input.env }),
56
+ ...(input.dependencies === undefined ? {} : { dependencies: input.dependencies }),
57
+ });
58
+ const cleanup = removeFeedbackOutbox(input.directory, record);
59
+ cleanupDiagnostic ??= cleanup.cleanupDiagnostic;
60
+ deliveredCount++;
61
+ }
62
+ catch (error) {
63
+ const current = feedbackDiagnostic(error);
64
+ diagnostic ??= current;
65
+ if (['identity-conflict', 'event-conflict', 'archived-session'].includes(current))
66
+ continue;
67
+ diagnostic = current;
68
+ break;
69
+ }
70
+ }
71
+ return {
72
+ ...feedbackOutboxStatus(input.directory),
73
+ deliveredCount,
74
+ ...(cleanupDiagnostic === undefined ? {} : { cleanupDiagnostic }),
75
+ ...(diagnostic === undefined ? {} : { diagnostic }),
76
+ };
77
+ }
78
+ function prepareEnvelope(input) {
79
+ const token = (input.env ?? process.env).AGENT_BLACKBOARD_TOKEN;
80
+ const envelope = createFeedbackEnvelope(input.envelope, {
81
+ knownSensitiveValues: token ? [token] : [],
82
+ });
83
+ if (token &&
84
+ token.length >= 4 &&
85
+ JSON.stringify({ identity: input.identity, envelope }).includes(token))
86
+ throw new Error('feedback identity contains a configured sensitive value');
87
+ return envelope;
88
+ }
@@ -0,0 +1,4 @@
1
+ import type { FeedbackReference } from './feedback-types.mts';
2
+ export declare function validateFeedbackText(value: unknown, label: string, maximum: number): asserts value is string;
3
+ export declare function normalizeRepositories(value: unknown, required?: boolean): string[];
4
+ export declare function validateFeedbackReferences(value: unknown, label: string): asserts value is FeedbackReference[];