@aiwg/cli 2026.7.24 → 2026.8.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.
@@ -937,12 +937,19 @@ async function handleExport(args) {
937
937
  console.log(' --out <path> Write JSON or .shard output to a file');
938
938
  console.log(' --repo <name> Source repository label (default: cwd basename)');
939
939
  console.log(' --privacy <level> private, sanitized, or public (default: private)');
940
- console.log(' --schema-version <v> Export contract version: v1 or v2 (default: v1)');
940
+ console.log(' --schema-version <v> Browser: v1|v2; shard: 2.0.0|1.2.0');
941
+ console.log(' --profile <name> Shard profile: full-v1 (default) or core-v1');
942
+ console.log(' --fail-on-loss Reject a full-v1 conversion that reports any loss');
943
+ console.log(' --dry-run Build and report without writing the shard');
944
+ console.log(' --force Replace an existing output path');
945
+ console.log(' --migrate-from <path> Diagnose a source-less legacy shard (dry-run only)');
946
+ console.log(' --json Emit the machine-readable conversion report');
941
947
  console.log(' --generated-at <iso> Override generated timestamp for deterministic fixtures');
942
948
  console.log('');
943
949
  console.log('Examples:');
944
950
  console.log(' aiwg index export --format fortemi --graph project --out aiwg-fortemi-index.json');
945
- console.log(' aiwg index export --format fortemi-shard --graph project --out aiwg-index.shard');
951
+ console.log(' aiwg index export --format fortemi-shard --graph project --schema-version 2.0.0 --profile full-v1 --fail-on-loss --out aiwg-index.shard');
952
+ console.log(' aiwg index export --format fortemi-shard --migrate-from legacy.shard --dry-run --json');
946
953
  console.log(' aiwg index export --format fortemi --privacy sanitized --generated-at 2026-01-01T00:00:00.000Z');
947
954
  return;
948
955
  }
@@ -960,29 +967,65 @@ async function handleExport(args) {
960
967
  process.exit(1);
961
968
  }
962
969
  const generatedAt = parseFlagValue(args, '--generated-at', 'Error: --generated-at requires an ISO timestamp value');
963
- const schemaVersion = parseFlagValue(args, '--schema-version', 'Error: --schema-version must be v1 or v2');
964
- if (schemaVersion && !['v1', 'v2'].includes(schemaVersion)) {
965
- console.error('Error: --schema-version must be v1 or v2');
970
+ const schemaVersion = parseFlagValue(args, '--schema-version', 'Error: --schema-version requires a value');
971
+ const profile = parseFlagValue(args, '--profile', 'Error: --profile requires a value');
972
+ const migrateFrom = parseFlagValue(args, '--migrate-from', 'Error: --migrate-from requires a shard path');
973
+ const dryRun = args.includes('--dry-run');
974
+ const json = args.includes('--json');
975
+ if (format === 'fortemi' && schemaVersion && !['v1', 'v2'].includes(schemaVersion)) {
976
+ console.error('Error: browser export --schema-version must be v1 or v2');
977
+ process.exit(1);
978
+ }
979
+ if (format === 'fortemi-shard' && schemaVersion && !['1.2.0', '2.0.0'].includes(schemaVersion)) {
980
+ console.error('Error: shard --schema-version must be 2.0.0 or 1.2.0');
966
981
  process.exit(1);
967
982
  }
968
- if (format === 'fortemi-shard' && schemaVersion && schemaVersion !== 'v2') {
969
- console.error('Error: --format fortemi-shard requires --schema-version v2');
983
+ if (format === 'fortemi-shard' && profile && !['full-v1', 'core-v1'].includes(profile)) {
984
+ console.error('Error: shard --profile must be full-v1 or core-v1');
970
985
  process.exit(1);
971
986
  }
972
- if (format === 'fortemi-shard' && !out) {
987
+ if (format === 'fortemi-shard' && !out && !migrateFrom) {
973
988
  console.error('Error: --format fortemi-shard requires --out <path>');
974
989
  process.exit(1);
975
990
  }
976
991
  try {
977
992
  if (format === 'fortemi-shard') {
978
- const { writeAiwgFortemiKnowledgeShard } = await import('./fortemi-shard-export.js');
993
+ const { diagnoseAiwgFortemiShardMigration, writeAiwgFortemiKnowledgeShard, } = await import('./fortemi-shard-export.js');
994
+ if (migrateFrom) {
995
+ if (!dryRun) {
996
+ throw new Error('--migrate-from is diagnostic-only and requires --dry-run; '
997
+ + 'source-less core-v1 artifacts cannot be promoted losslessly.');
998
+ }
999
+ const diagnosis = diagnoseAiwgFortemiShardMigration(process.cwd(), migrateFrom);
1000
+ if (json)
1001
+ console.log(JSON.stringify(diagnosis, null, 2));
1002
+ else {
1003
+ console.log(`Migration supported: no`);
1004
+ console.log(`Diagnostic: ${diagnosis.diagnostic}`);
1005
+ console.log(`Action: ${diagnosis.action}`);
1006
+ }
1007
+ return;
1008
+ }
979
1009
  const result = await writeAiwgFortemiKnowledgeShard(process.cwd(), out, {
980
1010
  graph,
981
1011
  repo,
982
1012
  privacy: privacy,
983
1013
  generatedAt,
1014
+ schemaVersion: schemaVersion,
1015
+ profile: profile,
1016
+ failOnLoss: args.includes('--fail-on-loss'),
1017
+ dryRun,
1018
+ overwrite: args.includes('--force'),
984
1019
  });
985
- console.log(`Exported ${result.items} AIWG records to ${result.outPath} (${result.bytes} bytes)`);
1020
+ if (json)
1021
+ console.log(JSON.stringify(result, null, 2));
1022
+ else {
1023
+ const action = result.written ? 'Exported' : 'Would export';
1024
+ console.log(`${action} ${result.items} AIWG records as `
1025
+ + `${result.conversion.schemaVersion}/${result.conversion.profile} `
1026
+ + `to ${result.outPath} (${result.bytes} bytes; `
1027
+ + `${result.conversion.losses.length} losses)`);
1028
+ }
986
1029
  return;
987
1030
  }
988
1031
  const { buildAiwgFortemiIndexExport, writeAiwgFortemiIndexExport } = await import('./browser-export.js');
@@ -1,7 +1,20 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import { buildAiwgFortemiIndexExport, } from "./browser-export.js";
4
- async function loadFortemiShardConverter() {
4
+ const SUPPORTED_SHARD_TUPLES = new Set([
5
+ "1.2.0/core-v1",
6
+ "2.0.0/full-v1",
7
+ ]);
8
+ export function resolveAiwgFortemiShardTuple(options) {
9
+ const schemaVersion = options.schemaVersion ?? "2.0.0";
10
+ const profile = options.profile ?? "full-v1";
11
+ if (!SUPPORTED_SHARD_TUPLES.has(`${schemaVersion}/${profile}`)) {
12
+ throw new Error(`Unsupported Fortemi Knowledge Shard tuple ${schemaVersion}/${profile}; `
13
+ + "supported tuples are 2.0.0/full-v1 and 1.2.0/core-v1.");
14
+ }
15
+ return { schemaVersion, profile };
16
+ }
17
+ async function loadFortemiShardConverters() {
5
18
  let core;
6
19
  try {
7
20
  core = (await import(
@@ -10,35 +23,111 @@ async function loadFortemiShardConverter() {
10
23
  catch {
11
24
  throw new Error("Portable Fortemi shard export requires @fortemi/core with aiwgFortemiIndexToKnowledgeShard.");
12
25
  }
13
- if (!core.aiwgFortemiIndexToKnowledgeShard) {
14
- throw new Error("Installed @fortemi/core does not support portable AIWG shards; upgrade to the release containing aiwgFortemiIndexToKnowledgeShard.");
26
+ if (!core.aiwgFortemiIndexToKnowledgeShard
27
+ || !core.aiwgFortemiIndexToKnowledgeShardWithReport) {
28
+ throw new Error("Installed @fortemi/core does not support both public AIWG shard converters; "
29
+ + "upgrade to a compatible published release.");
15
30
  }
16
- return core.aiwgFortemiIndexToKnowledgeShard;
31
+ return core;
17
32
  }
18
- export async function buildAiwgFortemiKnowledgeShard(cwd, options = {}, converter) {
33
+ export async function buildAiwgFortemiKnowledgeShardWithReport(cwd, options = {}, converter, reportConverter) {
19
34
  const exported = buildAiwgFortemiIndexExport(cwd, {
20
35
  ...options,
21
36
  schemaVersion: "v2",
22
37
  });
23
- const convert = converter ?? (await loadFortemiShardConverter());
24
- return convert(exported, {
25
- createdAt: options.generatedAt,
26
- matricVersion: "aiwg",
27
- });
38
+ const tuple = resolveAiwgFortemiShardTuple(options);
39
+ const needsCore = tuple.profile === "core-v1" ? !converter : !reportConverter;
40
+ const core = needsCore ? await loadFortemiShardConverters() : null;
41
+ if (tuple.profile === "core-v1") {
42
+ const archive = await (converter ?? core.aiwgFortemiIndexToKnowledgeShard)(exported, { createdAt: options.generatedAt, matricVersion: "aiwg" });
43
+ return {
44
+ ...tuple,
45
+ success: true,
46
+ lossless: true,
47
+ losses: [],
48
+ receipt: null,
49
+ archive,
50
+ };
51
+ }
52
+ const result = await (reportConverter ?? core.aiwgFortemiIndexToKnowledgeShardWithReport)(exported, { createdAt: options.generatedAt, matricVersion: "aiwg" });
53
+ if (!result.success || !result.archive) {
54
+ throw new Error("Fortemi full-v1 conversion failed without producing an archive; losses="
55
+ + JSON.stringify(result.losses));
56
+ }
57
+ if (options.failOnLoss && (!result.lossless || result.losses.length > 0)) {
58
+ throw new Error(`Fortemi full-v1 conversion reported ${result.losses.length} loss(es); `
59
+ + "archive was not written because --fail-on-loss was set.");
60
+ }
61
+ return {
62
+ ...tuple,
63
+ success: result.success,
64
+ lossless: result.lossless,
65
+ losses: result.losses,
66
+ receipt: result.receipt,
67
+ archive: result.archive,
68
+ };
69
+ }
70
+ export async function buildAiwgFortemiKnowledgeShard(cwd, options = {}, converter, reportConverter) {
71
+ return (await buildAiwgFortemiKnowledgeShardWithReport(cwd, options, converter, reportConverter)).archive;
28
72
  }
29
- export async function writeAiwgFortemiKnowledgeShard(cwd, outPath, options = {}, converter) {
73
+ export async function writeAiwgFortemiKnowledgeShard(cwd, outPath, options = {}, converter, reportConverter) {
30
74
  const exported = buildAiwgFortemiIndexExport(cwd, {
31
75
  ...options,
32
76
  schemaVersion: "v2",
33
77
  });
34
- const convert = converter ?? (await loadFortemiShardConverter());
35
- const shard = await convert(exported, {
36
- createdAt: options.generatedAt,
37
- matricVersion: "aiwg",
38
- });
78
+ const conversion = await buildAiwgFortemiKnowledgeShardWithReport(cwd, options, converter, reportConverter);
39
79
  const resolved = path.resolve(cwd, outPath);
80
+ const report = {
81
+ bytes: conversion.archive.byteLength,
82
+ items: exported.items.length,
83
+ outPath: resolved,
84
+ written: !options.dryRun,
85
+ conversion: {
86
+ schemaVersion: conversion.schemaVersion,
87
+ profile: conversion.profile,
88
+ success: conversion.success,
89
+ lossless: conversion.lossless,
90
+ losses: conversion.losses,
91
+ receipt: conversion.receipt,
92
+ },
93
+ };
94
+ if (options.dryRun)
95
+ return report;
96
+ if (fs.existsSync(resolved) && !options.overwrite) {
97
+ throw new Error(`Refusing to overwrite existing shard ${resolved}; choose a new path or pass --force.`);
98
+ }
40
99
  fs.mkdirSync(path.dirname(resolved), { recursive: true });
41
- fs.writeFileSync(resolved, shard);
42
- return { bytes: shard.byteLength, items: exported.items.length, outPath: resolved };
100
+ const temporary = `${resolved}.tmp-${process.pid}`;
101
+ try {
102
+ fs.writeFileSync(temporary, conversion.archive, { flag: "wx" });
103
+ if (options.overwrite) {
104
+ fs.renameSync(temporary, resolved);
105
+ }
106
+ else {
107
+ fs.linkSync(temporary, resolved);
108
+ fs.unlinkSync(temporary);
109
+ }
110
+ }
111
+ catch (error) {
112
+ if (fs.existsSync(temporary))
113
+ fs.unlinkSync(temporary);
114
+ throw error;
115
+ }
116
+ return report;
117
+ }
118
+ export function diagnoseAiwgFortemiShardMigration(cwd, inputPath) {
119
+ const resolved = path.resolve(cwd, inputPath);
120
+ if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
121
+ throw new Error(`Legacy shard input does not exist or is not a regular file: ${resolved}`);
122
+ }
123
+ return {
124
+ supported: false,
125
+ inputPath: resolved,
126
+ mutationPlanned: false,
127
+ diagnostic: "Source-less core-v1 to full-v1 conversion is unsupported because omitted rich "
128
+ + "components cannot be reconstructed without loss.",
129
+ action: "Regenerate from the source-backed AIWG index with "
130
+ + "--schema-version 2.0.0 --profile full-v1; retain the legacy shard for rollback.",
131
+ };
43
132
  }
44
133
  //# sourceMappingURL=fortemi-shard-export.js.map
@@ -57,12 +57,13 @@ import { commandLogHandler } from './command-log.js';
57
57
  import { skillUsageHandler } from './skill-usage.js';
58
58
  import { modelsHandler } from './models.js';
59
59
  import { versionsHandler } from './resource-versions.js';
60
+ import { jobHandler } from './job.js';
60
61
  // Re-export individual handlers
61
62
  export {
62
63
  // Maintenance
63
64
  helpHandler, versionHandler, doctorHandler, updateHandler, refreshHandler, regenerateHandler, workspaceContextHandler,
64
65
  // Framework management
65
- useHandler, listHandler, removeHandler, promoteHandler, installHandler, packagesHandler, marketplaceHandler, initHandler, setupHandler, setupGenerateHandler, setupRunHandler, setupValidateHandler, issueHandler, issueAuditHandler, runHandler,
66
+ useHandler, listHandler, removeHandler, promoteHandler, installHandler, packagesHandler, marketplaceHandler, initHandler, setupHandler, setupGenerateHandler, setupRunHandler, setupValidateHandler, issueHandler, issueAuditHandler, runHandler, jobHandler,
66
67
  // Project
67
68
  newBundleHandler, quickrefHandler, newProjectHandler, sessionHandler, sessionsHandler,
68
69
  // Workspace
@@ -139,6 +140,7 @@ export const allHandlers = [
139
140
  issueHandler,
140
141
  issueAuditHandler,
141
142
  runHandler,
143
+ jobHandler,
142
144
  // Workspace management
143
145
  ...workspaceHandlers,
144
146
  // Subcommand handlers (MCP, catalog, index, skills)
@@ -0,0 +1,97 @@
1
+ import { constants, promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { CodexJobExecutor } from '../../jobs/executor.js';
4
+ import { loadJobFlow, resolveWorkspaceFile } from '../../jobs/flow.js';
5
+ import { GiteaWorkItemClient } from '../../jobs/gitea.js';
6
+ import { renderExternalTrigger } from '../../jobs/render.js';
7
+ import { runExternalJob } from '../../jobs/runner.js';
8
+ import { AiwgError, EXIT_CODES, handlerResultFromError } from '../errors.js';
9
+ function option(args, name) {
10
+ const index = args.indexOf(name);
11
+ return index >= 0 ? args[index + 1] : undefined;
12
+ }
13
+ function usage(message) {
14
+ throw new AiwgError({ code: 'ERR_USAGE_JOB', message, exitCode: EXIT_CODES.USAGE, hint: 'Run `aiwg job help` for usage.' });
15
+ }
16
+ function help() {
17
+ console.log(`AIWG external-trigger jobs
18
+
19
+ Usage:
20
+ aiwg job validate <flow>
21
+ aiwg job render-cron <flow> [--format cron|systemd|gitea-actions]
22
+ aiwg job run <flow> --once [--state-dir <absolute-path>] [--json]
23
+
24
+ The operating system or CI owns time. AIWG validates and executes one reviewed job.`);
25
+ }
26
+ async function validateFiles(flow) {
27
+ const workspace = await fs.realpath(flow.spec.executor.workspace);
28
+ if (workspace !== flow.spec.executor.workspace) {
29
+ throw new Error('executor.workspace must be canonical (no symlink or relative segments)');
30
+ }
31
+ const prompt = resolveWorkspaceFile(flow, flow.spec.executor.prompt);
32
+ const schema = resolveWorkspaceFile(flow, flow.spec.executor.resultSchema);
33
+ const [promptStat, schemaSource] = await Promise.all([fs.stat(prompt), fs.readFile(schema, 'utf8'), fs.access(flow.spec.executor.binary, constants.X_OK)]);
34
+ if (!promptStat.isFile())
35
+ throw new Error('executor.prompt must resolve to a regular file');
36
+ JSON.parse(schemaSource);
37
+ for (const root of flow.spec.security.approvedAttachmentRoots) {
38
+ const [canonical, stat] = await Promise.all([fs.realpath(root), fs.stat(root)]);
39
+ if (canonical !== root || !stat.isDirectory())
40
+ throw new Error('approvedAttachmentRoots must contain canonical directories');
41
+ }
42
+ }
43
+ async function execute(ctx) {
44
+ try {
45
+ const [subcommand = 'help', flowArg] = ctx.args;
46
+ if (subcommand === 'help' || subcommand === '--help' || subcommand === '-h') {
47
+ help();
48
+ return { exitCode: 0 };
49
+ }
50
+ if (!['validate', 'render-cron', 'run'].includes(subcommand))
51
+ usage(`Unknown job subcommand: ${subcommand}`);
52
+ if (!flowArg || flowArg.startsWith('-'))
53
+ usage(`job ${subcommand} requires a flow file`);
54
+ const loaded = await loadJobFlow(flowArg, ctx.cwd);
55
+ await validateFiles(loaded.flow);
56
+ if (subcommand === 'validate') {
57
+ console.log(JSON.stringify({ valid: true, apiVersion: loaded.flow.apiVersion, kind: loaded.flow.kind, name: loaded.flow.metadata.name }));
58
+ return { exitCode: 0 };
59
+ }
60
+ if (subcommand === 'render-cron') {
61
+ const format = (option(ctx.args.slice(2), '--format') ?? 'cron');
62
+ if (!['cron', 'systemd', 'gitea-actions'].includes(format))
63
+ usage(`Unsupported scheduler format: ${format}`);
64
+ console.log(renderExternalTrigger(loaded.flow, loaded.file, format));
65
+ return { exitCode: 0 };
66
+ }
67
+ if (!ctx.args.includes('--once'))
68
+ usage('job run requires --once; AIWG does not own a resident scheduler');
69
+ const stateRoot = option(ctx.args.slice(2), '--state-dir');
70
+ if (stateRoot && !stateRoot.startsWith('/'))
71
+ usage('--state-dir must be absolute');
72
+ if (stateRoot && path.resolve(stateRoot) === path.parse(path.resolve(stateRoot)).root)
73
+ usage('--state-dir must not be a filesystem root');
74
+ const client = await GiteaWorkItemClient.create(loaded.flow);
75
+ const result = await runExternalJob({
76
+ flow: loaded.flow,
77
+ client,
78
+ executor: new CodexJobExecutor(),
79
+ ...(stateRoot ? { stateRoot } : {}),
80
+ signal: ctx.signal,
81
+ });
82
+ console.log(JSON.stringify(result, null, ctx.args.includes('--json') ? 2 : 0));
83
+ return { exitCode: result.status === 'failed-verification' ? EXIT_CODES.GENERAL : EXIT_CODES.OK };
84
+ }
85
+ catch (error) {
86
+ return handlerResultFromError(error);
87
+ }
88
+ }
89
+ export const jobHandler = {
90
+ id: 'job',
91
+ name: 'External Job',
92
+ description: 'Validate, render, or run one externally triggered provider job',
93
+ category: 'orchestration',
94
+ aliases: ['job'],
95
+ execute,
96
+ };
97
+ //# sourceMappingURL=job.js.map
@@ -258,7 +258,7 @@ async function handleRuntimeInfo(args, cwd = process.cwd()) {
258
258
  console.log(`Catalog: ${summary.catalogPath}`);
259
259
  // Scheduler backend detection
260
260
  const { execSync } = await import('child_process');
261
- let schedulerBackend = 'aiwg-cli (daemon)';
261
+ let schedulerBackend = 'external trigger required (system cron/systemd/CI)';
262
262
  let chronyInstalled = false;
263
263
  try {
264
264
  execSync('which chronyc 2>/dev/null || which chronyd 2>/dev/null', { stdio: 'pipe' });
@@ -273,7 +273,7 @@ async function handleRuntimeInfo(args, cwd = process.cwd()) {
273
273
  const isClaudeCodeContext = process.env.CLAUDE_CODE_VERSION !== undefined ||
274
274
  process.env.ANTHROPIC_API_KEY !== undefined;
275
275
  if (isClaudeCodeContext) {
276
- schedulerBackend = 'native-cron (CronCreate) / aiwg-cli fallback';
276
+ schedulerBackend = 'native-cron (CronCreate); external trigger outside agent sessions';
277
277
  }
278
278
  console.log(`\nScheduler:`);
279
279
  console.log(` Backend: ${schedulerBackend}`);
@@ -64,7 +64,7 @@ function parseServeArgs(args) {
64
64
  // ============================================================
65
65
  // WebSocket routing (#851)
66
66
  //
67
- // @hono/node-server v1.x does not export createNodeWebSocket.
67
+ // @hono/node-server does not export createNodeWebSocket.
68
68
  // We wire WebSocket routes directly via the Node.js HTTP server's
69
69
  // 'upgrade' event and the `ws` npm package instead.
70
70
  // ============================================================
@@ -550,7 +550,7 @@ export async function startServer(opts) {
550
550
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
551
551
  const app = new Hono();
552
552
  // WebSocket routes are handled via Node.js upgrade event below (see setupWebSockets).
553
- // @hono/node-server v1.x does not export createNodeWebSocket.
553
+ // @hono/node-server does not export createNodeWebSocket.
554
554
  // Health check
555
555
  app.get('/api/health', (c) => c.json({ status: 'ok', readOnly: opts.readOnly }));
556
556
  // Connection status — server health, PTY sessions, sandboxes, subsystem status (#887)
@@ -28,6 +28,8 @@ Commands:
28
28
  restore <session-id> Restore a reversible catalog tombstone
29
29
  purge <session-id> Preview terminal AIWG-copy purge
30
30
  audit --workspace <id> Read content-free mutation events
31
+ analytics <view> --workspace <id> Summary, tool-calls, escalations, or HITL
32
+ forensics <view> --workspace <id> Authorized timeline, indicators, or evidence
31
33
  doctor Check catalog availability and integrity
32
34
 
33
35
  Options:
@@ -219,6 +221,95 @@ async function executeCommand(ctx, args) {
219
221
  nextCursor: result.nextCursor },
220
222
  });
221
223
  }
224
+ case 'analytics': {
225
+ const view = requiredPositional(args, 0, 'analytics view');
226
+ const { workspaceId } = readAuthorizationContext(ctx, args, command, repository);
227
+ const query = analyticsQuery(args, workspaceId);
228
+ if (view === 'summary') {
229
+ return ok(command, {
230
+ view,
231
+ summary: repository.analyticsSummary(query),
232
+ });
233
+ }
234
+ const categories = analyticsCategories(view);
235
+ const items = repository.listAnalyticsFacts({ ...query, categories });
236
+ return ok(command, {
237
+ analyticsVersion: '1.0.0',
238
+ view,
239
+ items,
240
+ count: items.length,
241
+ groupBy: analyticsGrouping(items, args.values.get('--group-by')),
242
+ });
243
+ }
244
+ case 'forensics': {
245
+ if (!args.flags.has('--authorize-forensics')) {
246
+ throw new CliError('OPERATION_NOT_AUTHORIZED', 'forensic extraction requires --authorize-forensics for this invocation', EXIT.usage);
247
+ }
248
+ const view = requiredPositional(args, 0, 'forensics view');
249
+ const { workspaceId } = authorizationContext(ctx, args, command);
250
+ const query = analyticsQuery(args, workspaceId);
251
+ if (view === 'indicators') {
252
+ const items = repository.listAnalyticsFacts({
253
+ ...query,
254
+ categories: ['indicator'],
255
+ });
256
+ return ok(command, forensicOutput(view, items, args));
257
+ }
258
+ if (view === 'timeline') {
259
+ const target = requiredPositional(args, 1, 'session-id or query');
260
+ const direct = repository.getSession(target, workspaceId);
261
+ const sessionIds = direct
262
+ ? [target]
263
+ : [...new Set(repository.search({
264
+ query: target,
265
+ workspaceId,
266
+ limit: boundedInteger(args.values.get('--limit'), 50, 1, 500, '--limit'),
267
+ }).items.map((item) => item.sessionId))];
268
+ const items = sessionIds.flatMap((sessionId) => repository.listAnalyticsFacts({ ...query, sessionId }));
269
+ return ok(command, forensicOutput(view, items, args));
270
+ }
271
+ if (view === 'evidence') {
272
+ const id = requiredPositional(args, 1, 'event-id, fact-id, or candidate-id');
273
+ const evidence = repository.getAnalyticsEvidence(id, workspaceId);
274
+ if (evidence.fact && evidence.event) {
275
+ return ok(command, {
276
+ analyticsVersion: '1.0.0',
277
+ view,
278
+ fact: evidence.fact,
279
+ event: {
280
+ eventId: evidence.event.eventId,
281
+ sessionId: evidence.event.sessionId,
282
+ sourceId: evidence.event.sourceId,
283
+ importRunId: evidence.event.importRunId,
284
+ sequence: evidence.event.sequence,
285
+ kind: evidence.event.kind,
286
+ occurredAt: evidence.event.occurredAt,
287
+ sensitivity: evidence.event.sensitivity,
288
+ rawReference: evidence.event.rawReference,
289
+ digest: evidence.event.digest,
290
+ },
291
+ });
292
+ }
293
+ const candidate = repository.getCandidate(id, undefined, workspaceId);
294
+ if (!candidate) {
295
+ throw new CliError('EVIDENCE_NOT_FOUND', `authorized analytics evidence not found: ${id}`, EXIT.unavailable);
296
+ }
297
+ return ok(command, {
298
+ analyticsVersion: '1.0.0',
299
+ view,
300
+ candidate: {
301
+ candidateId: candidate.candidateId,
302
+ version: candidate.version,
303
+ type: candidate.type,
304
+ evidence: candidate.evidence.map(({ quote: _quote, ...citation }) => citation),
305
+ sensitivity: candidate.sensitivity,
306
+ security: candidate.security,
307
+ reviewState: candidate.reviewState,
308
+ },
309
+ });
310
+ }
311
+ throw new CliError('INVALID_ARGUMENT', `unknown forensics view: ${view}`, EXIT.usage);
312
+ }
222
313
  case 'extract': {
223
314
  const { workspaceId } = authorizationContext(ctx, args, command);
224
315
  const sessionId = args.positionals[0];
@@ -997,6 +1088,7 @@ function parseArgs(argv) {
997
1088
  '--manifest', '--provider-home', '--codex-root', '--lock-wait-ms', '--min-coverage', '--gap',
998
1089
  '--inactivity-threshold',
999
1090
  '--control-events',
1091
+ '--session', '--status', '--actor', '--group-by',
1000
1092
  ]);
1001
1093
  let command;
1002
1094
  for (let index = 0; index < argv.length; index += 1) {
@@ -1184,6 +1276,96 @@ function dependentAction(value) {
1184
1276
  }
1185
1277
  return value;
1186
1278
  }
1279
+ function analyticsQuery(args, workspaceId) {
1280
+ const providerInput = args.values.get('--provider');
1281
+ const statusInput = args.values.get('--status');
1282
+ return {
1283
+ workspaceId,
1284
+ provider: providerInput ? assertSessionProviderId(providerInput) : undefined,
1285
+ sessionId: args.values.get('--session'),
1286
+ dateFrom: args.values.get('--date-from'),
1287
+ dateTo: args.values.get('--date-to'),
1288
+ tool: args.values.get('--tool'),
1289
+ status: statusInput ? analyticsStatus(statusInput) : undefined,
1290
+ actor: args.values.get('--actor') ?? args.values.get('--participant'),
1291
+ tag: args.values.get('--tag'),
1292
+ sensitivity: args.values.get('--sensitivity'),
1293
+ extractionState: args.values.get('--extraction-state'),
1294
+ limit: boundedInteger(args.values.get('--limit'), 500, 1, 5_000, '--limit'),
1295
+ };
1296
+ }
1297
+ function analyticsStatus(value) {
1298
+ const allowed = new Set([
1299
+ 'requested', 'running', 'succeeded', 'failed', 'granted', 'denied',
1300
+ 'timed-out', 'unsupported', 'provider-unknown', 'observed',
1301
+ ]);
1302
+ if (!allowed.has(value)) {
1303
+ throw new CliError('INVALID_ARGUMENT', `invalid analytics status: ${value}`, EXIT.usage);
1304
+ }
1305
+ return value;
1306
+ }
1307
+ function analyticsCategories(view) {
1308
+ if (view === 'tool-calls')
1309
+ return ['tool-call', 'tool-result'];
1310
+ if (view === 'escalations')
1311
+ return ['escalation'];
1312
+ if (view === 'hitl')
1313
+ return ['hitl'];
1314
+ throw new CliError('INVALID_ARGUMENT', `unknown analytics view: ${view}`, EXIT.usage);
1315
+ }
1316
+ function analyticsGrouping(items, groupBy) {
1317
+ if (!groupBy)
1318
+ return null;
1319
+ if (!['tool', 'session', 'provider'].includes(groupBy)) {
1320
+ throw new CliError('INVALID_ARGUMENT', `invalid --group-by value: ${groupBy}`, EXIT.usage);
1321
+ }
1322
+ const counts = {};
1323
+ for (const item of items) {
1324
+ const value = groupBy === 'tool'
1325
+ ? item.toolName
1326
+ : groupBy === 'session'
1327
+ ? item.sessionId
1328
+ : item.provider;
1329
+ const key = typeof value === 'string' && value ? value : '<unknown>';
1330
+ counts[key] = (counts[key] ?? 0) + 1;
1331
+ }
1332
+ return Object.fromEntries(Object.entries(counts).sort(([left], [right]) => left.localeCompare(right)));
1333
+ }
1334
+ function forensicOutput(view, items, args) {
1335
+ const output = {
1336
+ analyticsVersion: '1.0.0',
1337
+ view,
1338
+ items,
1339
+ count: items.length,
1340
+ authorization: {
1341
+ explicit: true,
1342
+ providerLogsModified: false,
1343
+ historicalContentExecuted: false,
1344
+ },
1345
+ };
1346
+ if (args.flags.has('--markdown')) {
1347
+ output.markdown = [
1348
+ `# Session Forensics ${view}`,
1349
+ '',
1350
+ `Facts: ${items.length}`,
1351
+ '',
1352
+ '| Time | Provider | Session | Category | Status | Evidence |',
1353
+ '|---|---|---|---|---|---|',
1354
+ ...items.map((item) => {
1355
+ const citation = item.sourceCitation;
1356
+ return [
1357
+ item.occurredAt ?? '<unknown>',
1358
+ item.provider ?? '<unknown>',
1359
+ item.sessionId ?? '<unknown>',
1360
+ item.category ?? '<unknown>',
1361
+ item.status ?? '<unknown>',
1362
+ citation?.eventId ?? item.eventId ?? '<unknown>',
1363
+ ].map((value) => String(value).replaceAll('|', '\\|')).join(' | ');
1364
+ }).map((row) => `| ${row} |`),
1365
+ ].join('\n');
1366
+ }
1367
+ return output;
1368
+ }
1187
1369
  function isDryRun(ctx, args) {
1188
1370
  return Boolean(ctx.dryRun || args.flags.has('--dry-run'));
1189
1371
  }
@@ -1202,6 +1384,12 @@ function emit(value) {
1202
1384
  function printHuman(value) {
1203
1385
  if (value.status === 'preview')
1204
1386
  console.log('Preview (no changes applied)');
1387
+ if (value.command === 'sessions.forensics' && value.data
1388
+ && typeof value.data === 'object' && 'markdown' in value.data
1389
+ && typeof value.data.markdown === 'string') {
1390
+ console.log(value.data.markdown);
1391
+ return;
1392
+ }
1205
1393
  if (value.command === 'sessions.timeline' && value.data
1206
1394
  && typeof value.data === 'object' && 'items' in value.data) {
1207
1395
  const data = value.data;