vouchington-tooling 0.37.0 → 0.37.2

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 (37) hide show
  1. package/README.md +30 -1
  2. package/dist/agent-blackboard/feedback-audit-source.d.mts +15 -3
  3. package/dist/agent-blackboard/feedback-audit-source.mjs +13 -6
  4. package/dist/agent-blackboard/feedback-codec.d.mts +4 -0
  5. package/dist/agent-blackboard/feedback-codec.mjs +14 -5
  6. package/dist/agent-blackboard/feedback-compose-audit.d.mts +23 -0
  7. package/dist/agent-blackboard/feedback-compose-audit.mjs +45 -0
  8. package/dist/agent-blackboard/feedback-compose.mjs +31 -17
  9. package/dist/agent-blackboard/feedback-journal-audit.d.mts +11 -0
  10. package/dist/agent-blackboard/feedback-journal-audit.mjs +10 -4
  11. package/dist/agent-blackboard/feedback-journal-sandbox.d.mts +2 -1
  12. package/dist/agent-blackboard/feedback-journal-sandbox.mjs +23 -5
  13. package/dist/session-friction/audit-block.d.mts +11 -2
  14. package/dist/session-friction/audit-block.mjs +27 -12
  15. package/dist/session-friction/audit-fit.d.mts +25 -0
  16. package/dist/session-friction/audit-fit.mjs +52 -0
  17. package/dist/session-friction/ci-failures.d.mts +2 -1
  18. package/dist/session-friction/ci-failures.mjs +19 -5
  19. package/dist/session-friction/index.d.mts +1 -1
  20. package/dist/session-friction/index.mjs +1 -1
  21. package/dist/session-friction/report.d.mts +15 -1
  22. package/dist/session-friction/report.mjs +25 -12
  23. package/dist/session-friction/sandbox.d.mts +2 -1
  24. package/dist/session-friction/sandbox.mjs +28 -7
  25. package/dist/session-friction/text.d.mts +14 -1
  26. package/dist/session-friction/text.mjs +34 -9
  27. package/dist/session-friction/types.d.mts +7 -0
  28. package/dist/shared-context/source-graph-owner.d.mts +4 -0
  29. package/dist/shared-context/source-graph-owner.mjs +67 -0
  30. package/dist/shared-context/source-graph-resolve.d.mts +8 -0
  31. package/dist/shared-context/source-graph-resolve.mjs +60 -0
  32. package/dist/shared-context/source-graph.d.mts +24 -0
  33. package/dist/shared-context/source-graph.mjs +86 -0
  34. package/dist/source-module-graph/index.d.mts +2 -0
  35. package/dist/source-module-graph/index.mjs +1 -0
  36. package/package.json +11 -1
  37. package/scripts/allocate-browser-safe-ports.py +3 -0
package/README.md CHANGED
@@ -89,6 +89,22 @@ vouchington post-review
89
89
  vouchington stage-review-payload optional|required <source> <destination>
90
90
  ```
91
91
 
92
+ ### Running `allocate-browser-safe-ports` without an install
93
+
94
+ The allocator is a standalone Python script that needs only the Python 3 standard library: no
95
+ Node, no `pnpm install`. Run it from the extracted published tarball:
96
+
97
+ ```bash
98
+ npm pack vouchington-tooling@<version> # or download the registry .tgz
99
+ mkdir extracted && tar -xzf vouchington-tooling-<version>.tgz -C extracted
100
+ python3 extracted/package/scripts/allocate-browser-safe-ports.py <count> [--policy ...] [--forbidden-ports ...]
101
+ ```
102
+
103
+ `pnpm dlx` and `npx` resolve the package's whole dependency tree, which can fail under
104
+ `minimumReleaseAge` (`ERR_PNPM_NO_MATURE_MATCHING_VERSION`) when a lockstep-published dependency
105
+ straddles the cutoff. CI jobs without an install should fetch the tarball instead. Tests enforce
106
+ that the script stays stdlib-only and reads only its two sibling JSON files.
107
+
92
108
  For persistent `pnpm-install`, v5 metadata tracks structural inputs separately from the
93
109
  `--install-scripts` policy. A warm scripts-enabled tree can therefore toggle
94
110
  `true → false → true` without forced reconciliation; a tree first installed with scripts disabled
@@ -216,6 +232,7 @@ import {
216
232
  installFakeGit,
217
233
  runNamedChecks,
218
234
  } from 'vouchington-tooling/shared-context'
235
+ import { createSourceModuleGraph } from 'vouchington-tooling/source-module-graph'
219
236
  import {
220
237
  decodeSelectedFiles,
221
238
  writeSelectedFilesOutput,
@@ -439,7 +456,7 @@ when building a report, stopping earlier when its aggregate 1 MB inspected-byte
439
456
  Bounded journal scans that stop before exhaustion are reported as incomplete rather than clean.
440
457
  Report liveness inherits the caller-supplied journal loader, which must bound its own I/O and yields.
441
458
  Log reads are capped at 2 MB, journal Markdown at 10,000 bytes per entry,
442
- and rendered audit fields at 120 escaped characters. Permission requests record requested outcomes; explicit result observations may record approved
459
+ and rendered audit fields are kept whole: composition loads the journal and friction log once, redacts each field from its raw text before Markdown escaping (a caller-supplied redactor runs first, then the built-in one), then renders the CI and sandbox sections, measures the real envelope against its 12,000-byte markdown and 16,384-byte envelope limits, and shrinks them in a few bounded attempts. A block shrinks Root diagnostic first and Evidence last, cutting only at token boundaries (never inside a URL or SHA) with a trailing `…`; groups that still cannot fit are omitted and counted in an explicit conforming group that points at the journal. The final attempt keeps only that omission note (about 300 bytes), so an input within roughly 300 bytes of the limit can still fail to compose. A caller-supplied `budgets` cap limits rendered groups only; the omission note is exempt from it. Permission requests record requested outcomes; explicit result observations may record approved
443
460
  or denied outcomes. A decision and simultaneous tool failure are both retained and counted in
444
461
  the report. Escalation detail without a decision remains unknown. Localhost connection
445
462
  refusal is an ambiguous failure and does not establish a sandbox cause. The supplied log directory must be dedicated
@@ -657,6 +674,18 @@ TypeScript and route rows from Markdown. Callers configure declaration names and
657
674
  runbook headings, then compare the returned data using their own route policy.
658
675
  See the [API contract](docs/basic-auth-doc-sync.md).
659
676
 
677
+ ## TypeScript source graph
678
+
679
+ `vouchington-tooling/source-module-graph` exports `createSourceModuleGraph`. It accepts the
680
+ `modules` report from released no-mistakes
681
+ `analyzeTypeScriptModules({ root, files })`, plus the caller's root, ordered source extensions,
682
+ aliases, and file inventory. It resolves local imports and reexport owners without reading or
683
+ parsing source again. `runtimeImports(file)` uses binding references rather than declaration
684
+ presence, and `reachableFrom(entrypoints, { runtimeOnly: true })` follows used runtime imports,
685
+ reexports, and literal dynamic/require loads. Missing or incomplete facts for a reached file
686
+ throw; external specifiers resolve to `null`. The default file inventory limit is 10,000
687
+ files and can be reduced with `maxFiles`.
688
+
660
689
  ## SQL view declarations
661
690
 
662
691
  `vouchington-tooling/sql-ast` exports `extractViewDeclarations(sql)` and the
@@ -1,4 +1,5 @@
1
- import { type SessionFrictionReport, type SessionFrictionReportOptions } from '../session-friction/index.mts';
1
+ import { type FrictionReportInputs, type SessionFrictionReport, type SessionFrictionReportOptions } from '../session-friction/index.mts';
2
+ import type { JournalScan } from '../session-friction/journal.mts';
2
3
  import { type JournalAuditOptions, type JournalAuditReport } from './feedback-journal-audit.mts';
3
4
  export type AuditSourceInput = {
4
5
  friction?: SessionFrictionReportOptions;
@@ -6,8 +7,19 @@ export type AuditSourceInput = {
6
7
  };
7
8
  export type AuditReport = SessionFrictionReport | JournalAuditReport;
8
9
  export declare function assertSingleAuditSource(input: AuditSourceInput): void;
9
- /** Builds the CI-failure and sandbox sections from the friction log or from journal entries alone. */
10
- export declare function buildAuditReport(sessionId: string, input: AuditSourceInput): Promise<AuditReport>;
10
+ /** What the audit sections read from outside, loaded once and rendered as often as needed. */
11
+ export type AuditInputs = {
12
+ kind: 'friction';
13
+ loaded: FrictionReportInputs;
14
+ } | {
15
+ kind: 'journal';
16
+ scanned: JournalScan;
17
+ } | {
18
+ kind: 'none';
19
+ };
20
+ export declare function loadAuditInputs(sessionId: string, input: AuditSourceInput): Promise<AuditInputs>;
21
+ /** Builds the CI-failure and sandbox sections from loaded inputs, with no further I/O. */
22
+ export declare function renderAuditReport(sessionId: string, input: AuditSourceInput, inputs: AuditInputs): AuditReport;
11
23
  /** Only a real log status or an explicit journal-only scan counts as assessed. */
12
24
  export declare function auditAssessed({ coverage }: AuditReport): boolean;
13
25
  export declare function completeCoverageError(input: AuditSourceInput): Error;
@@ -1,17 +1,24 @@
1
- import { buildSessionFrictionReport, } from "../session-friction/index.mjs";
2
- import { buildJournalAuditReport, } from "./feedback-journal-audit.mjs";
1
+ import { loadFrictionReportInputs, renderFrictionReport, } from "../session-friction/index.mjs";
2
+ import { loadJournalAuditInput, renderJournalAuditReport, } from "./feedback-journal-audit.mjs";
3
3
  const ASSESSED_SOURCE_ERROR = 'complete feedback coverage requires assessed available factual and finding sources';
4
4
  const NOT_ASSESSED_MARKDOWN = '## CI Failures\nStatus: unavailable (not assessed)\n\n## Sandbox & Permission Audit\nStatus: unavailable (not assessed)';
5
5
  export function assertSingleAuditSource(input) {
6
6
  if (input.friction && input.journal)
7
7
  throw new Error('composition accepts either a friction or a journal audit source, not both');
8
8
  }
9
- /** Builds the CI-failure and sandbox sections from the friction log or from journal entries alone. */
10
- export async function buildAuditReport(sessionId, input) {
9
+ export async function loadAuditInputs(sessionId, input) {
11
10
  if (input.friction)
12
- return buildSessionFrictionReport(sessionId, input.friction);
11
+ return { kind: 'friction', loaded: await loadFrictionReportInputs(sessionId, input.friction) };
13
12
  if (input.journal)
14
- return buildJournalAuditReport(sessionId, input.journal);
13
+ return { kind: 'journal', scanned: await loadJournalAuditInput(sessionId, input.journal) };
14
+ return { kind: 'none' };
15
+ }
16
+ /** Builds the CI-failure and sandbox sections from loaded inputs, with no further I/O. */
17
+ export function renderAuditReport(sessionId, input, inputs) {
18
+ if (inputs.kind === 'friction')
19
+ return renderFrictionReport(sessionId, input.friction, inputs.loaded);
20
+ if (inputs.kind === 'journal')
21
+ return renderJournalAuditReport(sessionId, input.journal, inputs.scanned);
15
22
  return {
16
23
  coverage: { journalStatus: 'unavailable', frictionStatus: 'absent', truncated: false },
17
24
  markdown: NOT_ASSESSED_MARKDOWN,
@@ -2,6 +2,10 @@ export { normalizeRepositories } from './feedback-fields.mts';
2
2
  import type { FeedbackEnvelope } from './feedback-types.mts';
3
3
  export declare function validateFeedbackEnvelope(value: unknown): asserts value is FeedbackEnvelope;
4
4
  export declare function redactFeedbackText(value: string, knownSensitiveValues?: string[]): string;
5
+ /** Bytes by which the envelope exceeds its markdown or whole-envelope limit; 0 when it fits. */
6
+ export declare function feedbackOverflowBytes(input: FeedbackEnvelope, options?: {
7
+ knownSensitiveValues?: string[];
8
+ }): number;
5
9
  export declare function createFeedbackEnvelope(input: FeedbackEnvelope, options?: {
6
10
  knownSensitiveValues?: string[];
7
11
  }): FeedbackEnvelope;
@@ -2,6 +2,7 @@ import { normalizeRepositories, validateFeedbackText, validateFeedbackReferences
2
2
  export { normalizeRepositories } from "./feedback-fields.mjs";
3
3
  import { isCount, isObject } from "./snapshot-partition-guards.mjs";
4
4
  const FEEDBACK_MAX_BYTES = 16_384;
5
+ const FEEDBACK_MARKDOWN_MAX_BYTES = 12_000;
5
6
  const OUTCOMES = [
6
7
  'in-progress',
7
8
  'success',
@@ -44,7 +45,7 @@ export function validateFeedbackEnvelope(value) {
44
45
  throw new Error('timestamp must be a canonical ISO date-time');
45
46
  if (JSON.stringify(value.repositories) !== JSON.stringify(normalizeRepositories(value.repositories)))
46
47
  throw new Error('repositories must be canonical');
47
- validateFeedbackText(value.markdown, 'markdown', 12_000);
48
+ validateFeedbackText(value.markdown, 'markdown', FEEDBACK_MARKDOWN_MAX_BYTES);
48
49
  if (typeof value.workOutcome !== 'string' || !OUTCOMES.includes(value.workOutcome))
49
50
  throw new Error('workOutcome must be explicit');
50
51
  if (!isObject(value.feedbackCoverage) ||
@@ -88,10 +89,20 @@ export function redactFeedbackText(value, knownSensitiveValues = []) {
88
89
  .replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+:[^\s/@]+@/gi, '$1[REDACTED]@')
89
90
  .replace(/((?:password|secret|token|api[_-]?key)\s*[=:]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi, '$1[REDACTED]');
90
91
  }
92
+ /** Bytes by which the envelope exceeds its markdown or whole-envelope limit; 0 when it fits. */
93
+ export function feedbackOverflowBytes(input, options = {}) {
94
+ const envelope = redactedEnvelope(input, options);
95
+ return Math.max(0, Buffer.byteLength(envelope.markdown) - FEEDBACK_MARKDOWN_MAX_BYTES, Buffer.byteLength(JSON.stringify(envelope)) - FEEDBACK_MAX_BYTES);
96
+ }
91
97
  export function createFeedbackEnvelope(input, options = {}) {
92
- validateFeedbackText(input.markdown, 'markdown', 12_000);
98
+ validateFeedbackText(input.markdown, 'markdown', FEEDBACK_MARKDOWN_MAX_BYTES);
93
99
  validateFeedbackText(input.timestamp, 'timestamp', 40);
94
- const envelope = {
100
+ const envelope = redactedEnvelope(input, options);
101
+ validateFeedbackEnvelope(envelope);
102
+ return envelope;
103
+ }
104
+ function redactedEnvelope(input, options) {
105
+ return {
95
106
  ...input,
96
107
  repositories: normalizeRepositories(input.repositories),
97
108
  markdown: redactFeedbackText(input.markdown, options.knownSensitiveValues),
@@ -116,6 +127,4 @@ export function createFeedbackEnvelope(input, options = {}) {
116
127
  prs: input.prs.map((ref) => typeof ref === 'string' ? redactFeedbackText(ref, options.knownSensitiveValues) : ref),
117
128
  }),
118
129
  };
119
- validateFeedbackEnvelope(envelope);
120
- return envelope;
121
130
  }
@@ -0,0 +1,23 @@
1
+ import type { AuditSourceInput } from './feedback-audit-source.mts';
2
+ export type AuditCaps = {
3
+ ciBytes: number;
4
+ sandboxBytes: number;
5
+ };
6
+ type AuditInput = AuditSourceInput & {
7
+ knownSensitiveValues?: string[] | undefined;
8
+ };
9
+ /** Rendering attempts: measured shrinks, then every audit group omitted. */
10
+ export declare const AUDIT_ATTEMPTS = 5;
11
+ export declare const UNBOUNDED_CAPS: AuditCaps;
12
+ /**
13
+ * Caps for the next attempt after an overflow, scaled by how much of the rendered audit markdown
14
+ * (`auditBytes`) must go. The last attempt allows nothing, leaving only omission notes.
15
+ */
16
+ export declare function shrinkCaps(caps: AuditCaps, auditBytes: number, overflowBytes: number, last: boolean): AuditCaps;
17
+ /**
18
+ * Audit source with redaction applied to raw field text (escaping first would defeat matching) and
19
+ * byte caps. A caller-supplied redactor runs first, then the built-in one; caller-supplied caps
20
+ * can only lower the computed ones.
21
+ */
22
+ export declare function boundedAuditSource(input: AuditInput, caps: AuditCaps): AuditSourceInput;
23
+ export {};
@@ -0,0 +1,45 @@
1
+ import { redactFeedbackText } from "./feedback-codec.mjs";
2
+ const SHRINK_MARGIN_BYTES = 32;
3
+ /** Rendering attempts: measured shrinks, then every audit group omitted. */
4
+ export const AUDIT_ATTEMPTS = 5;
5
+ export const UNBOUNDED_CAPS = { ciBytes: Infinity, sandboxBytes: Infinity };
6
+ /**
7
+ * Caps for the next attempt after an overflow, scaled by how much of the rendered audit markdown
8
+ * (`auditBytes`) must go. The last attempt allows nothing, leaving only omission notes.
9
+ */
10
+ export function shrinkCaps(caps, auditBytes, overflowBytes, last) {
11
+ if (last)
12
+ return { ciBytes: 0, sandboxBytes: 0 };
13
+ const keep = Math.max(0, auditBytes - overflowBytes - SHRINK_MARGIN_BYTES) / auditBytes;
14
+ const scale = (cap) => Math.floor(Math.min(cap, auditBytes) * keep);
15
+ return { ciBytes: scale(caps.ciBytes), sandboxBytes: scale(caps.sandboxBytes) };
16
+ }
17
+ /**
18
+ * Audit source with redaction applied to raw field text (escaping first would defeat matching) and
19
+ * byte caps. A caller-supplied redactor runs first, then the built-in one; caller-supplied caps
20
+ * can only lower the computed ones.
21
+ */
22
+ export function boundedAuditSource(input, caps) {
23
+ const redactionFor = (caller) => (value) => redactFeedbackText(caller ? caller(value) : value, input.knownSensitiveValues);
24
+ const clamp = (caller) => ({
25
+ ciBytes: Math.min(caller?.ciBytes ?? Infinity, caps.ciBytes),
26
+ sandboxBytes: Math.min(caller?.sandboxBytes ?? Infinity, caps.sandboxBytes),
27
+ });
28
+ return input.friction
29
+ ? {
30
+ friction: {
31
+ ...input.friction,
32
+ redact: redactionFor(input.friction.redact),
33
+ budgets: clamp(input.friction.budgets),
34
+ },
35
+ }
36
+ : input.journal
37
+ ? {
38
+ journal: {
39
+ ...input.journal,
40
+ redact: redactionFor(input.journal.redact),
41
+ budgets: clamp(input.journal.budgets),
42
+ },
43
+ }
44
+ : {};
45
+ }
@@ -1,7 +1,8 @@
1
1
  import { runRetrospectiveFactsReport, } from "../retrospective-facts/index.mjs";
2
2
  import { runRetrospectiveTranscriptReport, } from "../retrospective-transcript/index.mjs";
3
- import { assertSingleAuditSource, auditAssessed, buildAuditReport, completeCoverageError, } from "./feedback-audit-source.mjs";
4
- import { createFeedbackEnvelope, redactFeedbackText } from "./feedback-codec.mjs";
3
+ import { assertSingleAuditSource, auditAssessed, loadAuditInputs, renderAuditReport, completeCoverageError, } from "./feedback-audit-source.mjs";
4
+ import { AUDIT_ATTEMPTS, boundedAuditSource, shrinkCaps, UNBOUNDED_CAPS, } from "./feedback-compose-audit.mjs";
5
+ import { createFeedbackEnvelope, feedbackOverflowBytes, redactFeedbackText, } from "./feedback-codec.mjs";
5
6
  import { validateFeedbackText } from "./feedback-fields.mjs";
6
7
  function assessment(title, value) {
7
8
  if (!['findings', 'none-observed', 'not-assessed', 'unavailable'].includes(value.status))
@@ -45,25 +46,15 @@ export async function composeRetrospective(input) {
45
46
  coverage: input.transcript.status,
46
47
  }
47
48
  : undefined;
48
- const [facts, transcript, audit] = await Promise.all([
49
+ const [facts, transcript] = await Promise.all([
49
50
  'status' in input.facts
50
51
  ? Promise.resolve(unavailableFacts)
51
52
  : runRetrospectiveFactsReport({ ...input.facts, raw: false }),
52
53
  'status' in input.transcript
53
54
  ? Promise.resolve(unavailableTranscript)
54
55
  : runRetrospectiveTranscriptReport(input.transcript),
55
- buildAuditReport(input.sessionId, input),
56
56
  ]);
57
- if (input.feedbackCoverage.status === 'complete' &&
58
- (facts.coverage !== 'complete' ||
59
- transcript.coverage !== 'complete' ||
60
- ['not-assessed', 'unavailable'].includes(input.tools.status) ||
61
- ['not-assessed', 'unavailable'].includes(input.architecture.status) ||
62
- !auditAssessed(audit)))
63
- throw completeCoverageError(input);
64
- if ((audit.coverage.droppedCount ?? 0) > input.feedbackCoverage.droppedCount)
65
- throw new Error('feedback coverage dropped count must include observed friction drops');
66
- const markdown = [
57
+ const assemble = (auditMarkdown) => [
67
58
  '---',
68
59
  `date: ${JSON.stringify(input.date)}`,
69
60
  `issues: ${JSON.stringify(input.issues)}`,
@@ -78,11 +69,11 @@ export async function composeRetrospective(input) {
78
69
  `## Outcome\nWork outcome: ${input.workOutcome}\nFeedback coverage: ${input.feedbackCoverage.status}\nDropped records: ${input.feedbackCoverage.droppedCount}`,
79
70
  `## Verifiable Facts\n${facts.markdown.trim()}`,
80
71
  `## Transcript Facts\n${transcript.markdown.trim()}`,
81
- audit.markdown,
72
+ auditMarkdown,
82
73
  assessment('Tool Findings', input.tools),
83
74
  assessment('Architecture Findings', input.architecture),
84
75
  ].join('\n\n');
85
- const envelope = createFeedbackEnvelope({
76
+ const envelopeFor = (markdown) => ({
86
77
  schemaVersion: 1,
87
78
  type: 'retrospective',
88
79
  sourceEventId: 'composition-validation',
@@ -95,5 +86,28 @@ export async function composeRetrospective(input) {
95
86
  workOutcome: input.workOutcome,
96
87
  feedbackCoverage: input.feedbackCoverage,
97
88
  });
98
- return envelope.markdown;
89
+ // The audit sections are the only unbounded ones: render them, measure the real envelope against
90
+ // its limits, and shrink the audit caps by the measured overflow until it fits.
91
+ const loaded = await loadAuditInputs(input.sessionId, input);
92
+ let caps = UNBOUNDED_CAPS;
93
+ let audit;
94
+ let markdown;
95
+ for (let attempt = 1;; attempt++) {
96
+ audit = renderAuditReport(input.sessionId, boundedAuditSource(input, caps), loaded);
97
+ markdown = assemble(audit.markdown);
98
+ const overflow = feedbackOverflowBytes(envelopeFor(markdown));
99
+ if (!overflow || attempt === AUDIT_ATTEMPTS)
100
+ break;
101
+ caps = shrinkCaps(caps, Buffer.byteLength(audit.markdown), overflow, attempt === AUDIT_ATTEMPTS - 1);
102
+ }
103
+ if (input.feedbackCoverage.status === 'complete' &&
104
+ (facts.coverage !== 'complete' ||
105
+ transcript.coverage !== 'complete' ||
106
+ ['not-assessed', 'unavailable'].includes(input.tools.status) ||
107
+ ['not-assessed', 'unavailable'].includes(input.architecture.status) ||
108
+ !auditAssessed(audit)))
109
+ throw completeCoverageError(input);
110
+ if ((audit.coverage.droppedCount ?? 0) > input.feedbackCoverage.droppedCount)
111
+ throw new Error('feedback coverage dropped count must include observed friction drops');
112
+ return createFeedbackEnvelope(envelopeFor(markdown)).markdown;
99
113
  }
@@ -1,6 +1,14 @@
1
+ import { type JournalScan } from '../session-friction/journal.mts';
1
2
  import type { JournalLoader, SessionFrictionCoverage } from '../session-friction/types.mts';
2
3
  export type JournalAuditOptions = {
3
4
  journalLoader: JournalLoader;
5
+ /** Redacts raw field text before Markdown escaping, which would otherwise break matching. */
6
+ redact?: (value: string) => string;
7
+ /** Byte allowances for the CI and sandbox sections, as maximums over the built-in budgets. */
8
+ budgets?: {
9
+ ciBytes: number;
10
+ sandboxBytes: number;
11
+ };
4
12
  };
5
13
  /** `journal-only` says both sections were assessed from journal entries, with no log observed. */
6
14
  export type JournalAuditReport = {
@@ -17,4 +25,7 @@ export type JournalAuditReport = {
17
25
  * alone. Unlike `buildSessionFrictionReport`, a session with no journal is unavailable rather than
18
26
  * empty: without a log, nothing else establishes that the session was observed.
19
27
  */
28
+ export declare function loadJournalAuditInput(sessionId: string, options: JournalAuditOptions): Promise<JournalScan>;
29
+ /** Pure render of a loaded journal scan, so it can be repeated without reloading. */
30
+ export declare function renderJournalAuditReport(sessionId: string, options: JournalAuditOptions, scanned: JournalScan): JournalAuditReport;
20
31
  export declare function buildJournalAuditReport(sessionId: string, options: JournalAuditOptions): Promise<JournalAuditReport>;
@@ -13,17 +13,23 @@ function unavailable(reason) {
13
13
  * alone. Unlike `buildSessionFrictionReport`, a session with no journal is unavailable rather than
14
14
  * empty: without a log, nothing else establishes that the session was observed.
15
15
  */
16
- export async function buildJournalAuditReport(sessionId, options) {
16
+ export async function loadJournalAuditInput(sessionId, options) {
17
17
  validateSessionId(sessionId);
18
- const scanned = await scanJournal(sessionId, options.journalLoader);
18
+ return scanJournal(sessionId, options.journalLoader);
19
+ }
20
+ /** Pure render of a loaded journal scan, so it can be repeated without reloading. */
21
+ export function renderJournalAuditReport(sessionId, options, scanned) {
19
22
  if (scanned.status === 'unreachable')
20
23
  return unavailable('blackboard unreachable');
21
24
  if (scanned.status === 'not-found')
22
25
  return unavailable('no journal for session');
23
- const ci = getConformingGroups(scanned.entries);
24
- const sandbox = getConformingSandboxBlocks(scanned.entries);
26
+ const ci = getConformingGroups(scanned.entries, options.redact, options.budgets?.ciBytes);
27
+ const sandbox = getConformingSandboxBlocks(scanned.entries, options.redact, options.budgets?.sandboxBytes);
25
28
  if (scanned.truncated)
26
29
  return report('partial', `${incompleteCiSection(ci)}\n\n${unavailableSandboxSection('journal scan incomplete', sandbox)}`);
27
30
  const journal = { status: 'ok', markdownBlocks: ci, truncated: false };
28
31
  return report('complete', `${buildCiFailuresSection(sessionId, journal, 'journal-only')}\n\n${journalSandboxSection(sandbox)}`);
29
32
  }
33
+ export async function buildJournalAuditReport(sessionId, options) {
34
+ return renderJournalAuditReport(sessionId, options, await loadJournalAuditInput(sessionId, options));
35
+ }
@@ -1,5 +1,6 @@
1
+ import type { AuditRedactor } from '../session-friction/text.mts';
1
2
  import type { JournalEntry } from '../session-friction/types.mts';
2
3
  /** Journal entries that hold exactly one sandbox or permission block, made paste-safe. */
3
- export declare function getConformingSandboxBlocks(entries: Iterable<JournalEntry>): string[];
4
+ export declare function getConformingSandboxBlocks(entries: Iterable<JournalEntry>, redact?: AuditRedactor, maxBytes?: number): string[];
4
5
  export declare function unavailableSandboxSection(reason: string, blocks?: string[]): string;
5
6
  export declare function journalSandboxSection(blocks: string[]): string;
@@ -22,13 +22,31 @@ const FIELD_PREFIXES = [
22
22
  ' - Evidence: ',
23
23
  ' - Disposition: ',
24
24
  ];
25
- function matchBlock(markdown) {
26
- return (matchAuditBlock(markdown, ESCALATION, FIELD_PREFIXES) ??
27
- matchAuditBlock(markdown, FAILURE, FIELD_PREFIXES));
25
+ const SANDBOX_BLOCKS_MAX_BYTES = 3_000;
26
+ // Cut Disposition first, then the header detail, and Evidence last; Outcome is never cut.
27
+ const ESCALATION_SHRINK_ORDER = [3, 0, 2];
28
+ const FAILURE_SHRINK_ORDER = [2, 0, 1];
29
+ function matchBlock(markdown, render) {
30
+ return (matchAuditBlock(markdown, ESCALATION, FIELD_PREFIXES, {
31
+ ...render,
32
+ shrinkOrder: ESCALATION_SHRINK_ORDER,
33
+ }) ??
34
+ matchAuditBlock(markdown, FAILURE, FIELD_PREFIXES, {
35
+ ...render,
36
+ shrinkOrder: FAILURE_SHRINK_ORDER,
37
+ }));
38
+ }
39
+ /** A conforming block that reports dropped blocks, so omission is never silent. */
40
+ function omissionBlock(count) {
41
+ return [
42
+ `- \`ambiguous-failure\` — omitted — ${count} sandbox blocks omitted to fit the size limit; see journal`,
43
+ ' - Evidence: omitted blocks remain in the session journal',
44
+ ' - Disposition: see journal for the omitted blocks',
45
+ ].join('\n');
28
46
  }
29
47
  /** Journal entries that hold exactly one sandbox or permission block, made paste-safe. */
30
- export function getConformingSandboxBlocks(entries) {
31
- return conformingBlocks(entries, matchBlock);
48
+ export function getConformingSandboxBlocks(entries, redact, maxBytes = SANDBOX_BLOCKS_MAX_BYTES) {
49
+ return conformingBlocks(entries, matchBlock, Math.min(maxBytes, SANDBOX_BLOCKS_MAX_BYTES), redact, omissionBlock);
32
50
  }
33
51
  export function unavailableSandboxSection(reason, blocks = []) {
34
52
  return [`${SANDBOX_SECTION_HEADER}\nStatus: unavailable (${reason})`, ...blocks].join('\n\n');
@@ -1,4 +1,13 @@
1
1
  import type { JournalEntry } from './types.mts';
2
+ import { type AuditRender } from './audit-fit.mts';
3
+ import { type AuditRedactor } from './text.mts';
2
4
  /** Returns the paste-safe block, or null when the markdown is not exactly one conforming block. */
3
- export declare function matchAuditBlock(markdown: string, patterns: readonly RegExp[], prefixes: readonly string[]): string | null;
4
- export declare function conformingBlocks(entries: Iterable<JournalEntry>, match: (markdown: string) => string | null): string[];
5
+ export declare function matchAuditBlock(markdown: string, patterns: readonly RegExp[], prefixes: readonly string[], render: AuditRender & {
6
+ shrinkOrder: readonly number[];
7
+ }): string | null;
8
+ /**
9
+ * Matches every journal block whole while together they fit `totalBudgetBytes`. Otherwise it keeps
10
+ * the leading blocks that fit an equal share of the total and appends `omission(count)` — itself a
11
+ * conforming block — so dropped groups are always reported, never silent.
12
+ */
13
+ export declare function conformingBlocks(entries: Iterable<JournalEntry>, match: (markdown: string, render: AuditRender) => string | null, totalBudgetBytes: number, redact: AuditRedactor | undefined, omission: (count: number) => string): string[];
@@ -1,12 +1,16 @@
1
- import { isWellFormedUnicode, markdownAuditText } from "./text.mjs";
1
+ import { byteLength, fitFields, selectWithin } from "./audit-fit.mjs";
2
+ import { isWellFormedUnicode, markdownAuditUnits } from "./text.mjs";
2
3
  const BLANK = /^\s*$/;
3
- function sanitizeField(line, prefixes) {
4
+ // Below this a block would keep little beyond its field names, so it is omitted (and counted)
5
+ // instead. This only ever drops groups; it never lets the section exceed its budget.
6
+ const MIN_BLOCK_SHARE_BYTES = 300;
7
+ function parseField(line, prefixes, redact) {
4
8
  const prefix = prefixes.find((value) => line.startsWith(value));
5
9
  /* v8 ignore next -- callers only pass lines that matched a pattern with a known prefix. */
6
10
  if (!prefix)
7
- return markdownAuditText(line) || null;
8
- const content = markdownAuditText(line.slice(prefix.length));
9
- return content ? `${prefix}${content}` : null;
11
+ return null;
12
+ const units = markdownAuditUnits(line.slice(prefix.length), redact);
13
+ return units.length ? { prefix, units } : null;
10
14
  }
11
15
  // Consumes exactly one line per pattern (skipping blank lines between them) and nothing else.
12
16
  function matchLines(markdown, patterns) {
@@ -27,19 +31,30 @@ function matchLines(markdown, patterns) {
27
31
  return lines.slice(index).every((line) => BLANK.test(line)) ? fields : null;
28
32
  }
29
33
  /** Returns the paste-safe block, or null when the markdown is not exactly one conforming block. */
30
- export function matchAuditBlock(markdown, patterns, prefixes) {
34
+ export function matchAuditBlock(markdown, patterns, prefixes, render) {
31
35
  const fields = matchLines(markdown, patterns);
32
36
  if (!fields)
33
37
  return null;
34
- const safeFields = fields.map((field) => sanitizeField(field, prefixes));
35
- return safeFields.some((field) => field === null) ? null : safeFields.join('\n');
38
+ const parsed = fields.map((field) => parseField(field, prefixes, render.redact));
39
+ const safe = parsed.filter((field) => field !== null);
40
+ if (safe.length !== parsed.length)
41
+ return null;
42
+ const overhead = safe.reduce((sum, { prefix }) => sum + byteLength(prefix), safe.length - 1);
43
+ const contents = fitFields(safe.map(({ units }) => units), overhead, render.budgetBytes ?? Infinity, render.shrinkOrder);
44
+ return safe.map(({ prefix }, index) => `${prefix}${contents[index]}`).join('\n');
36
45
  }
37
- export function conformingBlocks(entries, match) {
38
- return [...entries]
46
+ /**
47
+ * Matches every journal block whole while together they fit `totalBudgetBytes`. Otherwise it keeps
48
+ * the leading blocks that fit an equal share of the total and appends `omission(count)` — itself a
49
+ * conforming block — so dropped groups are always reported, never silent.
50
+ */
51
+ export function conformingBlocks(entries, match, totalBudgetBytes, redact, omission) {
52
+ const markdowns = [...entries]
39
53
  .flatMap((entry) => {
40
54
  const data = entry?.data;
41
55
  return data?.type === 'journal' && typeof data.markdown === 'string' ? [data.markdown] : [];
42
56
  })
43
- .map(match)
44
- .filter((block) => block !== null);
57
+ .filter((markdown) => match(markdown, { redact }) !== null);
58
+ const { rendered, omitted } = selectWithin(markdowns, totalBudgetBytes, byteLength(omission(markdowns.length)) + 2, 2, MIN_BLOCK_SHARE_BYTES, (markdown, budgetBytes) => match(markdown, { redact, budgetBytes }));
59
+ return omitted ? [...rendered, omission(omitted)] : rendered;
45
60
  }
@@ -0,0 +1,25 @@
1
+ import { type AuditRedactor } from './text.mts';
2
+ export type AuditRender = {
3
+ redact?: AuditRedactor | undefined;
4
+ budgetBytes?: number;
5
+ /** Field indexes that may be cut, least important first; other fields are never cut. */
6
+ shrinkOrder?: readonly number[];
7
+ };
8
+ export declare function byteLength(value: string): number;
9
+ /**
10
+ * Renders fields whole while they fit in `budgetBytes` (minus the fixed `overheadBytes`). When they
11
+ * do not, only the `shrinkOrder` fields are cut, in that order, each down to at most a cut marker,
12
+ * and only at token boundaries. The result can still exceed the budget if the fields cannot shrink
13
+ * enough; callers check the size.
14
+ */
15
+ export declare function fitFields(fields: string[][], overheadBytes: number, budgetBytes: number, shrinkOrder: readonly number[]): string[];
16
+ /**
17
+ * Renders every item whole when together they fit `totalBytes`. Otherwise renders the largest
18
+ * leading run of items that each fit an equal share of what remains after `reserveBytes` (for the
19
+ * omission note), and reports how many were left out. Never renders more than the total allows.
20
+ * A share below `minShareBytes` would leave only stubs, so fewer items are kept instead.
21
+ */
22
+ export declare function selectWithin<T>(items: T[], totalBytes: number, reserveBytes: number, separatorBytes: number, minShareBytes: number, render: (item: T, shareBytes: number) => string): {
23
+ rendered: string[];
24
+ omitted: number;
25
+ };
@@ -0,0 +1,52 @@
1
+ import { fitUnits } from "./text.mjs";
2
+ const MIN_FIELD_BYTES = Buffer.byteLength('…');
3
+ export function byteLength(value) {
4
+ return Buffer.byteLength(value);
5
+ }
6
+ /**
7
+ * Renders fields whole while they fit in `budgetBytes` (minus the fixed `overheadBytes`). When they
8
+ * do not, only the `shrinkOrder` fields are cut, in that order, each down to at most a cut marker,
9
+ * and only at token boundaries. The result can still exceed the budget if the fields cannot shrink
10
+ * enough; callers check the size.
11
+ */
12
+ export function fitFields(fields, overheadBytes, budgetBytes, shrinkOrder) {
13
+ const sizes = fields.map((units) => byteLength(units.join('')));
14
+ let excess = sizes.reduce((sum, size) => sum + size, overheadBytes) - budgetBytes;
15
+ const caps = [...sizes];
16
+ for (const index of shrinkOrder) {
17
+ if (excess <= 0)
18
+ break;
19
+ const cut = Math.min(excess, Math.max(0, sizes[index] - MIN_FIELD_BYTES));
20
+ caps[index] = sizes[index] - cut;
21
+ excess -= cut;
22
+ }
23
+ return fields.map((units, index) => caps[index] === sizes[index] ? units.join('') : fitUnits(units, caps[index]));
24
+ }
25
+ /**
26
+ * Renders every item whole when together they fit `totalBytes`. Otherwise renders the largest
27
+ * leading run of items that each fit an equal share of what remains after `reserveBytes` (for the
28
+ * omission note), and reports how many were left out. Never renders more than the total allows.
29
+ * A share below `minShareBytes` would leave only stubs, so fewer items are kept instead.
30
+ */
31
+ export function selectWithin(items, totalBytes, reserveBytes, separatorBytes, minShareBytes, render) {
32
+ const whole = items.map((item) => render(item, Infinity));
33
+ if (byteLength(whole.join('')) + separatorBytes * Math.max(0, whole.length - 1) <= totalBytes)
34
+ return { rendered: whole, omitted: 0 };
35
+ const available = totalBytes - reserveBytes;
36
+ const renderRun = (count) => {
37
+ const share = Math.floor((available - separatorBytes * (count - 1)) / count);
38
+ const rendered = items.slice(0, count).map((item) => render(item, share));
39
+ const fits = share >= minShareBytes && rendered.every((line) => byteLength(line) <= share);
40
+ return { rendered, fits };
41
+ };
42
+ let low = 0;
43
+ let high = items.length;
44
+ while (low < high) {
45
+ const middle = Math.ceil((low + high) / 2);
46
+ if (renderRun(middle).fits)
47
+ low = middle;
48
+ else
49
+ high = middle - 1;
50
+ }
51
+ return { rendered: low === 0 ? [] : renderRun(low).rendered, omitted: items.length - low };
52
+ }
@@ -1,6 +1,7 @@
1
1
  import type { FrictionLogReadResult, JournalEntry } from './types.mts';
2
+ import { type AuditRedactor } from './text.mts';
2
3
  export declare function isConformingCiFailureBlock(markdown: string): boolean;
3
- export declare function getConformingGroups(entries: Iterable<JournalEntry>): string[];
4
+ export declare function getConformingGroups(entries: Iterable<JournalEntry>, redact?: AuditRedactor, maxBytes?: number): string[];
4
5
  export declare function incompleteCiSection(markdownBlocks: string[]): string;
5
6
  export declare function buildCiFailuresSection(sessionId: string, journal: {
6
7
  status: 'ok';