@aiwg/cli 2026.7.21 → 2026.7.23

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.
@@ -15,6 +15,7 @@ import { normalizeNamedCaptures } from '../artifacts/index-builder.js';
15
15
  import { getProviderDefinition, PROVIDER_IDS, resolveProviderPathValue, } from '../providers/provider-definitions.js';
16
16
  import { validateAuthorization, } from '../policy/authorization.js';
17
17
  import { projectAiwgPath, resolveProjectAiwgDir } from './project-artifacts.js';
18
+ import { defaultThreatAssessmentConfig, validateThreatAssessmentConfig, } from '../security/threat-assessment-config.js';
18
19
  const CONFIG_FILENAME = 'aiwg.config';
19
20
  /**
20
21
  * Operations that a workspace may authorize for one member repository.
@@ -633,6 +634,9 @@ export function emptyConfig(providers = ['claude']) {
633
634
  providers,
634
635
  installed: {},
635
636
  scripts: {},
637
+ security: {
638
+ threatAssessment: defaultThreatAssessmentConfig(),
639
+ },
636
640
  delivery: {
637
641
  mode: 'pr-required',
638
642
  default_branch: 'main',
@@ -711,12 +715,20 @@ export async function readAiwgConfig(projectDir) {
711
715
  if (authorizationErrors.length > 0) {
712
716
  throw new Error(`Invalid .aiwg/aiwg.config:\n${authorizationErrors.map(item => item.message).join('\n')}`);
713
717
  }
718
+ const threatAssessmentErrors = validateThreatAssessmentConfig(parsed.security?.threatAssessment);
719
+ if (threatAssessmentErrors.length > 0) {
720
+ throw new Error(`Invalid .aiwg/aiwg.config:\n${threatAssessmentErrors.join('\n')}`);
721
+ }
714
722
  return parsed;
715
723
  }
716
724
  /**
717
725
  * Write aiwg.config, creating the resolved AIWG artifact directory if needed.
718
726
  */
719
727
  export async function writeAiwgConfig(projectDir, config) {
728
+ const threatAssessmentErrors = validateThreatAssessmentConfig(config.security?.threatAssessment);
729
+ if (threatAssessmentErrors.length > 0) {
730
+ throw new Error(`Invalid .aiwg/aiwg.config:\n${threatAssessmentErrors.join('\n')}`);
731
+ }
720
732
  const dir = resolveProjectAiwgDir(projectDir);
721
733
  await mkdir(dir, { recursive: true });
722
734
  const filePath = join(dir, CONFIG_FILENAME);
@@ -152,6 +152,7 @@ const ENUM_RULES = {
152
152
  'remotes.tracker_actor.via': ['tea', 'gh', 'mcp', 'api'],
153
153
  'remotes.transport.protocol': ['ssh', 'https'],
154
154
  'repo_maintainer.tiers.local': ['collaborator', 'maintainer', 'admin'],
155
+ 'security.threatAssessment.mode': ['off', 'audit', 'enforce'],
155
156
  };
156
157
  const BOOLEAN_FIELDS = new Set([
157
158
  'delivery.delete_branch_on_merge',
@@ -214,15 +215,17 @@ async function projectConfigSet(key, raw, args) {
214
215
  }
215
216
  // Coerce booleans for known boolean fields
216
217
  let value = raw;
217
- if (/^externalLinks\.[^.]+$/.test(key)) {
218
+ if (/^externalLinks\.[^.]+$/.test(key) || key === 'security.threatAssessment') {
218
219
  try {
219
220
  value = JSON.parse(raw);
220
221
  }
221
222
  catch {
222
223
  throw new AiwgError({
223
224
  code: 'ERR_INVALID_VALUE',
224
- message: `${key} must be a JSON object containing label and url`,
225
- hint: `Try: aiwg config set --project ${key} '{"label":"Project docs","url":"https://example.com/docs"}'`,
225
+ message: `${key} must be a valid JSON object`,
226
+ hint: key === 'security.threatAssessment'
227
+ ? `Try: aiwg config set --project ${key} '{"schemaVersion":"1","mode":"audit","defaultProfile":"balanced"}'`
228
+ : `Try: aiwg config set --project ${key} '{"label":"Project docs","url":"https://example.com/docs"}'`,
226
229
  exitCode: EXIT_CODES.USAGE,
227
230
  });
228
231
  }
@@ -285,6 +288,16 @@ async function projectConfigSet(key, raw, args) {
285
288
  exitCode: EXIT_CODES.USAGE,
286
289
  });
287
290
  }
291
+ const { validateThreatAssessmentConfig } = await import('../security/threat-assessment-config.js');
292
+ const threatErrors = validateThreatAssessmentConfig(cfg.security?.threatAssessment);
293
+ if (threatErrors.length > 0) {
294
+ throw new AiwgError({
295
+ code: 'ERR_INVALID_VALUE',
296
+ message: `Invalid threat-assessment configuration: ${threatErrors.join('; ')}`,
297
+ hint: 'Use a built-in profile or correct the referenced profile, rule pack, threshold, or regex.',
298
+ exitCode: EXIT_CODES.USAGE,
299
+ });
300
+ }
288
301
  await writeAiwgConfig(projectDir, cfg);
289
302
  console.log(`Set --project ${key} = ${raw}`);
290
303
  }
@@ -0,0 +1,296 @@
1
+ export const THREAT_ASSESSMENT_MODES = ['off', 'audit', 'enforce'];
2
+ export const THREAT_ASSESSMENT_SEVERITIES = [
3
+ 'informational',
4
+ 'low',
5
+ 'moderate',
6
+ 'high',
7
+ 'critical',
8
+ ];
9
+ export const THREAT_ASSESSMENT_SURFACES = [
10
+ 'issue-title',
11
+ 'issue-body',
12
+ 'issue-comment',
13
+ 'pull-request-title',
14
+ 'pull-request-body',
15
+ 'pull-request-diff-summary',
16
+ 'review-comment',
17
+ 'release-note',
18
+ 'handoff',
19
+ 'outbound-maintainer-comment',
20
+ ];
21
+ const BUILTIN_PROFILES = new Set(['trusted', 'audit', 'balanced', 'strict', 'high-assurance']);
22
+ const BUILTIN_RULE_PACKS = new Set([
23
+ 'aiwg:all',
24
+ 'aiwg:prompt-injection',
25
+ 'aiwg:supply-chain',
26
+ 'aiwg:credential-protection',
27
+ ]);
28
+ const BUILTIN_RULE_IDS = new Set([
29
+ 'instruction-override',
30
+ 'sensitive-file-target',
31
+ 'third-party-execution',
32
+ 'floating-version',
33
+ 'credential-or-env-probing',
34
+ 'pressure-without-evidence',
35
+ 'unverifiable-authority-claim',
36
+ 'security-framing-conflict',
37
+ ]);
38
+ function objectValue(value) {
39
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
40
+ }
41
+ function stringArray(value) {
42
+ return Array.isArray(value) && value.every(item => typeof item === 'string');
43
+ }
44
+ function validateStatement(statement, where, errors) {
45
+ if (!objectValue(statement)) {
46
+ errors.push(`${where}: must be an object`);
47
+ return;
48
+ }
49
+ if (typeof statement.id !== 'string' || !/^[a-z][a-z0-9-]*$/.test(statement.id)) {
50
+ errors.push(`${where}.id: must be a kebab-case identifier`);
51
+ }
52
+ if (!['suppress', 'set-severity'].includes(String(statement.effect))) {
53
+ errors.push(`${where}.effect: must be suppress or set-severity`);
54
+ }
55
+ if (typeof statement.reason !== 'string' || !statement.reason.trim()) {
56
+ errors.push(`${where}.reason: must be a non-empty string`);
57
+ }
58
+ if (statement.signals !== undefined && !stringArray(statement.signals)) {
59
+ errors.push(`${where}.signals: must be an array of strings`);
60
+ }
61
+ if (statement.severity !== undefined
62
+ && !THREAT_ASSESSMENT_SEVERITIES.includes(statement.severity)) {
63
+ errors.push(`${where}.severity: unknown severity`);
64
+ }
65
+ if (statement.when !== undefined && !objectValue(statement.when)) {
66
+ errors.push(`${where}.when: must be an object`);
67
+ }
68
+ if (statement.riskAcceptance !== undefined) {
69
+ if (!objectValue(statement.riskAcceptance)
70
+ || typeof statement.riskAcceptance.acceptedBy !== 'string'
71
+ || typeof statement.riskAcceptance.rationale !== 'string') {
72
+ errors.push(`${where}.riskAcceptance: requires acceptedBy and rationale`);
73
+ }
74
+ }
75
+ if (statement.effect === 'suppress') {
76
+ if (!stringArray(statement.signals) || statement.signals.length === 0) {
77
+ errors.push(`${where}.signals: suppress statements must name at least one signal`);
78
+ }
79
+ const when = objectValue(statement.when) ? statement.when : undefined;
80
+ if (!when || !Object.values(when).some(value => Array.isArray(value) && value.length > 0)) {
81
+ errors.push(`${where}.when: suppress statements require at least one narrow condition`);
82
+ }
83
+ if (!objectValue(statement.riskAcceptance)
84
+ || typeof statement.riskAcceptance.acceptedBy !== 'string'
85
+ || typeof statement.riskAcceptance.rationale !== 'string') {
86
+ errors.push(`${where}.riskAcceptance: suppress statements require acceptedBy and rationale`);
87
+ }
88
+ }
89
+ if (statement.effect === 'set-severity'
90
+ && !THREAT_ASSESSMENT_SEVERITIES.includes(statement.severity)) {
91
+ errors.push(`${where}.severity: set-severity requires a known severity`);
92
+ }
93
+ }
94
+ /**
95
+ * Fail-closed validation for project threat-assessment policy. The evaluator
96
+ * performs the same checks at runtime; config loading validates first so a
97
+ * malformed policy never degrades into the default or off mode.
98
+ */
99
+ export function validateThreatAssessmentConfig(value) {
100
+ const errors = [];
101
+ if (value === undefined || value === null)
102
+ return errors;
103
+ if (!objectValue(value))
104
+ return ['security.threatAssessment: must be an object'];
105
+ if (value.schemaVersion !== undefined && value.schemaVersion !== '1') {
106
+ errors.push("security.threatAssessment.schemaVersion: must be '1'");
107
+ }
108
+ if (value.mode !== undefined
109
+ && !THREAT_ASSESSMENT_MODES.includes(value.mode)) {
110
+ errors.push('security.threatAssessment.mode: must be off, audit, or enforce');
111
+ }
112
+ if (value.defaultProfile !== undefined && typeof value.defaultProfile !== 'string') {
113
+ errors.push('security.threatAssessment.defaultProfile: must be a string');
114
+ }
115
+ if (value.surfaces !== undefined) {
116
+ if (!objectValue(value.surfaces))
117
+ errors.push('security.threatAssessment.surfaces: must be an object');
118
+ else {
119
+ for (const [surface, entry] of Object.entries(value.surfaces)) {
120
+ const where = `security.threatAssessment.surfaces.${surface}`;
121
+ if (!THREAT_ASSESSMENT_SURFACES.includes(surface)) {
122
+ errors.push(`${where}: unknown surface`);
123
+ }
124
+ if (!objectValue(entry)) {
125
+ errors.push(`${where}: must be an object`);
126
+ continue;
127
+ }
128
+ if (entry.mode !== undefined
129
+ && !THREAT_ASSESSMENT_MODES.includes(entry.mode)) {
130
+ errors.push(`${where}.mode: must be off, audit, or enforce`);
131
+ }
132
+ if (entry.profile !== undefined && typeof entry.profile !== 'string') {
133
+ errors.push(`${where}.profile: must be a string`);
134
+ }
135
+ }
136
+ }
137
+ }
138
+ const rulePacks = objectValue(value.rulePacks) ? value.rulePacks : {};
139
+ if (value.rulePacks !== undefined && !objectValue(value.rulePacks)) {
140
+ errors.push('security.threatAssessment.rulePacks: must be an object');
141
+ }
142
+ for (const [name, pack] of Object.entries(rulePacks)) {
143
+ const where = `security.threatAssessment.rulePacks.${name}`;
144
+ if (name.startsWith('aiwg:'))
145
+ errors.push(`${where}: cannot shadow a built-in rule pack`);
146
+ if (!objectValue(pack) || !Array.isArray(pack.rules)) {
147
+ errors.push(`${where}.rules: must be an array`);
148
+ continue;
149
+ }
150
+ if (typeof pack.version !== 'string' || !pack.version.trim()) {
151
+ errors.push(`${where}.version: must be a non-empty string`);
152
+ }
153
+ const ids = new Set();
154
+ pack.rules.forEach((rule, index) => {
155
+ const ruleWhere = `${where}.rules[${index}]`;
156
+ if (!objectValue(rule)) {
157
+ errors.push(`${ruleWhere}: must be an object`);
158
+ return;
159
+ }
160
+ if (typeof rule.id !== 'string' || !/^[a-z][a-z0-9-]*$/.test(rule.id)) {
161
+ errors.push(`${ruleWhere}.id: must be a kebab-case identifier`);
162
+ }
163
+ else if (BUILTIN_RULE_IDS.has(rule.id)) {
164
+ errors.push(`${ruleWhere}.id: cannot shadow built-in rule '${rule.id}'`);
165
+ }
166
+ else if (ids.has(rule.id)) {
167
+ errors.push(`${ruleWhere}.id: duplicate '${rule.id}'`);
168
+ }
169
+ else
170
+ ids.add(rule.id);
171
+ if (!THREAT_ASSESSMENT_SEVERITIES.includes(rule.severity)) {
172
+ errors.push(`${ruleWhere}.severity: unknown severity`);
173
+ }
174
+ for (const dimension of ['likelihood', 'impact']) {
175
+ const dimensionValue = rule[dimension];
176
+ if (dimensionValue !== undefined
177
+ && (!Number.isInteger(dimensionValue) || Number(dimensionValue) < 1 || Number(dimensionValue) > 5)) {
178
+ errors.push(`${ruleWhere}.${dimension}: must be an integer from 1 to 5`);
179
+ }
180
+ }
181
+ if (!stringArray(rule.patterns) || rule.patterns.length === 0) {
182
+ errors.push(`${ruleWhere}.patterns: must be a non-empty array of strings`);
183
+ }
184
+ else {
185
+ rule.patterns.forEach((pattern, patternIndex) => {
186
+ const patternWhere = `${ruleWhere}.patterns[${patternIndex}]`;
187
+ if (!pattern || pattern.length > 500) {
188
+ errors.push(`${patternWhere}: must be 1-500 characters`);
189
+ return;
190
+ }
191
+ try {
192
+ new RegExp(pattern, 'imu');
193
+ }
194
+ catch (error) {
195
+ errors.push(`${patternWhere}: invalid regular expression (${error.message})`);
196
+ }
197
+ if (/\\[1-9]/.test(pattern) || /\(\?<([=!])/.test(pattern)
198
+ || /\([^)]*[+*][^)]*\)[+*{]/.test(pattern) || /(?:\.\*){2,}/.test(pattern)) {
199
+ errors.push(`${patternWhere}: unsafe regex construct (backreference, lookbehind, or nested unbounded quantifier)`);
200
+ }
201
+ });
202
+ }
203
+ });
204
+ }
205
+ const profiles = objectValue(value.profiles) ? value.profiles : {};
206
+ if (value.profiles !== undefined && !objectValue(value.profiles)) {
207
+ errors.push('security.threatAssessment.profiles: must be an object');
208
+ }
209
+ const visit = (name, stack) => {
210
+ if (stack.includes(name)) {
211
+ errors.push(`security.threatAssessment.profiles.${name}.extends: cyclic inheritance (${[...stack, name].join(' -> ')})`);
212
+ return;
213
+ }
214
+ const profile = profiles[name];
215
+ if (!objectValue(profile))
216
+ return;
217
+ const parents = profile.extends ?? [];
218
+ if (!stringArray(parents)) {
219
+ errors.push(`security.threatAssessment.profiles.${name}.extends: must be an array of strings`);
220
+ return;
221
+ }
222
+ for (const rawParent of parents) {
223
+ const parent = rawParent.replace(/^aiwg:/, '');
224
+ if (!BUILTIN_PROFILES.has(parent) && !(parent in profiles)) {
225
+ errors.push(`security.threatAssessment.profiles.${name}.extends: unknown profile '${rawParent}'`);
226
+ }
227
+ else if (parent in profiles)
228
+ visit(parent, [...stack, name]);
229
+ }
230
+ };
231
+ for (const [name, profile] of Object.entries(profiles)) {
232
+ const where = `security.threatAssessment.profiles.${name}`;
233
+ if (BUILTIN_PROFILES.has(name) || name.startsWith('aiwg:')) {
234
+ errors.push(`${where}: cannot shadow a built-in profile`);
235
+ }
236
+ if (!objectValue(profile)) {
237
+ errors.push(`${where}: must be an object`);
238
+ continue;
239
+ }
240
+ if (profile.mode !== undefined
241
+ && !THREAT_ASSESSMENT_MODES.includes(profile.mode)) {
242
+ errors.push(`${where}.mode: must be off, audit, or enforce`);
243
+ }
244
+ if (profile.version !== undefined && (typeof profile.version !== 'string' || !profile.version.trim())) {
245
+ errors.push(`${where}.version: must be a non-empty string`);
246
+ }
247
+ if (profile.ruleSets !== undefined && !stringArray(profile.ruleSets)) {
248
+ errors.push(`${where}.ruleSets: must be an array of strings`);
249
+ }
250
+ else {
251
+ for (const pack of (profile.ruleSets ?? [])) {
252
+ if (!BUILTIN_RULE_PACKS.has(pack) && !(pack in rulePacks)) {
253
+ errors.push(`${where}.ruleSets: unavailable rule pack '${pack}'`);
254
+ }
255
+ }
256
+ }
257
+ if (profile.thresholds !== undefined) {
258
+ if (!objectValue(profile.thresholds))
259
+ errors.push(`${where}.thresholds: must be an object`);
260
+ else {
261
+ for (const [action, severity] of Object.entries(profile.thresholds)) {
262
+ if (!['flag', 'requireAuthorization', 'reject'].includes(action)
263
+ || !THREAT_ASSESSMENT_SEVERITIES.includes(severity)) {
264
+ errors.push(`${where}.thresholds.${action}: unknown threshold or severity`);
265
+ }
266
+ }
267
+ }
268
+ }
269
+ if (profile.statements !== undefined) {
270
+ if (!Array.isArray(profile.statements))
271
+ errors.push(`${where}.statements: must be an array`);
272
+ else
273
+ profile.statements.forEach((statement, index) => validateStatement(statement, `${where}.statements[${index}]`, errors));
274
+ }
275
+ visit(name, []);
276
+ }
277
+ if (value.statements !== undefined) {
278
+ if (!Array.isArray(value.statements))
279
+ errors.push('security.threatAssessment.statements: must be an array');
280
+ else
281
+ value.statements.forEach((statement, index) => validateStatement(statement, `security.threatAssessment.statements[${index}]`, errors));
282
+ }
283
+ const defaultProfile = typeof value.defaultProfile === 'string' ? value.defaultProfile : 'balanced';
284
+ if (!BUILTIN_PROFILES.has(defaultProfile) && !(defaultProfile in profiles)) {
285
+ errors.push(`security.threatAssessment.defaultProfile: unknown profile '${defaultProfile}'`);
286
+ }
287
+ return Array.from(new Set(errors));
288
+ }
289
+ export function defaultThreatAssessmentConfig() {
290
+ return {
291
+ schemaVersion: '1',
292
+ mode: 'enforce',
293
+ defaultProfile: 'balanced',
294
+ };
295
+ }
296
+ //# sourceMappingURL=threat-assessment-config.js.map
@@ -16,6 +16,10 @@ const ClaudeRecordSchema = z.object({
16
16
  timestamp: z.string().datetime({ offset: true }).optional(),
17
17
  cwd: z.string().optional(),
18
18
  gitBranch: z.string().optional(),
19
+ parentSessionId: z.string().min(1).optional(),
20
+ isSidechain: z.boolean().optional(),
21
+ agentId: z.string().min(1).optional(),
22
+ slug: z.string().min(1).optional(),
19
23
  version: z.string().optional(),
20
24
  schemaVersion: z.string().optional(),
21
25
  message: z.object({
@@ -138,13 +142,11 @@ function normalizeTranscript(records, locator) {
138
142
  throw new SessionContractError('MALFORMED_SOURCE', 'Claude transcript record is malformed');
139
143
  }
140
144
  const value = parsed.data;
141
- const nativeSessionId = value.sessionId ?? value.session_id ?? filenameSessionId;
142
- if ((value.sessionId || value.session_id) && nativeSessionId !== filenameSessionId) {
143
- throw new SessionContractError('SCHEMA_DRIFT', 'Claude transcript session identity differs from its documented filename identity');
144
- }
145
+ const embeddedSessionId = value.sessionId ?? value.session_id;
146
+ const nativeSessionId = embeddedSessionId ?? filenameSessionId;
145
147
  const blocks = messageBlocks(value);
146
148
  if (blocks.length === 0) {
147
- output.push(providerRecord(value, record, nativeSessionId, {
149
+ output.push(providerRecord(value, record, nativeSessionId, filenameSessionId, {
148
150
  kind: `claude.${value.type}`,
149
151
  text: '',
150
152
  opaque: true,
@@ -153,7 +155,7 @@ function normalizeTranscript(records, locator) {
153
155
  continue;
154
156
  }
155
157
  for (const [blockIndex, block] of blocks.entries()) {
156
- output.push(providerRecord(value, record, nativeSessionId, {
158
+ output.push(providerRecord(value, record, nativeSessionId, filenameSessionId, {
157
159
  kind: block.kind,
158
160
  text: block.text,
159
161
  blockIndex,
@@ -184,11 +186,24 @@ function normalizeHooks(records) {
184
186
  participant: 'claude',
185
187
  model: hook.model,
186
188
  occurredAt: hook.timestamp,
189
+ activityBoundary: hook.hook_event_name === 'SessionEnd'
190
+ ? 'end'
191
+ : hook.source === 'resume'
192
+ ? 'resume'
193
+ : hook.source === 'compact'
194
+ ? 'continuation'
195
+ : undefined,
196
+ activityBoundaryBasis: hook.hook_event_name === 'SessionEnd' || hook.source
197
+ ? `claude-hook:${hook.hook_event_name}:${hook.source ?? 'none'}`
198
+ : undefined,
199
+ activityBoundaryConfidence: hook.hook_event_name === 'SessionEnd' || hook.source
200
+ ? 'high'
201
+ : undefined,
187
202
  text: '',
188
203
  rawReference: { locatorClass: 'claude-hook-jsonl', offset: record.byteOffset },
189
204
  extensions: {
190
205
  hookEventName: hook.hook_event_name,
191
- lifecycle: hook.hook_event_name === 'SessionEnd' ? 'complete' : 'active',
206
+ ...(hook.hook_event_name === 'SessionEnd' ? { lifecycle: 'complete' } : {}),
192
207
  startSource: hook.source,
193
208
  model: hook.model,
194
209
  reason: hook.reason,
@@ -201,7 +216,7 @@ function normalizeHooks(records) {
201
216
  }
202
217
  return { records: output, complete };
203
218
  }
204
- function providerRecord(value, record, nativeSessionId, block) {
219
+ function providerRecord(value, record, nativeSessionId, sourceArtifactSessionId, block) {
205
220
  const nativeBase = value.uuid ?? value.message?.id;
206
221
  const nativeEventId = block.blockNativeId
207
222
  ?? (nativeBase ? `${nativeBase}:${block.blockIndex ?? 0}` : undefined);
@@ -221,6 +236,18 @@ function providerRecord(value, record, nativeSessionId, block) {
221
236
  productVersion: value.version,
222
237
  workspace: { cwdClass: value.cwd ? '<workspace>' : undefined, gitBranch: value.gitBranch },
223
238
  provenance: { acquisition: 'claude-transcript', schema: CLAUDE_TRANSCRIPT_SCHEMA_VERSION },
239
+ transcriptFamily: {
240
+ sourceArtifactSessionId,
241
+ nativeSessionId,
242
+ identityRelation: nativeSessionId === sourceArtifactSessionId ? 'self' : 'related',
243
+ parentUuid: value.parentUuid,
244
+ parentSessionId: value.parentSessionId,
245
+ gitBranch: value.gitBranch,
246
+ continuation: record.sequence > 0 && value.parentUuid !== null,
247
+ subagent: value.isSidechain === true || Boolean(value.agentId),
248
+ agentId: value.agentId,
249
+ agentSlug: value.slug,
250
+ },
224
251
  opaque: block.opaque,
225
252
  unknownFields: {
226
253
  ...unknownFields(value, TRANSCRIPT_KEYS),
@@ -348,7 +375,8 @@ function unknownFields(value, known) {
348
375
  }
349
376
  const TRANSCRIPT_KEYS = new Set([
350
377
  'type', 'subtype', 'uuid', 'parentUuid', 'sessionId', 'session_id',
351
- 'timestamp', 'cwd', 'gitBranch', 'version', 'schemaVersion', 'message',
378
+ 'timestamp', 'cwd', 'gitBranch', 'parentSessionId', 'isSidechain',
379
+ 'agentId', 'slug', 'version', 'schemaVersion', 'message',
352
380
  ]);
353
381
  const HOOK_KEYS = new Set([
354
382
  'session_id', 'transcript_path', 'cwd', 'hook_event_name', 'permission_mode',
@@ -83,7 +83,8 @@ export class CodexSessionAdapter {
83
83
  let mode = isAppServerLocator(source.locator)
84
84
  ? 'app-server' : null;
85
85
  let schemaVersion = null;
86
- let nativeSessionId = rolloutIdFromFilename(source.locator);
86
+ const sourceArtifactSessionId = rolloutIdFromFilename(source.locator);
87
+ let nativeSessionId = sourceArtifactSessionId;
87
88
  const identities = new Map();
88
89
  let outputIndex = 0;
89
90
  let sawRecord = false;
@@ -118,15 +119,12 @@ export class CodexSessionAdapter {
118
119
  if (!declaredId) {
119
120
  throw new SessionContractError('MALFORMED_SOURCE', 'Codex session metadata is missing its id');
120
121
  }
121
- if (nativeSessionId && nativeSessionId !== declaredId) {
122
- throw new SessionContractError('SCHEMA_DRIFT', 'Codex rollout session identity differs from its filename identity');
123
- }
124
122
  nativeSessionId = declaredId;
125
123
  }
126
124
  if (!nativeSessionId) {
127
125
  throw new SessionContractError('MALFORMED_SOURCE', 'Codex rollout has no session identity before content records');
128
126
  }
129
- normalized = [rolloutRecord(nativeSessionId, parsed.data, payload, line)];
127
+ normalized = [rolloutRecord(nativeSessionId, parsed.data, payload, line, sourceArtifactSessionId)];
130
128
  }
131
129
  for (const record of normalized) {
132
130
  if (outputIndex++ >= start)
@@ -188,6 +186,15 @@ function normalizeAppServer(input) {
188
186
  sequence: line.sequence * 1_000,
189
187
  kind: event === 'compacted' ? 'summary' : 'codex.lifecycle',
190
188
  role: 'system',
189
+ activityBoundary: event === 'unarchived'
190
+ ? 'resume'
191
+ : event === 'compacted'
192
+ ? 'continuation'
193
+ : event === 'archived' || event === 'deleted'
194
+ ? 'end'
195
+ : undefined,
196
+ activityBoundaryBasis: `codex-app-server:${event}`,
197
+ activityBoundaryConfidence: event === 'status-changed' ? 'medium' : 'high',
191
198
  text: '',
192
199
  rawReference: { locatorClass: 'codex-app-server-jsonl', offset: line.byteOffset },
193
200
  extensions: {
@@ -201,7 +208,8 @@ function normalizeAppServer(input) {
201
208
  return { records, complete: complete && !sawActive };
202
209
  }
203
210
  function normalizeRollout(input, locator) {
204
- let nativeSessionId = rolloutIdFromFilename(locator);
211
+ const sourceArtifactSessionId = rolloutIdFromFilename(locator);
212
+ let nativeSessionId = sourceArtifactSessionId;
205
213
  const records = [];
206
214
  for (const line of input) {
207
215
  const parsed = RolloutEnvelopeSchema.safeParse(line.value);
@@ -215,15 +223,12 @@ function normalizeRollout(input, locator) {
215
223
  if (!declaredId) {
216
224
  throw new SessionContractError('MALFORMED_SOURCE', 'Codex session metadata is missing its id');
217
225
  }
218
- if (nativeSessionId && nativeSessionId !== declaredId) {
219
- throw new SessionContractError('SCHEMA_DRIFT', 'Codex rollout session identity differs from its filename identity');
220
- }
221
226
  nativeSessionId = declaredId;
222
227
  }
223
228
  if (!nativeSessionId) {
224
229
  throw new SessionContractError('MALFORMED_SOURCE', 'Codex rollout has no session identity before content records');
225
230
  }
226
- records.push(rolloutRecord(nativeSessionId, envelope, payload, line));
231
+ records.push(rolloutRecord(nativeSessionId, envelope, payload, line, sourceArtifactSessionId));
227
232
  }
228
233
  return { records, complete: false };
229
234
  }
@@ -281,7 +286,7 @@ function threadItem(thread, turn, item, line, turnIndex, itemIndex) {
281
286
  },
282
287
  };
283
288
  }
284
- function rolloutRecord(nativeSessionId, envelope, payload, line) {
289
+ function rolloutRecord(nativeSessionId, envelope, payload, line, sourceArtifactSessionId) {
285
290
  const nativeId = stringValue(payload.id)
286
291
  ?? stringValue(payload.call_id)
287
292
  ?? `${envelope.type}:${line.sequence}`;
@@ -298,6 +303,21 @@ function rolloutRecord(nativeSessionId, envelope, payload, line) {
298
303
  toolCallId: stringValue(payload.call_id),
299
304
  model: stringValue(payload.model),
300
305
  occurredAt: envelope.timestamp,
306
+ activityBoundary: envelope.type === 'compacted'
307
+ ? 'continuation'
308
+ : envelope.type === 'event_msg'
309
+ && stringValue(payload.type) === 'task_complete'
310
+ ? 'end'
311
+ : undefined,
312
+ activityBoundaryBasis: envelope.type === 'compacted'
313
+ ? 'codex-rollout:compacted'
314
+ : envelope.type === 'event_msg' && stringValue(payload.type) === 'task_complete'
315
+ ? 'codex-rollout:task-complete'
316
+ : undefined,
317
+ activityBoundaryConfidence: envelope.type === 'compacted'
318
+ || (envelope.type === 'event_msg' && stringValue(payload.type) === 'task_complete')
319
+ ? 'high'
320
+ : undefined,
301
321
  text: rolloutText(payload),
302
322
  rawReference: { locatorClass: 'codex-rollout-jsonl', offset: line.byteOffset },
303
323
  extensions: {
@@ -310,6 +330,13 @@ function rolloutRecord(nativeSessionId, envelope, payload, line) {
310
330
  : undefined,
311
331
  productVersion: stringValue(payload.cli_version),
312
332
  provenance: { acquisition: 'codex-rollout', durableReplay: true },
333
+ transcriptFamily: {
334
+ sourceArtifactSessionId,
335
+ nativeSessionId,
336
+ identityRelation: !sourceArtifactSessionId || sourceArtifactSessionId === nativeSessionId
337
+ ? 'self'
338
+ : 'related',
339
+ },
313
340
  opaque: !KNOWN_ROLLOUT_TYPES.has(envelope.type),
314
341
  unknownFields: unknownFields(payload, ROLLOUT_KEYS),
315
342
  },