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.
Files changed (76) hide show
  1. package/README.md +102 -4
  2. package/dist/agent-blackboard/client.d.mts +34 -0
  3. package/dist/agent-blackboard/client.mjs +41 -0
  4. package/dist/agent-blackboard/feedback-canonical.d.mts +1 -0
  5. package/dist/agent-blackboard/feedback-canonical.mjs +13 -0
  6. package/dist/agent-blackboard/feedback-codec.d.mts +7 -0
  7. package/dist/agent-blackboard/feedback-codec.mjs +121 -0
  8. package/dist/agent-blackboard/feedback-compose.d.mts +37 -0
  9. package/dist/agent-blackboard/feedback-compose.mjs +109 -0
  10. package/dist/agent-blackboard/feedback-deadline.d.mts +1 -0
  11. package/dist/agent-blackboard/feedback-deadline.mjs +18 -0
  12. package/dist/agent-blackboard/feedback-delivery.d.mts +18 -0
  13. package/dist/agent-blackboard/feedback-delivery.mjs +88 -0
  14. package/dist/agent-blackboard/feedback-fields.d.mts +4 -0
  15. package/dist/agent-blackboard/feedback-fields.mjs +38 -0
  16. package/dist/agent-blackboard/feedback-identity.d.mts +2 -0
  17. package/dist/agent-blackboard/feedback-identity.mjs +29 -0
  18. package/dist/agent-blackboard/feedback-online-error.d.mts +7 -0
  19. package/dist/agent-blackboard/feedback-online-error.mjs +23 -0
  20. package/dist/agent-blackboard/feedback-online.d.mts +2 -0
  21. package/dist/agent-blackboard/feedback-online.mjs +137 -0
  22. package/dist/agent-blackboard/feedback-outbox.d.mts +8 -0
  23. package/dist/agent-blackboard/feedback-outbox.mjs +157 -0
  24. package/dist/agent-blackboard/feedback-types.d.mts +66 -0
  25. package/dist/agent-blackboard/feedback-types.mjs +1 -0
  26. package/dist/agent-blackboard/index.d.mts +16 -39
  27. package/dist/agent-blackboard/index.mjs +35 -81
  28. package/dist/cli/commands/agent-blackboard.mjs +35 -4
  29. package/dist/cli/usage.d.mts +1 -1
  30. package/dist/cli/usage.mjs +3 -1
  31. package/dist/markdown/details-scope.d.mts +13 -0
  32. package/dist/markdown/details-scope.mjs +73 -0
  33. package/dist/markdown/document-sections.d.mts +3 -0
  34. package/dist/markdown/document-sections.mjs +64 -0
  35. package/dist/markdown/document-types.d.mts +21 -0
  36. package/dist/markdown/document-types.mjs +1 -0
  37. package/dist/markdown/index.d.mts +3 -0
  38. package/dist/markdown/index.mjs +2 -0
  39. package/dist/markdown/validate-sections.d.mts +3 -0
  40. package/dist/markdown/validate-sections.mjs +34 -0
  41. package/dist/retrospective-facts/coverage.d.mts +6 -0
  42. package/dist/retrospective-facts/coverage.mjs +42 -0
  43. package/dist/retrospective-facts/index.d.mts +3 -0
  44. package/dist/retrospective-facts/index.mjs +18 -1
  45. package/dist/retrospective-transcript/codex.mjs +8 -3
  46. package/dist/retrospective-transcript/index.d.mts +5 -0
  47. package/dist/retrospective-transcript/index.mjs +29 -9
  48. package/dist/session-friction/classify.mjs +25 -14
  49. package/dist/session-friction/coverage-ledger.d.mts +10 -0
  50. package/dist/session-friction/coverage-ledger.mjs +75 -0
  51. package/dist/session-friction/index.d.mts +1 -1
  52. package/dist/session-friction/log-file.d.mts +2 -2
  53. package/dist/session-friction/log-file.mjs +22 -10
  54. package/dist/session-friction/log.mjs +57 -10
  55. package/dist/session-friction/report.mjs +36 -7
  56. package/dist/session-friction/sandbox.mjs +11 -4
  57. package/dist/session-friction/types.d.mts +25 -4
  58. package/dist/workflow-policy/index.d.mts +1 -1
  59. package/dist/workflow-policy/index.mjs +1 -1
  60. package/dist/workflow-policy/permission-declarations.d.mts +14 -0
  61. package/dist/workflow-policy/permission-declarations.mjs +55 -0
  62. package/dist/workflow-policy/permission-inheritance.d.mts +8 -0
  63. package/dist/workflow-policy/permission-inheritance.mjs +88 -0
  64. package/dist/workflow-policy/permission-mismatches.d.mts +4 -1
  65. package/dist/workflow-policy/permission-mismatches.mjs +14 -3
  66. package/docs/workflow-and-compiler.md +18 -1
  67. package/package.json +1 -1
  68. package/skills/agent-workflow/SKILL.md +5 -0
  69. package/skills/agent-workflow/references/review.md +3 -0
  70. package/skills/blackboard/SKILL.md +34 -14
  71. package/skills/manifest.json +21 -21
  72. package/skills/pr-description/SKILL.md +44 -10
  73. package/skills/pr-description/references/examples.md +136 -0
  74. package/skills/retrospective/SKILL.md +27 -16
  75. package/skills/retrospective-distill/SKILL.md +15 -6
  76. 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 formatUnavailable(resolved.error);
97
+ return unavailable(resolved.error);
89
98
  const lines = await readLines(resolved.path);
90
99
  if (!lines)
91
- return formatUnavailable('could not read transcript');
100
+ return unavailable('could not read transcript');
92
101
  const detected = schema(lines);
93
102
  if (!detected)
94
- return formatUnavailable('unsupported or mixed transcript schema');
103
+ return unavailable('unsupported or mixed transcript schema');
95
104
  if (hasMalformedInteriorRecord(lines))
96
- return formatUnavailable(MALFORMED_INTERIOR);
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
- return formatTranscriptFacts(resolved.sessionId, computeTranscriptFacts(lines, subagents.filter((value) => value !== undefined && schema(value) === 'claude' && !hasMalformedInteriorRecord(value))));
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 formatUnavailable('could not segment Codex transcript');
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 formatUnavailable('could not resolve a referenced Codex child transcript');
112
- return formatTranscriptFacts(resolved.sessionId, computeCodex(root.lines, subagents, root.baseline));
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 { kind: 'sandbox-escalation', commandPrefix, detail: 'permission-request' };
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 (escalationDetail) {
61
- return {
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 null;
81
+ return permission;
73
82
  const token = FAILURE_TOKENS.find(([, pattern]) => pattern.test(stderr))?.[0];
74
- if (token)
75
- return { kind: 'sandbox-failure', commandPrefix, detail: `stderr matched "${token}"` };
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
- return {
81
- kind: 'sandbox-failure',
82
- commandPrefix,
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 null;
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 atEventLimit(content: string, limit: number): boolean;
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' || record.kind === 'sandbox-failure') &&
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 > LOG_MAX_BYTES)
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(LOG_MAX_BYTES + 1);
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 > LOG_MAX_BYTES)
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 { atEventLimit, LOG_MAX_BYTES, openLogFile, readLogContent, validEvent, writeAll, } from './log-file.mjs';
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
- if (atEventLimit(content, maxEvents))
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 (fstatSync(descriptor).size + Buffer.byteLength(addition) > LOG_MAX_BYTES)
70
- throw new Error('session-friction log is too large');
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
- events.push(value);
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
- return events.length ? { status: 'events', events } : { status: 'empty' };
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 undefined;
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 data = journalData(entry);
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
- markdown: '## CI Failures\nStatus: unavailable (journal scan incomplete)\n\n' +
66
- sandboxMarkdown(friction),
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
- ? '## CI Failures\nStatus: unavailable (journal scan incomplete)'
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 = ['sandbox-escalation', 'sandbox-failure'];
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 = events.filter((event) => event.kind === kind);
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: ${events.length}\n\n${groups.join('\n')}`;
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 {};