vouchington-tooling 0.36.2 → 0.37.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.
- package/README.md +1 -1
- package/dist/agent-blackboard/feedback-audit-source.d.mts +15 -3
- package/dist/agent-blackboard/feedback-audit-source.mjs +13 -6
- package/dist/agent-blackboard/feedback-codec.d.mts +4 -0
- package/dist/agent-blackboard/feedback-codec.mjs +14 -5
- package/dist/agent-blackboard/feedback-compose-audit.d.mts +23 -0
- package/dist/agent-blackboard/feedback-compose-audit.mjs +45 -0
- package/dist/agent-blackboard/feedback-compose.mjs +31 -17
- package/dist/agent-blackboard/feedback-journal-audit.d.mts +11 -0
- package/dist/agent-blackboard/feedback-journal-audit.mjs +10 -4
- package/dist/agent-blackboard/feedback-journal-sandbox.d.mts +2 -1
- package/dist/agent-blackboard/feedback-journal-sandbox.mjs +23 -5
- package/dist/session-friction/audit-block.d.mts +11 -2
- package/dist/session-friction/audit-block.mjs +27 -12
- package/dist/session-friction/audit-fit.d.mts +25 -0
- package/dist/session-friction/audit-fit.mjs +52 -0
- package/dist/session-friction/ci-failures.d.mts +2 -1
- package/dist/session-friction/ci-failures.mjs +19 -5
- package/dist/session-friction/index.d.mts +1 -1
- package/dist/session-friction/index.mjs +1 -1
- package/dist/session-friction/report.d.mts +15 -1
- package/dist/session-friction/report.mjs +25 -12
- package/dist/session-friction/sandbox.d.mts +2 -1
- package/dist/session-friction/sandbox.mjs +28 -7
- package/dist/session-friction/text.d.mts +14 -1
- package/dist/session-friction/text.mjs +34 -9
- package/dist/session-friction/types.d.mts +7 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -439,7 +439,7 @@ when building a report, stopping earlier when its aggregate 1 MB inspected-byte
|
|
|
439
439
|
Bounded journal scans that stop before exhaustion are reported as incomplete rather than clean.
|
|
440
440
|
Report liveness inherits the caller-supplied journal loader, which must bound its own I/O and yields.
|
|
441
441
|
Log reads are capped at 2 MB, journal Markdown at 10,000 bytes per entry,
|
|
442
|
-
and rendered audit fields at
|
|
442
|
+
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
443
|
or denied outcomes. A decision and simultaneous tool failure are both retained and counted in
|
|
444
444
|
the report. Escalation detail without a decision remains unknown. Localhost connection
|
|
445
445
|
refusal is an ambiguous failure and does not establish a sandbox cause. The supplied log directory must be dedicated
|
|
@@ -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
|
-
/**
|
|
10
|
-
export
|
|
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 {
|
|
2
|
-
import {
|
|
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
|
-
|
|
10
|
-
export async function buildAuditReport(sessionId, input) {
|
|
9
|
+
export async function loadAuditInputs(sessionId, input) {
|
|
11
10
|
if (input.friction)
|
|
12
|
-
return
|
|
11
|
+
return { kind: 'friction', loaded: await loadFrictionReportInputs(sessionId, input.friction) };
|
|
13
12
|
if (input.journal)
|
|
14
|
-
return
|
|
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',
|
|
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',
|
|
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,
|
|
4
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
72
|
+
auditMarkdown,
|
|
82
73
|
assessment('Tool Findings', input.tools),
|
|
83
74
|
assessment('Architecture Findings', input.architecture),
|
|
84
75
|
].join('\n\n');
|
|
85
|
-
const
|
|
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
|
-
|
|
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
|
|
16
|
+
export async function loadJournalAuditInput(sessionId, options) {
|
|
17
17
|
validateSessionId(sessionId);
|
|
18
|
-
|
|
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
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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[]
|
|
4
|
-
|
|
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 {
|
|
1
|
+
import { byteLength, fitFields, selectWithin } from "./audit-fit.mjs";
|
|
2
|
+
import { isWellFormedUnicode, markdownAuditUnits } from "./text.mjs";
|
|
2
3
|
const BLANK = /^\s*$/;
|
|
3
|
-
|
|
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
|
|
8
|
-
const
|
|
9
|
-
return
|
|
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
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
.
|
|
44
|
-
|
|
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
|
|
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';
|
|
@@ -13,8 +13,22 @@ const FIELD_PREFIXES = [
|
|
|
13
13
|
' - Root diagnostic: ',
|
|
14
14
|
' - Disposition: ',
|
|
15
15
|
];
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
const CI_SECTION_MAX_BYTES = 4_000;
|
|
17
|
+
// Field order: header, Evidence, Root diagnostic, Disposition. Cut Root diagnostic first and
|
|
18
|
+
// Evidence last, so the run URL and commit SHA survive longest.
|
|
19
|
+
const SHRINK_ORDER = [2, 3, 0, 1];
|
|
20
|
+
const SESSION_ID_MAX_BYTES = 120;
|
|
21
|
+
function matchBlock(markdown, render = {}) {
|
|
22
|
+
return matchAuditBlock(markdown, [GROUP_HEADER, EVIDENCE, ROOT_DIAGNOSTIC, DISPOSITION], FIELD_PREFIXES, { ...render, shrinkOrder: SHRINK_ORDER });
|
|
23
|
+
}
|
|
24
|
+
/** A conforming group that reports dropped groups, so omission is never silent. */
|
|
25
|
+
function omissionGroup(count) {
|
|
26
|
+
return [
|
|
27
|
+
`- \`one-off\` — \`GitHub Actions\` — ${count} further failure groups omitted to fit the size limit; see journal`,
|
|
28
|
+
' - Evidence: omitted groups remain in the session journal',
|
|
29
|
+
' - Root diagnostic: omitted to fit the retrospective size limit',
|
|
30
|
+
' - Disposition: see journal for the omitted failure groups',
|
|
31
|
+
].join('\n');
|
|
18
32
|
}
|
|
19
33
|
export function isConformingCiFailureBlock(markdown) {
|
|
20
34
|
if (markdown.length > CI_FAILURE_BLOCK_MAX_BYTES ||
|
|
@@ -22,8 +36,8 @@ export function isConformingCiFailureBlock(markdown) {
|
|
|
22
36
|
return false;
|
|
23
37
|
return matchBlock(markdown) !== null;
|
|
24
38
|
}
|
|
25
|
-
export function getConformingGroups(entries) {
|
|
26
|
-
return conformingBlocks(entries, matchBlock);
|
|
39
|
+
export function getConformingGroups(entries, redact, maxBytes = CI_SECTION_MAX_BYTES) {
|
|
40
|
+
return conformingBlocks(entries, matchBlock, Math.min(maxBytes, CI_SECTION_MAX_BYTES), redact, omissionGroup);
|
|
27
41
|
}
|
|
28
42
|
export function incompleteCiSection(markdownBlocks) {
|
|
29
43
|
return [
|
|
@@ -35,7 +49,7 @@ export function buildCiFailuresSection(sessionId, journal, frictionStatus) {
|
|
|
35
49
|
if (journal.status === 'unreachable')
|
|
36
50
|
return `${CI_FAILURES_HEADER}\nStatus: unavailable (blackboard unreachable)`;
|
|
37
51
|
if (journal.markdownBlocks.length === 0 && frictionStatus === 'absent')
|
|
38
|
-
return `${CI_FAILURES_HEADER}\nStatus: unavailable (no friction log for session ${markdownAuditText(sessionId)})`;
|
|
52
|
+
return `${CI_FAILURES_HEADER}\nStatus: unavailable (no friction log for session ${markdownAuditText(sessionId, undefined, SESSION_ID_MAX_BYTES)})`;
|
|
39
53
|
if (journal.markdownBlocks.length === 0)
|
|
40
54
|
return `${CI_FAILURES_HEADER}\nStatus: none observed`;
|
|
41
55
|
return `${CI_FAILURES_HEADER}\nStatus: failures observed\n\n${journal.markdownBlocks.join('\n\n')}`;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { classifyFrictionObservation } from './classify.mts';
|
|
2
2
|
export { normalizeCommandPrefix } from './normalize.mts';
|
|
3
3
|
export { FRICTION_LOG_MAX_EVENTS, readFrictionLog, recordFriction } from './log.mts';
|
|
4
|
-
export { buildSessionFrictionReport } from './report.mts';
|
|
4
|
+
export { buildSessionFrictionReport, loadFrictionReportInputs, renderFrictionReport, type FrictionReportInputs, } from './report.mts';
|
|
5
5
|
export { isConformingCiFailureBlock } from './ci-failures.mts';
|
|
6
6
|
export type { FrictionEvent, FrictionEventKind, FrictionLogOptions, FrictionLogReadResult, FrictionObservation, JournalEntry, JournalLoadResult, JournalLoader, PermissionRequestObservation, SessionFrictionReport, SessionFrictionCoverage, SessionFrictionReportOptions, ToolResultObservation, } from './types.mts';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { classifyFrictionObservation } from "./classify.mjs";
|
|
2
2
|
export { normalizeCommandPrefix } from "./normalize.mjs";
|
|
3
3
|
export { FRICTION_LOG_MAX_EVENTS, readFrictionLog, recordFriction } from "./log.mjs";
|
|
4
|
-
export { buildSessionFrictionReport } from "./report.mjs";
|
|
4
|
+
export { buildSessionFrictionReport, loadFrictionReportInputs, renderFrictionReport, } from "./report.mjs";
|
|
5
5
|
export { isConformingCiFailureBlock } from "./ci-failures.mjs";
|
|
@@ -1,2 +1,16 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { type JournalScan } from './journal.mts';
|
|
2
|
+
import type { FrictionLogReadResult, SessionFrictionReport, SessionFrictionReportOptions } from './types.mts';
|
|
3
|
+
type FrictionRead = {
|
|
4
|
+
result: FrictionLogReadResult;
|
|
5
|
+
} | {
|
|
6
|
+
error: unknown;
|
|
7
|
+
};
|
|
8
|
+
/** Everything a report reads from outside, loaded once so rendering can be repeated for free. */
|
|
9
|
+
export type FrictionReportInputs = {
|
|
10
|
+
scanned: JournalScan;
|
|
11
|
+
read: FrictionRead;
|
|
12
|
+
};
|
|
13
|
+
export declare function loadFrictionReportInputs(sessionId: string, options: SessionFrictionReportOptions): Promise<FrictionReportInputs>;
|
|
14
|
+
export declare function renderFrictionReport(sessionId: string, options: SessionFrictionReportOptions, { scanned, read }: FrictionReportInputs): SessionFrictionReport;
|
|
2
15
|
export declare function buildSessionFrictionReport(sessionId: string, options: SessionFrictionReportOptions): Promise<SessionFrictionReport>;
|
|
16
|
+
export {};
|
|
@@ -3,17 +3,17 @@ import { buildCiFailuresSection, getConformingGroups, incompleteCiSection } from
|
|
|
3
3
|
import { errorMessage, scanJournal } from "./journal.mjs";
|
|
4
4
|
import { buildSandboxSection } from "./sandbox.mjs";
|
|
5
5
|
import { validateSessionId } from "./session-id.mjs";
|
|
6
|
-
function sandboxMarkdown(friction) {
|
|
6
|
+
function sandboxMarkdown(friction, options) {
|
|
7
7
|
if (friction.status === 'events')
|
|
8
|
-
return `${friction.truncated ? `## Capture Coverage\nStatus: partial\nDropped records: ${friction.droppedCount}\n\n` : ''}${buildSandboxSection(friction.events)}`;
|
|
8
|
+
return `${friction.truncated ? `## Capture Coverage\nStatus: partial\nDropped records: ${friction.droppedCount}\n\n` : ''}${buildSandboxSection(friction.events, options.redact, options.budgets?.sandboxBytes)}`;
|
|
9
9
|
if (friction.status === 'empty' && friction.truncated)
|
|
10
10
|
return `## Sandbox & Permission Audit\nStatus: unavailable (partial capture; dropped ${friction.droppedCount} records)`;
|
|
11
11
|
const status = friction.status === 'empty' ? 'none observed' : 'unavailable (no friction log)';
|
|
12
12
|
return `## Sandbox & Permission Audit\nStatus: ${status}`;
|
|
13
13
|
}
|
|
14
|
-
function reportFromLog(sessionId, logOptions, journal) {
|
|
15
|
-
|
|
16
|
-
const friction =
|
|
14
|
+
function reportFromLog(sessionId, logOptions, journal, read) {
|
|
15
|
+
if ('result' in read) {
|
|
16
|
+
const friction = read.result;
|
|
17
17
|
const coverage = {
|
|
18
18
|
journalStatus: journal.status === 'unreachable'
|
|
19
19
|
? 'unavailable'
|
|
@@ -27,12 +27,14 @@ function reportFromLog(sessionId, logOptions, journal) {
|
|
|
27
27
|
if (journal.status === 'ok' && journal.truncated)
|
|
28
28
|
return {
|
|
29
29
|
coverage,
|
|
30
|
-
markdown: incompleteCiSection(journal.markdownBlocks) +
|
|
30
|
+
markdown: incompleteCiSection(journal.markdownBlocks) +
|
|
31
|
+
'\n\n' +
|
|
32
|
+
sandboxMarkdown(friction, logOptions),
|
|
31
33
|
};
|
|
32
34
|
const markdown = buildCiFailuresSection(sessionId, journal, friction.status);
|
|
33
|
-
return { coverage, markdown: `${markdown}\n\n${sandboxMarkdown(friction)}` };
|
|
35
|
+
return { coverage, markdown: `${markdown}\n\n${sandboxMarkdown(friction, logOptions)}` };
|
|
34
36
|
}
|
|
35
|
-
|
|
37
|
+
else {
|
|
36
38
|
const ciMarkdown = journal.status === 'ok' && journal.truncated
|
|
37
39
|
? incompleteCiSection(journal.markdownBlocks)
|
|
38
40
|
: journal.status === 'ok' && journal.markdownBlocks.length === 0
|
|
@@ -49,25 +51,33 @@ function reportFromLog(sessionId, logOptions, journal) {
|
|
|
49
51
|
truncated: true,
|
|
50
52
|
},
|
|
51
53
|
markdown: `${ciMarkdown}\n\n## Sandbox & Permission Audit\nStatus: unavailable (friction log unreadable)`,
|
|
52
|
-
diagnostic: errorMessage(error),
|
|
54
|
+
diagnostic: errorMessage(read.error),
|
|
53
55
|
};
|
|
54
56
|
}
|
|
55
57
|
}
|
|
56
|
-
export async function
|
|
58
|
+
export async function loadFrictionReportInputs(sessionId, options) {
|
|
57
59
|
validateSessionId(sessionId);
|
|
58
60
|
requireDirectory(options.directory);
|
|
59
61
|
eventLimit(options.maxEvents);
|
|
60
62
|
const scanned = await scanJournal(sessionId, options.journalLoader);
|
|
63
|
+
try {
|
|
64
|
+
return { scanned, read: { result: readFrictionLog(sessionId, options) } };
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
return { scanned, read: { error } };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export function renderFrictionReport(sessionId, options, { scanned, read }) {
|
|
61
71
|
const journal = scanned.status === 'unreachable'
|
|
62
72
|
? scanned
|
|
63
73
|
: scanned.status === 'not-found'
|
|
64
74
|
? { status: 'ok', markdownBlocks: [], truncated: false }
|
|
65
75
|
: {
|
|
66
76
|
status: 'ok',
|
|
67
|
-
markdownBlocks: getConformingGroups(scanned.entries),
|
|
77
|
+
markdownBlocks: getConformingGroups(scanned.entries, options.redact, options.budgets?.ciBytes),
|
|
68
78
|
truncated: scanned.truncated,
|
|
69
79
|
};
|
|
70
|
-
const report = reportFromLog(sessionId, options, journal);
|
|
80
|
+
const report = reportFromLog(sessionId, options, journal, read);
|
|
71
81
|
if (journal.status === 'unreachable') {
|
|
72
82
|
const diagnostic = report.diagnostic
|
|
73
83
|
? `${journal.diagnostic}; ${report.diagnostic}`
|
|
@@ -76,3 +86,6 @@ export async function buildSessionFrictionReport(sessionId, options) {
|
|
|
76
86
|
}
|
|
77
87
|
return report;
|
|
78
88
|
}
|
|
89
|
+
export async function buildSessionFrictionReport(sessionId, options) {
|
|
90
|
+
return renderFrictionReport(sessionId, options, await loadFrictionReportInputs(sessionId, options));
|
|
91
|
+
}
|
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
import type { FrictionEvent } from './types.mts';
|
|
2
|
-
|
|
2
|
+
import { type AuditRedactor } from './text.mts';
|
|
3
|
+
export declare function buildSandboxSection(events: FrictionEvent[], redact?: AuditRedactor, maxBytes?: number): string;
|
|
@@ -1,22 +1,43 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { byteLength, fitFields, selectWithin } from "./audit-fit.mjs";
|
|
2
|
+
import { markdownAuditUnits } from "./text.mjs";
|
|
2
3
|
const SANDBOX_SECTION_HEADER = '## Sandbox & Permission Audit';
|
|
4
|
+
const SANDBOX_SECTION_MAX_BYTES = 3_000;
|
|
5
|
+
// Room for the `- kind (n)` group lines, which are rendered outside the per-event budget.
|
|
6
|
+
const GROUP_LINES_BYTES = 120;
|
|
7
|
+
// Below this an event line would keep little beyond its timestamp, so it is omitted and counted.
|
|
8
|
+
const MIN_EVENT_SHARE_BYTES = 120;
|
|
9
|
+
// Event line fields: command prefix, detail, timestamp. Cut detail first, the timestamp never.
|
|
10
|
+
const SHRINK_ORDER = [1, 0];
|
|
3
11
|
const KIND_ORDER = [
|
|
4
12
|
'sandbox-escalation',
|
|
5
13
|
'sandbox-failure',
|
|
6
14
|
'ambiguous-failure',
|
|
7
15
|
];
|
|
8
|
-
|
|
16
|
+
/** Reports dropped events as a group in the same shape as the rest, so omission is never silent. */
|
|
17
|
+
function omissionGroup(count) {
|
|
18
|
+
return `- omitted (${count})\n - see journal — ${count} events omitted to fit the size limit — n/a`;
|
|
19
|
+
}
|
|
20
|
+
export function buildSandboxSection(events, redact, maxBytes = SANDBOX_SECTION_MAX_BYTES) {
|
|
9
21
|
const observations = events.flatMap((event) => event.failure
|
|
10
22
|
? [event, { ...event, kind: event.failure.kind, detail: event.failure.detail }]
|
|
11
23
|
: [event]);
|
|
24
|
+
const render = (event, budgetBytes) => {
|
|
25
|
+
const outcome = event.kind === 'sandbox-escalation' ? ` — outcome: ${event.outcome ?? 'unknown'}` : '';
|
|
26
|
+
const [prefix, detail, timestamp] = fitFields([event.commandPrefix, event.detail, event.timestamp].map((field) => markdownAuditUnits(field, redact)), byteLength(` - — — ${outcome}`), budgetBytes, SHRINK_ORDER);
|
|
27
|
+
return ` - ${prefix} — ${detail} — ${timestamp}${outcome}`;
|
|
28
|
+
};
|
|
29
|
+
const total = Math.max(0, Math.min(maxBytes, SANDBOX_SECTION_MAX_BYTES) - GROUP_LINES_BYTES);
|
|
30
|
+
const { rendered, omitted } = selectWithin(observations, total, byteLength(omissionGroup(observations.length)) + 1, 1, MIN_EVENT_SHARE_BYTES, render);
|
|
31
|
+
const kept = observations
|
|
32
|
+
.slice(0, rendered.length)
|
|
33
|
+
.map((event, i) => ({ event, line: rendered[i] }));
|
|
12
34
|
const groups = KIND_ORDER.map((kind) => {
|
|
13
|
-
const selected =
|
|
35
|
+
const selected = kept.filter(({ event }) => event.kind === kind);
|
|
14
36
|
return selected.length
|
|
15
|
-
? [
|
|
16
|
-
`- ${kind} (${selected.length})`,
|
|
17
|
-
...selected.map((event) => ` - ${markdownAuditText(event.commandPrefix)} — ${markdownAuditText(event.detail)} — ${markdownAuditText(event.timestamp)}${event.kind === 'sandbox-escalation' ? ` — outcome: ${event.outcome ?? 'unknown'}` : ''}`),
|
|
18
|
-
].join('\n')
|
|
37
|
+
? [`- ${kind} (${selected.length})`, ...selected.map(({ line }) => line)].join('\n')
|
|
19
38
|
: undefined;
|
|
20
39
|
}).filter((group) => group !== undefined);
|
|
40
|
+
if (omitted)
|
|
41
|
+
groups.push(omissionGroup(omitted));
|
|
21
42
|
return `${SANDBOX_SECTION_HEADER}\nEvents observed: ${observations.length}\n\n${groups.join('\n')}`;
|
|
22
43
|
}
|
|
@@ -2,4 +2,17 @@ export declare function normalizeAuditText(value: string): string;
|
|
|
2
2
|
export declare function boundedText(value: string, maximum: number): string;
|
|
3
3
|
export declare function isWellFormedUnicode(value: string): boolean;
|
|
4
4
|
export declare function isSafeAuditText(value: unknown): value is string;
|
|
5
|
-
export
|
|
5
|
+
export type AuditRedactor = (value: string) => string;
|
|
6
|
+
/**
|
|
7
|
+
* Splits a field into atomic Markdown-escaped units (one code point, plus its backslash when
|
|
8
|
+
* escaped), so a later cut can never land inside an escape sequence. Redaction runs on the raw
|
|
9
|
+
* text first: escaping inserts backslashes that would otherwise break secret and token matching.
|
|
10
|
+
* normalizeAuditText trims and escaping adds no whitespace, so the result has none at its edges.
|
|
11
|
+
*/
|
|
12
|
+
export declare function markdownAuditUnits(value: string, redact?: AuditRedactor): string[];
|
|
13
|
+
/**
|
|
14
|
+
* Joins escaped units whole, or, when over `maxBytes`, keeps only complete whitespace-separated
|
|
15
|
+
* tokens (so URLs and SHAs are never split) and marks the cut. Never ends in whitespace.
|
|
16
|
+
*/
|
|
17
|
+
export declare function fitUnits(units: string[], maxBytes: number, atTokens?: boolean): string;
|
|
18
|
+
export declare function markdownAuditText(value: string, redact?: AuditRedactor, maxBytes?: number): string;
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
const CONTROL_CHARACTERS = /[\p{Cc}\p{Cf}]+/gu;
|
|
2
2
|
const MARKDOWN_CHARACTER = /\\|`|\*|_|~|\[|\]|<|>|#|-|\+|!|\||&/;
|
|
3
|
-
const MARKDOWN_AUDIT_MAX_LENGTH = 120;
|
|
4
3
|
export function normalizeAuditText(value) {
|
|
5
4
|
return value.replace(CONTROL_CHARACTERS, ' ').trim();
|
|
6
5
|
}
|
|
@@ -32,14 +31,40 @@ export function isSafeAuditText(value) {
|
|
|
32
31
|
isWellFormedUnicode(value) &&
|
|
33
32
|
value === normalizeAuditText(value));
|
|
34
33
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Splits a field into atomic Markdown-escaped units (one code point, plus its backslash when
|
|
36
|
+
* escaped), so a later cut can never land inside an escape sequence. Redaction runs on the raw
|
|
37
|
+
* text first: escaping inserts backslashes that would otherwise break secret and token matching.
|
|
38
|
+
* normalizeAuditText trims and escaping adds no whitespace, so the result has none at its edges.
|
|
39
|
+
*/
|
|
40
|
+
export function markdownAuditUnits(value, redact) {
|
|
41
|
+
const raw = normalizeAuditText(value);
|
|
42
|
+
return Array.from(redact ? normalizeAuditText(redact(raw)) : raw).map((character) => MARKDOWN_CHARACTER.test(character) ? `\\${character}` : character);
|
|
43
|
+
}
|
|
44
|
+
const CUT_MARKER = '…';
|
|
45
|
+
const WHITESPACE = /^\s$/u;
|
|
46
|
+
/**
|
|
47
|
+
* Joins escaped units whole, or, when over `maxBytes`, keeps only complete whitespace-separated
|
|
48
|
+
* tokens (so URLs and SHAs are never split) and marks the cut. Never ends in whitespace.
|
|
49
|
+
*/
|
|
50
|
+
export function fitUnits(units, maxBytes, atTokens = true) {
|
|
51
|
+
const whole = units.join('');
|
|
52
|
+
if (Buffer.byteLength(whole) <= maxBytes)
|
|
53
|
+
return whole;
|
|
54
|
+
const room = maxBytes - Buffer.byteLength(` ${CUT_MARKER}`);
|
|
55
|
+
let used = 0;
|
|
56
|
+
let boundary = 0;
|
|
57
|
+
for (const [index, unit] of units.entries()) {
|
|
58
|
+
used += Buffer.byteLength(unit);
|
|
59
|
+
if (used > room)
|
|
41
60
|
break;
|
|
42
|
-
|
|
61
|
+
if (!atTokens || WHITESPACE.test(unit))
|
|
62
|
+
boundary = atTokens ? index : index + 1;
|
|
43
63
|
}
|
|
44
|
-
|
|
64
|
+
const kept = units.slice(0, boundary).join('').trimEnd();
|
|
65
|
+
return kept ? `${kept}${atTokens ? ' ' : ''}${CUT_MARKER}` : CUT_MARKER;
|
|
66
|
+
}
|
|
67
|
+
export function markdownAuditText(value, redact, maxBytes = Infinity) {
|
|
68
|
+
// Used for identifiers, which have no token boundaries to cut at.
|
|
69
|
+
return fitUnits(markdownAuditUnits(value, redact), maxBytes, false);
|
|
45
70
|
}
|
|
@@ -69,5 +69,12 @@ export type SessionFrictionReport = {
|
|
|
69
69
|
};
|
|
70
70
|
export type SessionFrictionReportOptions = FrictionLogOptions & {
|
|
71
71
|
journalLoader: JournalLoader;
|
|
72
|
+
/** Redacts raw field text before Markdown escaping, which would otherwise break matching. */
|
|
73
|
+
redact?: (value: string) => string;
|
|
74
|
+
/** Byte allowances for the CI and sandbox sections, as maximums over the built-in budgets. */
|
|
75
|
+
budgets?: {
|
|
76
|
+
ciBytes: number;
|
|
77
|
+
sandboxBytes: number;
|
|
78
|
+
};
|
|
72
79
|
};
|
|
73
80
|
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vouchington-tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.1",
|
|
4
4
|
"description": "Vouchington CLI and extractable tooling libraries.",
|
|
5
5
|
"homepage": "https://github.com/vouchington/vouchington-tooling/tree/main/packages/vouchington-tooling#readme",
|
|
6
6
|
"bugs": {
|