vouchington-tooling 0.22.1 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +102 -4
- package/dist/agent-blackboard/client.d.mts +34 -0
- package/dist/agent-blackboard/client.mjs +41 -0
- package/dist/agent-blackboard/feedback-canonical.d.mts +1 -0
- package/dist/agent-blackboard/feedback-canonical.mjs +13 -0
- package/dist/agent-blackboard/feedback-codec.d.mts +7 -0
- package/dist/agent-blackboard/feedback-codec.mjs +121 -0
- package/dist/agent-blackboard/feedback-compose.d.mts +37 -0
- package/dist/agent-blackboard/feedback-compose.mjs +109 -0
- package/dist/agent-blackboard/feedback-deadline.d.mts +1 -0
- package/dist/agent-blackboard/feedback-deadline.mjs +18 -0
- package/dist/agent-blackboard/feedback-delivery.d.mts +18 -0
- package/dist/agent-blackboard/feedback-delivery.mjs +88 -0
- package/dist/agent-blackboard/feedback-fields.d.mts +4 -0
- package/dist/agent-blackboard/feedback-fields.mjs +38 -0
- package/dist/agent-blackboard/feedback-identity.d.mts +2 -0
- package/dist/agent-blackboard/feedback-identity.mjs +29 -0
- package/dist/agent-blackboard/feedback-online-error.d.mts +7 -0
- package/dist/agent-blackboard/feedback-online-error.mjs +23 -0
- package/dist/agent-blackboard/feedback-online.d.mts +2 -0
- package/dist/agent-blackboard/feedback-online.mjs +137 -0
- package/dist/agent-blackboard/feedback-outbox.d.mts +8 -0
- package/dist/agent-blackboard/feedback-outbox.mjs +157 -0
- package/dist/agent-blackboard/feedback-types.d.mts +66 -0
- package/dist/agent-blackboard/feedback-types.mjs +1 -0
- package/dist/agent-blackboard/index.d.mts +16 -39
- package/dist/agent-blackboard/index.mjs +35 -81
- package/dist/cli/commands/agent-blackboard.mjs +35 -4
- package/dist/cli/usage.d.mts +1 -1
- package/dist/cli/usage.mjs +3 -1
- package/dist/markdown/details-scope.d.mts +13 -0
- package/dist/markdown/details-scope.mjs +73 -0
- package/dist/markdown/document-sections.d.mts +3 -0
- package/dist/markdown/document-sections.mjs +64 -0
- package/dist/markdown/document-types.d.mts +21 -0
- package/dist/markdown/document-types.mjs +1 -0
- package/dist/markdown/index.d.mts +3 -0
- package/dist/markdown/index.mjs +2 -0
- package/dist/markdown/validate-sections.d.mts +3 -0
- package/dist/markdown/validate-sections.mjs +34 -0
- package/dist/retrospective-facts/coverage.d.mts +6 -0
- package/dist/retrospective-facts/coverage.mjs +42 -0
- package/dist/retrospective-facts/index.d.mts +3 -0
- package/dist/retrospective-facts/index.mjs +18 -1
- package/dist/retrospective-transcript/codex.mjs +8 -3
- package/dist/retrospective-transcript/index.d.mts +5 -0
- package/dist/retrospective-transcript/index.mjs +29 -9
- package/dist/session-friction/classify.mjs +25 -14
- package/dist/session-friction/coverage-ledger.d.mts +10 -0
- package/dist/session-friction/coverage-ledger.mjs +75 -0
- package/dist/session-friction/index.d.mts +1 -1
- package/dist/session-friction/log-file.d.mts +2 -2
- package/dist/session-friction/log-file.mjs +22 -10
- package/dist/session-friction/log.mjs +57 -10
- package/dist/session-friction/report.mjs +36 -7
- package/dist/session-friction/sandbox.mjs +11 -4
- package/dist/session-friction/types.d.mts +25 -4
- package/dist/workflow-policy/index.d.mts +1 -1
- package/dist/workflow-policy/index.mjs +1 -1
- package/dist/workflow-policy/permission-declarations.d.mts +14 -0
- package/dist/workflow-policy/permission-declarations.mjs +55 -0
- package/dist/workflow-policy/permission-inheritance.d.mts +8 -0
- package/dist/workflow-policy/permission-inheritance.mjs +88 -0
- package/dist/workflow-policy/permission-mismatches.d.mts +4 -1
- package/dist/workflow-policy/permission-mismatches.mjs +14 -3
- package/docs/workflow-and-compiler.md +18 -1
- package/package.json +1 -1
- package/skills/agent-workflow/SKILL.md +5 -0
- package/skills/agent-workflow/references/review.md +3 -0
- package/skills/blackboard/SKILL.md +34 -14
- package/skills/manifest.json +21 -21
- package/skills/pr-description/SKILL.md +44 -10
- package/skills/pr-description/references/examples.md +136 -0
- package/skills/retrospective/SKILL.md +27 -16
- package/skills/retrospective-distill/SKILL.md +15 -6
- package/skills/review-ci-logs/SKILL.md +10 -0
|
@@ -12,6 +12,12 @@ export { codexChildren, codexIdentity } from './codex.mjs';
|
|
|
12
12
|
export { formatTranscriptFacts, formatUnavailable } from './format.mjs';
|
|
13
13
|
export { resolveTranscriptFile } from './resolve-transcript-file.mjs';
|
|
14
14
|
const MALFORMED_INTERIOR = 'malformed interior transcript record';
|
|
15
|
+
function partialCapture(lines) {
|
|
16
|
+
return parseLines(lines).length !== lines.filter((line) => line.trim()).length;
|
|
17
|
+
}
|
|
18
|
+
function unavailable(reason) {
|
|
19
|
+
return { markdown: formatUnavailable(reason), coverage: 'unavailable' };
|
|
20
|
+
}
|
|
15
21
|
function schema(lines) {
|
|
16
22
|
const records = parseLines(lines);
|
|
17
23
|
const kinds = new Set(records
|
|
@@ -74,7 +80,7 @@ async function codexSubagents(lines, sessionsDir, ownerPath, visited = new Set()
|
|
|
74
80
|
const segment = segmentCodex(child);
|
|
75
81
|
if (!segment)
|
|
76
82
|
return undefined;
|
|
77
|
-
result.push(segment);
|
|
83
|
+
result.push({ ...segment, partial: partialCapture(child) });
|
|
78
84
|
const nested = await codexSubagents(segment.lines, sessionsDir, edge.agentPath, visited);
|
|
79
85
|
if (!nested)
|
|
80
86
|
return undefined;
|
|
@@ -83,31 +89,45 @@ async function codexSubagents(lines, sessionsDir, ownerPath, visited = new Set()
|
|
|
83
89
|
return result;
|
|
84
90
|
}
|
|
85
91
|
export async function runRetrospectiveTranscript(options) {
|
|
92
|
+
return (await runRetrospectiveTranscriptReport(options)).markdown;
|
|
93
|
+
}
|
|
94
|
+
export async function runRetrospectiveTranscriptReport(options) {
|
|
86
95
|
const resolved = resolveTranscriptFile(options);
|
|
87
96
|
if ('error' in resolved)
|
|
88
|
-
return
|
|
97
|
+
return unavailable(resolved.error);
|
|
89
98
|
const lines = await readLines(resolved.path);
|
|
90
99
|
if (!lines)
|
|
91
|
-
return
|
|
100
|
+
return unavailable('could not read transcript');
|
|
92
101
|
const detected = schema(lines);
|
|
93
102
|
if (!detected)
|
|
94
|
-
return
|
|
103
|
+
return unavailable('unsupported or mixed transcript schema');
|
|
95
104
|
if (hasMalformedInteriorRecord(lines))
|
|
96
|
-
return
|
|
105
|
+
return unavailable(MALFORMED_INTERIOR);
|
|
97
106
|
if (detected === 'claude') {
|
|
98
107
|
const directory = join(dirname(resolved.path), basename(resolved.path, '.jsonl'), 'subagents');
|
|
99
108
|
const subagents = await Promise.all(globFrom(directory, '*.jsonl').sort().map(readLines));
|
|
100
|
-
|
|
109
|
+
const valid = subagents.filter((value) => value !== undefined && schema(value) === 'claude' && !hasMalformedInteriorRecord(value));
|
|
110
|
+
return {
|
|
111
|
+
markdown: formatTranscriptFacts(resolved.sessionId, computeTranscriptFacts(lines, valid)),
|
|
112
|
+
coverage: valid.length === subagents.length && !partialCapture(lines) && !valid.some(partialCapture)
|
|
113
|
+
? 'complete'
|
|
114
|
+
: 'partial',
|
|
115
|
+
};
|
|
101
116
|
}
|
|
102
117
|
const identity = codexIdentity(lines);
|
|
103
118
|
const root = segmentCodex(lines);
|
|
104
119
|
if (!root)
|
|
105
|
-
return
|
|
120
|
+
return unavailable('could not segment Codex transcript');
|
|
106
121
|
const codexHome = (options.env ?? process.env).CODEX_HOME;
|
|
107
122
|
const subagents = await codexSubagents(root.lines, options.codexSessionsDir ??
|
|
108
123
|
(options.jsonlPath ? dirname(resolved.path) : undefined) ??
|
|
109
124
|
join(codexHome || join(homedir(), '.codex'), 'sessions'), identity.agentPath, new Set([identity.threadId ?? resolved.sessionId].filter((threadId) => SESSION_ID.test(threadId))));
|
|
110
125
|
if (!subagents)
|
|
111
|
-
return
|
|
112
|
-
return
|
|
126
|
+
return unavailable('could not resolve a referenced Codex child transcript');
|
|
127
|
+
return {
|
|
128
|
+
markdown: formatTranscriptFacts(resolved.sessionId, computeCodex(root.lines, subagents, root.baseline)),
|
|
129
|
+
coverage: partialCapture(lines) || subagents.some((segment) => segment.partial)
|
|
130
|
+
? 'partial'
|
|
131
|
+
: 'complete',
|
|
132
|
+
};
|
|
113
133
|
}
|
|
@@ -47,7 +47,12 @@ export function classifyFrictionObservation(observation) {
|
|
|
47
47
|
if (commandPrefix === '' || !isWellFormedUnicode(commandPrefix))
|
|
48
48
|
return null;
|
|
49
49
|
if (observation.type === 'permission-request') {
|
|
50
|
-
return {
|
|
50
|
+
return {
|
|
51
|
+
kind: 'sandbox-escalation',
|
|
52
|
+
commandPrefix,
|
|
53
|
+
detail: 'permission-request',
|
|
54
|
+
outcome: 'requested',
|
|
55
|
+
};
|
|
51
56
|
}
|
|
52
57
|
if (observation.type !== 'tool-result')
|
|
53
58
|
return null;
|
|
@@ -57,31 +62,37 @@ export function classifyFrictionObservation(observation) {
|
|
|
57
62
|
const escalationDetail = normalizeAuditText(boundedText(observation.escalationDetail ?? '', DETAIL_MAX_LENGTH));
|
|
58
63
|
if (!isWellFormedUnicode(escalationDetail))
|
|
59
64
|
return null;
|
|
60
|
-
if (
|
|
61
|
-
|
|
65
|
+
if (observation.permissionOutcome !== undefined &&
|
|
66
|
+
!['approved', 'denied'].includes(observation.permissionOutcome))
|
|
67
|
+
return null;
|
|
68
|
+
const permission = escalationDetail || observation.permissionOutcome
|
|
69
|
+
? {
|
|
62
70
|
kind: 'sandbox-escalation',
|
|
63
71
|
commandPrefix,
|
|
64
|
-
detail: escalationDetail
|
|
65
|
-
|
|
66
|
-
|
|
72
|
+
detail: escalationDetail || `permission-${observation.permissionOutcome}`,
|
|
73
|
+
outcome: observation.permissionOutcome ?? 'unknown',
|
|
74
|
+
}
|
|
75
|
+
: null;
|
|
67
76
|
const rawStderr = observation.structuredStderr;
|
|
68
77
|
if (rawStderr !== undefined && (typeof rawStderr !== 'string' || !isWellFormedUnicode(rawStderr)))
|
|
69
78
|
return null;
|
|
70
79
|
const stderr = rawStderr ? boundedStderr(rawStderr) : rawStderr;
|
|
71
80
|
if (stderr === undefined)
|
|
72
|
-
return
|
|
81
|
+
return permission;
|
|
73
82
|
const token = FAILURE_TOKENS.find(([, pattern]) => pattern.test(stderr))?.[0];
|
|
74
|
-
if (token)
|
|
75
|
-
|
|
83
|
+
if (token) {
|
|
84
|
+
const failure = { kind: 'sandbox-failure', detail: `stderr matched "${token}"` };
|
|
85
|
+
return permission ? { ...permission, failure } : { ...failure, commandPrefix };
|
|
86
|
+
}
|
|
76
87
|
if (stderr.split(/\r\n|[\r\n]/).some((rawLine) => {
|
|
77
88
|
const line = withoutUrlUserinfo(rawLine);
|
|
78
89
|
return (CONNECTION_REFUSED.test(line) && LOOPBACK_ADDRESSES.some((pattern) => pattern.test(line)));
|
|
79
90
|
})) {
|
|
80
|
-
|
|
81
|
-
kind: '
|
|
82
|
-
|
|
83
|
-
detail: 'stderr matched localhost connection failure',
|
|
91
|
+
const failure = {
|
|
92
|
+
kind: 'ambiguous-failure',
|
|
93
|
+
detail: 'localhost connection refused; sandbox cause unverified',
|
|
84
94
|
};
|
|
95
|
+
return permission ? { ...permission, failure } : { ...failure, commandPrefix };
|
|
85
96
|
}
|
|
86
|
-
return
|
|
97
|
+
return permission;
|
|
87
98
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type CoverageLedger = {
|
|
2
|
+
schemaVersion: 1;
|
|
3
|
+
retainedEvents: number;
|
|
4
|
+
logBytes: number;
|
|
5
|
+
droppedCount: number;
|
|
6
|
+
};
|
|
7
|
+
/** Caller holds the event log lock; incomplete atomic updates must never imply complete capture. */
|
|
8
|
+
export declare function readCoverageLedger(path: string): CoverageLedger | undefined;
|
|
9
|
+
/** The fixed-size atomic sidecar keeps saturation work independent of the accumulated drop count. */
|
|
10
|
+
export declare function writeCoverageLedger(path: string, ledger: CoverageLedger): void;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { closeSync, constants, fsyncSync, lstatSync, openSync, renameSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { openLogFile, readLogContent, writeAll, LOG_MAX_BYTES } from './log-file.mjs';
|
|
4
|
+
const LEDGER_MAX_BYTES = 192;
|
|
5
|
+
function missing(error) {
|
|
6
|
+
return !!error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT';
|
|
7
|
+
}
|
|
8
|
+
/** Caller holds the event log lock; incomplete atomic updates must never imply complete capture. */
|
|
9
|
+
export function readCoverageLedger(path) {
|
|
10
|
+
try {
|
|
11
|
+
lstatSync(`${path}.coverage.pending`);
|
|
12
|
+
throw new Error('session-friction coverage update is incomplete');
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
if (!missing(error))
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
let descriptor;
|
|
19
|
+
try {
|
|
20
|
+
descriptor = openLogFile(`${path}.coverage`, constants.O_RDONLY);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (missing(error))
|
|
24
|
+
return undefined;
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const value = JSON.parse(readLogContent(descriptor, LEDGER_MAX_BYTES));
|
|
29
|
+
if (!value || typeof value !== 'object')
|
|
30
|
+
throw new Error('invalid session-friction coverage ledger');
|
|
31
|
+
// oxlint-disable-next-line no-mistakes/ts-no-const-aliases -- establish a validated object view
|
|
32
|
+
const item = value;
|
|
33
|
+
if (Object.keys(item).length !== 4 ||
|
|
34
|
+
item.schemaVersion !== 1 ||
|
|
35
|
+
!Number.isSafeInteger(item.retainedEvents) ||
|
|
36
|
+
item.retainedEvents < 0 ||
|
|
37
|
+
item.retainedEvents > 500 ||
|
|
38
|
+
!Number.isSafeInteger(item.logBytes) ||
|
|
39
|
+
item.logBytes < 0 ||
|
|
40
|
+
item.logBytes > LOG_MAX_BYTES ||
|
|
41
|
+
!Number.isSafeInteger(item.droppedCount) ||
|
|
42
|
+
item.droppedCount < 1)
|
|
43
|
+
throw new Error('invalid session-friction coverage ledger');
|
|
44
|
+
return item;
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
closeSync(descriptor);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** The fixed-size atomic sidecar keeps saturation work independent of the accumulated drop count. */
|
|
51
|
+
export function writeCoverageLedger(path, ledger) {
|
|
52
|
+
if (!Number.isSafeInteger(ledger.droppedCount))
|
|
53
|
+
throw new Error('session-friction dropped count overflow; capture incomplete');
|
|
54
|
+
const temporary = `${path}.coverage.pending`;
|
|
55
|
+
const descriptor = openSync(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
|
|
56
|
+
try {
|
|
57
|
+
syncDirectory(path);
|
|
58
|
+
writeAll(descriptor, JSON.stringify(ledger));
|
|
59
|
+
fsyncSync(descriptor);
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
closeSync(descriptor);
|
|
63
|
+
}
|
|
64
|
+
renameSync(temporary, `${path}.coverage`);
|
|
65
|
+
syncDirectory(path);
|
|
66
|
+
}
|
|
67
|
+
function syncDirectory(path) {
|
|
68
|
+
const descriptor = openSync(dirname(path), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
69
|
+
try {
|
|
70
|
+
fsyncSync(descriptor);
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
closeSync(descriptor);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -3,4 +3,4 @@ export { normalizeCommandPrefix } from './normalize.mts';
|
|
|
3
3
|
export { FRICTION_LOG_MAX_EVENTS, readFrictionLog, recordFriction } from './log.mts';
|
|
4
4
|
export { buildSessionFrictionReport } from './report.mts';
|
|
5
5
|
export { isConformingCiFailureBlock } from './ci-failures.mts';
|
|
6
|
-
export type { FrictionEvent, FrictionEventKind, FrictionLogOptions, FrictionLogReadResult, FrictionObservation, JournalEntry, JournalLoadResult, JournalLoader, PermissionRequestObservation, SessionFrictionReport, SessionFrictionReportOptions, ToolResultObservation, } from './types.mts';
|
|
6
|
+
export type { FrictionEvent, FrictionEventKind, FrictionLogOptions, FrictionLogReadResult, FrictionObservation, JournalEntry, JournalLoadResult, JournalLoader, PermissionRequestObservation, SessionFrictionReport, SessionFrictionCoverage, SessionFrictionReportOptions, ToolResultObservation, } from './types.mts';
|
|
@@ -2,6 +2,6 @@ import type { FrictionEvent } from './types.mts';
|
|
|
2
2
|
export declare const LOG_MAX_BYTES = 2000000;
|
|
3
3
|
export declare function validEvent(value: unknown): value is FrictionEvent;
|
|
4
4
|
export declare function openLogFile(path: string, flags: number): number;
|
|
5
|
-
export declare function readLogContent(descriptor: number): string;
|
|
6
|
-
export declare function
|
|
5
|
+
export declare function readLogContent(descriptor: number, maximumBytes?: number): string;
|
|
6
|
+
export declare function validEventCount(content: string, limit: number): number;
|
|
7
7
|
export declare function writeAll(descriptor: number, value: string): void;
|
|
@@ -7,10 +7,25 @@ export function validEvent(value) {
|
|
|
7
7
|
return false;
|
|
8
8
|
// oxlint-disable-next-line no-mistakes/ts-no-const-aliases -- establish a record view after the runtime object check
|
|
9
9
|
const record = value;
|
|
10
|
-
return ((record.kind === 'sandbox-escalation' ||
|
|
10
|
+
return ((record.kind === 'sandbox-escalation' ||
|
|
11
|
+
record.kind === 'sandbox-failure' ||
|
|
12
|
+
record.kind === 'ambiguous-failure') &&
|
|
11
13
|
isSafeAuditText(record.timestamp) &&
|
|
12
14
|
isSafeAuditText(record.commandPrefix) &&
|
|
13
|
-
isSafeAuditText(record.detail)
|
|
15
|
+
isSafeAuditText(record.detail) &&
|
|
16
|
+
(record.failure === undefined || validFailure(record.failure)) &&
|
|
17
|
+
(record.outcome === undefined ||
|
|
18
|
+
(typeof record.outcome === 'string' &&
|
|
19
|
+
['requested', 'approved', 'denied', 'unknown'].includes(record.outcome))));
|
|
20
|
+
}
|
|
21
|
+
function validFailure(value) {
|
|
22
|
+
if (!value || typeof value !== 'object')
|
|
23
|
+
return false;
|
|
24
|
+
// oxlint-disable-next-line no-mistakes/ts-no-const-aliases -- establish a validated object view
|
|
25
|
+
const failure = value;
|
|
26
|
+
return (Object.keys(failure).length === 2 &&
|
|
27
|
+
(failure.kind === 'sandbox-failure' || failure.kind === 'ambiguous-failure') &&
|
|
28
|
+
isSafeAuditText(failure.detail));
|
|
14
29
|
}
|
|
15
30
|
export function openLogFile(path, flags) {
|
|
16
31
|
let descriptor;
|
|
@@ -36,10 +51,10 @@ export function openLogFile(path, flags) {
|
|
|
36
51
|
throw error;
|
|
37
52
|
}
|
|
38
53
|
}
|
|
39
|
-
export function readLogContent(descriptor) {
|
|
40
|
-
if (fstatSync(descriptor).size >
|
|
54
|
+
export function readLogContent(descriptor, maximumBytes = LOG_MAX_BYTES) {
|
|
55
|
+
if (fstatSync(descriptor).size > maximumBytes)
|
|
41
56
|
throw new Error('session-friction log is too large');
|
|
42
|
-
const buffer = Buffer.alloc(
|
|
57
|
+
const buffer = Buffer.alloc(maximumBytes + 1);
|
|
43
58
|
let length = 0;
|
|
44
59
|
while (length < buffer.length) {
|
|
45
60
|
const bytes = readSync(descriptor, buffer, length, buffer.length - length, length);
|
|
@@ -48,11 +63,11 @@ export function readLogContent(descriptor) {
|
|
|
48
63
|
length += bytes;
|
|
49
64
|
}
|
|
50
65
|
/* v8 ignore next -- detects external growth after the descriptor size check. */
|
|
51
|
-
if (length >
|
|
66
|
+
if (length > maximumBytes)
|
|
52
67
|
throw new Error('session-friction log is too large');
|
|
53
68
|
return decodeUtf8(buffer.subarray(0, length));
|
|
54
69
|
}
|
|
55
|
-
function validEventCount(content, limit) {
|
|
70
|
+
export function validEventCount(content, limit) {
|
|
56
71
|
let count = 0;
|
|
57
72
|
for (const line of content.split('\n')) {
|
|
58
73
|
if (!line.trim())
|
|
@@ -67,9 +82,6 @@ function validEventCount(content, limit) {
|
|
|
67
82
|
}
|
|
68
83
|
return count;
|
|
69
84
|
}
|
|
70
|
-
export function atEventLimit(content, limit) {
|
|
71
|
-
return validEventCount(content, limit) >= limit;
|
|
72
|
-
}
|
|
73
85
|
export function writeAll(descriptor, value) {
|
|
74
86
|
const buffer = Buffer.from(value);
|
|
75
87
|
let offset = 0;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { readCoverageLedger, writeCoverageLedger } from './coverage-ledger.mjs';
|
|
1
2
|
import { closeSync, constants, fchmodSync, fstatSync } from 'node:fs';
|
|
2
3
|
import { isAbsolute, join, resolve } from 'node:path';
|
|
3
4
|
import { classifyFrictionObservation } from './classify.mjs';
|
|
4
5
|
import { ensurePrivateDirectory } from './directory.mjs';
|
|
5
6
|
import { withFileLock } from './lock.mjs';
|
|
6
|
-
import {
|
|
7
|
+
import { validEventCount, LOG_MAX_BYTES, openLogFile, readLogContent, validEvent, writeAll, } from './log-file.mjs';
|
|
7
8
|
import { sanitizeSessionId } from './session-id.mjs';
|
|
8
9
|
import { boundedText, isWellFormedUnicode, normalizeAuditText } from './text.mjs';
|
|
9
10
|
export const FRICTION_LOG_MAX_EVENTS = 500;
|
|
@@ -52,9 +53,23 @@ export function recordFriction(sessionId, observation, options) {
|
|
|
52
53
|
throw new Error('session-friction log is too large');
|
|
53
54
|
if (!classified)
|
|
54
55
|
return;
|
|
56
|
+
const coverage = readCoverageLedger(path);
|
|
57
|
+
const logBytes = fstatSync(descriptor).size;
|
|
58
|
+
if (coverage && coverage.logBytes === logBytes && coverage.retainedEvents >= maxEvents) {
|
|
59
|
+
writeCoverageLedger(path, { ...coverage, droppedCount: coverage.droppedCount + 1 });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
55
62
|
const content = readLogContent(descriptor);
|
|
56
|
-
|
|
63
|
+
const retainedEvents = validEventCount(content, FRICTION_LOG_MAX_EVENTS);
|
|
64
|
+
if (retainedEvents >= maxEvents) {
|
|
65
|
+
writeCoverageLedger(path, {
|
|
66
|
+
schemaVersion: 1,
|
|
67
|
+
logBytes,
|
|
68
|
+
retainedEvents,
|
|
69
|
+
droppedCount: (coverage?.droppedCount ?? 0) + 1,
|
|
70
|
+
});
|
|
57
71
|
return;
|
|
72
|
+
}
|
|
58
73
|
const event = {
|
|
59
74
|
...classified,
|
|
60
75
|
commandPrefix: normalizeAuditText(classified.commandPrefix),
|
|
@@ -66,9 +81,22 @@ export function recordFriction(sessionId, observation, options) {
|
|
|
66
81
|
return;
|
|
67
82
|
const prefix = content && !content.endsWith('\n') ? '\n' : '';
|
|
68
83
|
const addition = `${prefix}${JSON.stringify(event)}\n`;
|
|
69
|
-
if (
|
|
70
|
-
|
|
84
|
+
if (logBytes + Buffer.byteLength(addition) > LOG_MAX_BYTES) {
|
|
85
|
+
writeCoverageLedger(path, {
|
|
86
|
+
schemaVersion: 1,
|
|
87
|
+
logBytes,
|
|
88
|
+
retainedEvents,
|
|
89
|
+
droppedCount: (coverage?.droppedCount ?? 0) + 1,
|
|
90
|
+
});
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
71
93
|
writeAll(descriptor, addition);
|
|
94
|
+
if (coverage)
|
|
95
|
+
writeCoverageLedger(path, {
|
|
96
|
+
...coverage,
|
|
97
|
+
logBytes: fstatSync(descriptor).size,
|
|
98
|
+
retainedEvents: retainedEvents + 1,
|
|
99
|
+
});
|
|
72
100
|
}
|
|
73
101
|
finally {
|
|
74
102
|
closeSync(descriptor);
|
|
@@ -89,19 +117,38 @@ export function readFrictionLog(sessionId, options) {
|
|
|
89
117
|
const descriptor = openLogFile(path, constants.O_RDONLY);
|
|
90
118
|
try {
|
|
91
119
|
const events = [];
|
|
120
|
+
const ledger = readCoverageLedger(path);
|
|
121
|
+
let droppedCount = ledger?.droppedCount ?? 0;
|
|
122
|
+
let retainedEvents = 0;
|
|
92
123
|
for (const line of readLogContent(descriptor).split('\n')) {
|
|
93
124
|
if (!line.trim())
|
|
94
125
|
continue;
|
|
95
126
|
try {
|
|
96
127
|
const value = JSON.parse(line);
|
|
97
|
-
if (validEvent(value))
|
|
98
|
-
|
|
128
|
+
if (validEvent(value)) {
|
|
129
|
+
retainedEvents++;
|
|
130
|
+
if (events.length < maxEvents)
|
|
131
|
+
events.push(value);
|
|
132
|
+
else
|
|
133
|
+
droppedCount++;
|
|
134
|
+
}
|
|
135
|
+
else
|
|
136
|
+
droppedCount++;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
droppedCount++;
|
|
99
140
|
}
|
|
100
|
-
catch { }
|
|
101
|
-
if (events.length >= maxEvents)
|
|
102
|
-
break;
|
|
103
141
|
}
|
|
104
|
-
|
|
142
|
+
if (ledger &&
|
|
143
|
+
(ledger.logBytes !== fstatSync(descriptor).size ||
|
|
144
|
+
ledger.retainedEvents !== Math.min(retainedEvents, FRICTION_LOG_MAX_EVENTS)))
|
|
145
|
+
throw new Error('session-friction coverage ledger does not match retained log');
|
|
146
|
+
if (!Number.isSafeInteger(droppedCount))
|
|
147
|
+
throw new Error('session-friction dropped count overflow; capture incomplete');
|
|
148
|
+
const coverage = droppedCount ? { truncated: true, droppedCount } : {};
|
|
149
|
+
return events.length
|
|
150
|
+
? { status: 'events', events, ...coverage }
|
|
151
|
+
: { status: 'empty', ...coverage };
|
|
105
152
|
}
|
|
106
153
|
finally {
|
|
107
154
|
closeSync(descriptor);
|
|
@@ -11,7 +11,7 @@ function journalData(entry) {
|
|
|
11
11
|
return data ? { type: data.type, markdown: data.markdown } : undefined;
|
|
12
12
|
}
|
|
13
13
|
catch {
|
|
14
|
-
return
|
|
14
|
+
return 'unreadable';
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
17
|
async function collectEntries(entries) {
|
|
@@ -22,8 +22,13 @@ async function collectEntries(entries) {
|
|
|
22
22
|
let truncated = false;
|
|
23
23
|
for await (const entry of entries) {
|
|
24
24
|
consumed++;
|
|
25
|
-
const
|
|
25
|
+
const scanned = journalData(entry);
|
|
26
|
+
if (scanned === 'unreadable')
|
|
27
|
+
truncated = true;
|
|
28
|
+
const data = scanned === 'unreadable' ? undefined : scanned;
|
|
26
29
|
const markdown = data?.markdown;
|
|
30
|
+
if (data?.type === 'journal' && typeof markdown !== 'string')
|
|
31
|
+
truncated = true;
|
|
27
32
|
if (data?.type === 'journal' && typeof markdown === 'string') {
|
|
28
33
|
const bytes = Buffer.byteLength(markdown);
|
|
29
34
|
inspectedBytes += bytes;
|
|
@@ -53,28 +58,52 @@ function errorMessage(error) {
|
|
|
53
58
|
}
|
|
54
59
|
function sandboxMarkdown(friction) {
|
|
55
60
|
if (friction.status === 'events')
|
|
56
|
-
return buildSandboxSection(friction.events)
|
|
61
|
+
return `${friction.truncated ? `## Capture Coverage\nStatus: partial\nDropped records: ${friction.droppedCount}\n\n` : ''}${buildSandboxSection(friction.events)}`;
|
|
62
|
+
if (friction.status === 'empty' && friction.truncated)
|
|
63
|
+
return `## Sandbox & Permission Audit\nStatus: unavailable (partial capture; dropped ${friction.droppedCount} records)`;
|
|
57
64
|
const status = friction.status === 'empty' ? 'none observed' : 'unavailable (no friction log)';
|
|
58
65
|
return `## Sandbox & Permission Audit\nStatus: ${status}`;
|
|
59
66
|
}
|
|
67
|
+
function incompleteCiSection(markdownBlocks) {
|
|
68
|
+
return ['## CI Failures\nStatus: unavailable (journal scan incomplete)', ...markdownBlocks].join('\n\n');
|
|
69
|
+
}
|
|
60
70
|
function reportFromLog(sessionId, logOptions, journal) {
|
|
61
71
|
try {
|
|
62
72
|
const friction = readFrictionLog(sessionId, logOptions);
|
|
73
|
+
const coverage = {
|
|
74
|
+
journalStatus: journal.status === 'unreachable'
|
|
75
|
+
? 'unavailable'
|
|
76
|
+
: journal.truncated
|
|
77
|
+
? 'partial'
|
|
78
|
+
: 'complete',
|
|
79
|
+
frictionStatus: friction.status,
|
|
80
|
+
truncated: friction.status !== 'absent' && friction.truncated === true,
|
|
81
|
+
...(friction.status === 'absent' ? {} : { droppedCount: friction.droppedCount ?? 0 }),
|
|
82
|
+
};
|
|
63
83
|
if (journal.status === 'ok' && journal.truncated)
|
|
64
84
|
return {
|
|
65
|
-
|
|
66
|
-
|
|
85
|
+
coverage,
|
|
86
|
+
markdown: incompleteCiSection(journal.markdownBlocks) + '\n\n' + sandboxMarkdown(friction),
|
|
67
87
|
};
|
|
68
88
|
const markdown = buildCiFailuresSection(sessionId, journal, friction.status);
|
|
69
|
-
return { markdown: `${markdown}\n\n${sandboxMarkdown(friction)}` };
|
|
89
|
+
return { coverage, markdown: `${markdown}\n\n${sandboxMarkdown(friction)}` };
|
|
70
90
|
}
|
|
71
91
|
catch (error) {
|
|
72
92
|
const ciMarkdown = journal.status === 'ok' && journal.truncated
|
|
73
|
-
?
|
|
93
|
+
? incompleteCiSection(journal.markdownBlocks)
|
|
74
94
|
: journal.status === 'ok' && journal.markdownBlocks.length === 0
|
|
75
95
|
? '## CI Failures\nStatus: unavailable (friction log unreadable)'
|
|
76
96
|
: buildCiFailuresSection(sessionId, journal, 'empty');
|
|
77
97
|
return {
|
|
98
|
+
coverage: {
|
|
99
|
+
journalStatus: journal.status === 'unreachable'
|
|
100
|
+
? 'unavailable'
|
|
101
|
+
: journal.truncated
|
|
102
|
+
? 'partial'
|
|
103
|
+
: 'complete',
|
|
104
|
+
frictionStatus: 'unreadable',
|
|
105
|
+
truncated: true,
|
|
106
|
+
},
|
|
78
107
|
markdown: `${ciMarkdown}\n\n## Sandbox & Permission Audit\nStatus: unavailable (friction log unreadable)`,
|
|
79
108
|
diagnostic: errorMessage(error),
|
|
80
109
|
};
|
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
import { markdownAuditText } from './text.mjs';
|
|
2
2
|
const SANDBOX_SECTION_HEADER = '## Sandbox & Permission Audit';
|
|
3
|
-
const KIND_ORDER = [
|
|
3
|
+
const KIND_ORDER = [
|
|
4
|
+
'sandbox-escalation',
|
|
5
|
+
'sandbox-failure',
|
|
6
|
+
'ambiguous-failure',
|
|
7
|
+
];
|
|
4
8
|
export function buildSandboxSection(events) {
|
|
9
|
+
const observations = events.flatMap((event) => event.failure
|
|
10
|
+
? [event, { ...event, kind: event.failure.kind, detail: event.failure.detail }]
|
|
11
|
+
: [event]);
|
|
5
12
|
const groups = KIND_ORDER.map((kind) => {
|
|
6
|
-
const selected =
|
|
13
|
+
const selected = observations.filter((event) => event.kind === kind);
|
|
7
14
|
return selected.length
|
|
8
15
|
? [
|
|
9
16
|
`- ${kind} (${selected.length})`,
|
|
10
|
-
...selected.map((event) => ` - ${markdownAuditText(event.commandPrefix)} — ${markdownAuditText(event.detail)} — ${markdownAuditText(event.timestamp)}`),
|
|
17
|
+
...selected.map((event) => ` - ${markdownAuditText(event.commandPrefix)} — ${markdownAuditText(event.detail)} — ${markdownAuditText(event.timestamp)}${event.kind === 'sandbox-escalation' ? ` — outcome: ${event.outcome ?? 'unknown'}` : ''}`),
|
|
11
18
|
].join('\n')
|
|
12
19
|
: undefined;
|
|
13
20
|
}).filter((group) => group !== undefined);
|
|
14
|
-
return `${SANDBOX_SECTION_HEADER}\nEvents observed: ${
|
|
21
|
+
return `${SANDBOX_SECTION_HEADER}\nEvents observed: ${observations.length}\n\n${groups.join('\n')}`;
|
|
15
22
|
}
|
|
@@ -1,14 +1,20 @@
|
|
|
1
|
-
export type FrictionEventKind = 'sandbox-escalation' | 'sandbox-failure';
|
|
1
|
+
export type FrictionEventKind = 'sandbox-escalation' | 'sandbox-failure' | 'ambiguous-failure';
|
|
2
2
|
export type FrictionEvent = {
|
|
3
3
|
kind: FrictionEventKind;
|
|
4
4
|
timestamp: string;
|
|
5
5
|
commandPrefix: string;
|
|
6
6
|
detail: string;
|
|
7
|
+
outcome?: 'requested' | 'approved' | 'denied' | 'unknown';
|
|
8
|
+
failure?: {
|
|
9
|
+
kind: 'sandbox-failure' | 'ambiguous-failure';
|
|
10
|
+
detail: string;
|
|
11
|
+
};
|
|
7
12
|
};
|
|
8
13
|
export type ToolResultObservation = {
|
|
9
14
|
type: 'tool-result';
|
|
10
15
|
command: string;
|
|
11
16
|
commandWrappers?: string[];
|
|
17
|
+
permissionOutcome?: 'approved' | 'denied';
|
|
12
18
|
escalationDetail?: string;
|
|
13
19
|
structuredStderr?: string;
|
|
14
20
|
};
|
|
@@ -18,14 +24,21 @@ export type PermissionRequestObservation = {
|
|
|
18
24
|
commandWrappers?: string[];
|
|
19
25
|
};
|
|
20
26
|
export type FrictionObservation = ToolResultObservation | PermissionRequestObservation;
|
|
27
|
+
type FrictionLogCoverage = {
|
|
28
|
+
truncated: true;
|
|
29
|
+
droppedCount: number;
|
|
30
|
+
} | {
|
|
31
|
+
truncated?: false;
|
|
32
|
+
droppedCount?: never;
|
|
33
|
+
};
|
|
21
34
|
export type FrictionLogReadResult = {
|
|
22
35
|
status: 'absent';
|
|
23
|
-
} | {
|
|
36
|
+
} | ({
|
|
24
37
|
status: 'empty';
|
|
25
|
-
} | {
|
|
38
|
+
} & FrictionLogCoverage) | ({
|
|
26
39
|
status: 'events';
|
|
27
40
|
events: FrictionEvent[];
|
|
28
|
-
};
|
|
41
|
+
} & FrictionLogCoverage);
|
|
29
42
|
export type FrictionLogOptions = {
|
|
30
43
|
directory: string;
|
|
31
44
|
maxEvents?: number;
|
|
@@ -43,10 +56,18 @@ export type JournalLoadResult = {
|
|
|
43
56
|
status: 'not-found';
|
|
44
57
|
};
|
|
45
58
|
export type JournalLoader = (sessionId: string) => JournalLoadResult | Promise<JournalLoadResult>;
|
|
59
|
+
export type SessionFrictionCoverage = {
|
|
60
|
+
journalStatus: 'complete' | 'partial' | 'unavailable';
|
|
61
|
+
frictionStatus: 'absent' | 'empty' | 'events' | 'unreadable';
|
|
62
|
+
truncated: boolean;
|
|
63
|
+
droppedCount?: number;
|
|
64
|
+
};
|
|
46
65
|
export type SessionFrictionReport = {
|
|
66
|
+
coverage: SessionFrictionCoverage;
|
|
47
67
|
markdown: string;
|
|
48
68
|
diagnostic?: string;
|
|
49
69
|
};
|
|
50
70
|
export type SessionFrictionReportOptions = FrictionLogOptions & {
|
|
51
71
|
journalLoader: JournalLoader;
|
|
52
72
|
};
|
|
73
|
+
export {};
|
|
@@ -2,6 +2,6 @@ export { evaluateGraphPolicy } from './graph.mts';
|
|
|
2
2
|
export { evaluateLockPolicy, type ConcurrencyPolicy } from './concurrency.mts';
|
|
3
3
|
export { conditionEntails } from './condition-entailment.mts';
|
|
4
4
|
export { unprovisionedSecretsWithoutReadinessStep } from './secret-readiness.mts';
|
|
5
|
-
export { callerCalleePermissionMismatches } from './permission-mismatches.mts';
|
|
5
|
+
export { callerCalleePermissionMismatches, type CallerCalleePermissionPolicy, } from './permission-mismatches.mts';
|
|
6
6
|
export { missingTopLevelPermissionPaths, parsePermissions, parseWorkflow, requiredWorkflowPermissions, type Workflow, } from './permissions.mts';
|
|
7
7
|
export type { WorkflowTopology, WorkflowTopologyIndex, WorkflowTopologyPolicy, WorkflowNode, WorkflowJobNode, WorkflowStep, } from './types.mts';
|
|
@@ -2,5 +2,5 @@ export { evaluateGraphPolicy } from './graph.mjs';
|
|
|
2
2
|
export { evaluateLockPolicy } from './concurrency.mjs';
|
|
3
3
|
export { conditionEntails } from './condition-entailment.mjs';
|
|
4
4
|
export { unprovisionedSecretsWithoutReadinessStep } from './secret-readiness.mjs';
|
|
5
|
-
export { callerCalleePermissionMismatches } from './permission-mismatches.mjs';
|
|
5
|
+
export { callerCalleePermissionMismatches, } from './permission-mismatches.mjs';
|
|
6
6
|
export { missingTopLevelPermissionPaths, parsePermissions, parseWorkflow, requiredWorkflowPermissions, } from './permissions.mjs';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare const PERMISSION_LEVEL_RANK: {
|
|
2
|
+
readonly none: 0;
|
|
3
|
+
readonly read: 1;
|
|
4
|
+
readonly write: 2;
|
|
5
|
+
};
|
|
6
|
+
export type PermissionLevel = keyof typeof PERMISSION_LEVEL_RANK;
|
|
7
|
+
type PermissionMap = Record<string, PermissionLevel>;
|
|
8
|
+
export type BoundedPermissionDeclaration = PermissionMap | 'read-all';
|
|
9
|
+
export declare function isPlainRecord(value: unknown): value is Record<string, unknown>;
|
|
10
|
+
export declare function isValidPermissionMap(value: unknown): value is PermissionMap;
|
|
11
|
+
export declare function isValidDeclaration(value: unknown): value is BoundedPermissionDeclaration;
|
|
12
|
+
export declare function permissionMap(value: BoundedPermissionDeclaration): Map<string, PermissionLevel>;
|
|
13
|
+
export declare function mergePermissionMaps(target: Map<string, PermissionLevel>, source: Map<string, PermissionLevel>): void;
|
|
14
|
+
export {};
|