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.
- package/README.md +30 -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/dist/shared-context/source-graph-owner.d.mts +4 -0
- package/dist/shared-context/source-graph-owner.mjs +67 -0
- package/dist/shared-context/source-graph-resolve.d.mts +8 -0
- package/dist/shared-context/source-graph-resolve.mjs +60 -0
- package/dist/shared-context/source-graph.d.mts +24 -0
- package/dist/shared-context/source-graph.mjs +86 -0
- package/dist/source-module-graph/index.d.mts +2 -0
- package/dist/source-module-graph/index.mjs +1 -0
- package/package.json +11 -1
- 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
|
|
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
|
-
/**
|
|
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';
|