@akagilnc/pi-workflow-roles 0.1.2276 → 0.1.2296

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/CLAUDE.md CHANGED
@@ -82,7 +82,7 @@ A probe is temporary evidence. After its evidence purpose is disposed, either de
82
82
 
83
83
  **机器只咬契约,不咬呈现**:对自由文本的正则/措辞/表头机械依赖、对图像的像素机械依赖,视同缺陷;机器要消费的信息必须以键、typed 字段或 schema 提供。呈现为人服务,随时可重排。
84
84
 
85
- **生产与统计两个 regime(陛下 2026-08-25 释宪)**:上条禁令的对象是生产代码对不可穷举输入的机械依赖。统计/分析对**已冻结卷宗**不适用该禁令——太史报告层可由 LLM 做语义分类与统计,逐条附卷宗引证即为可核;形式语言命令(shell 等)非散文,不因 LLM 书写而成「自由文本」。太史代码机制维持确定性(ADR 0047/0068 不动);闸判据仍只认 typed 键。
85
+ **生产与统计两个 regime(陛下 2026-08-25 释宪)**:上条禁令的对象是生产代码对不可穷举输入的机械依赖。统计/分析对**已冻结卷宗**不适用该禁令——太史报告层可由 LLM 做语义分类与统计,逐条附卷宗引证即为可核;形式语言命令(shell 等)非散文,不因 LLM 书写而成「自由文本」。太史代码机制维持确定性(ADR 0047/0068 不动);闸判据仍只认 typed 键。报告引证同符宝郎证据条款:**指针(runId/toolCallId/路径)即合格引用,开卷核对相符即成立,不誊原文进报告**——卷宗是唯一真源,复印副本违 DRY;举证到问题所需粒度为止,汇总申报方法与判据即可,不逐条立账。
86
86
 
87
87
  **票面先过大理寺再开工;决策问题上呈陛下。**
88
88
 
@@ -25815,9 +25815,31 @@ function finishRole(role, accum) {
25815
25815
  successRate: rateMetric(accum.successCount, accum.successEligibleCount)
25816
25816
  };
25817
25817
  }
25818
+ function emptyGateOfficerNumeratorAccum() {
25819
+ return { rounds: 0, bounceCount: 0, passCount: 0, wallSum: 0 };
25820
+ }
25821
+ function absorbGateOfficerSummary(accum, summary) {
25822
+ accum.rounds += summary.rounds;
25823
+ accum.bounceCount += summary.bounceCount;
25824
+ accum.passCount += summary.passCount;
25825
+ if (summary.meanOfficerWallMs !== void 0) {
25826
+ accum.wallSum += summary.meanOfficerWallMs * summary.rounds;
25827
+ }
25828
+ }
25829
+ function finishGateOfficerNumerators(officer, accum) {
25830
+ return {
25831
+ officer,
25832
+ rounds: accum.rounds,
25833
+ bounceCount: accum.bounceCount,
25834
+ passCount: accum.passCount,
25835
+ bounceRate: rateMetric(accum.bounceCount, accum.rounds),
25836
+ meanOfficerWallMs: accum.rounds === 0 ? ABSENT : presentMetric(accum.wallSum / accum.rounds)
25837
+ };
25838
+ }
25818
25839
  async function aggregateGroup(index, input, ensureIssuePage) {
25819
25840
  const issueEntries = [];
25820
25841
  const roleAccums = /* @__PURE__ */ new Map();
25842
+ const gateOfficerAccums = /* @__PURE__ */ new Map();
25821
25843
  let reworkWallMs = 0;
25822
25844
  let totalWallMs = 0;
25823
25845
  let hasReworkSample = false;
@@ -25857,14 +25879,26 @@ async function aggregateGroup(index, input, ensureIssuePage) {
25857
25879
  legWalls.push(leg.wallMs);
25858
25880
  }
25859
25881
  }
25882
+ const gateCycles = page.gateCycles;
25883
+ if (gateCycles !== void 0) {
25884
+ for (const summary of gateCycles.byOfficer) {
25885
+ const accum = gateOfficerAccums.get(summary.officer) ?? emptyGateOfficerNumeratorAccum();
25886
+ absorbGateOfficerSummary(accum, summary);
25887
+ gateOfficerAccums.set(summary.officer, accum);
25888
+ }
25889
+ }
25860
25890
  }
25861
25891
  const byRole = [...roleAccums.keys()].sort((a, b) => a.localeCompare(b)).map((role) => finishRole(role, roleAccums.get(role)));
25892
+ const gateCyclesByOfficer = ["inspector", "notary"].filter((officer) => gateOfficerAccums.has(officer)).map(
25893
+ (officer) => finishGateOfficerNumerators(officer, gateOfficerAccums.get(officer))
25894
+ );
25862
25895
  return {
25863
25896
  groupLabel: input.groupLabel,
25864
25897
  issues: issueEntries,
25865
25898
  byRole,
25866
25899
  reworkRatio: hasReworkSample ? rateMetric(reworkWallMs, totalWallMs) : ABSENT,
25867
- medianWallMs: optionalMedian(legWalls)
25900
+ medianWallMs: optionalMedian(legWalls),
25901
+ gateCyclesByOfficer
25868
25902
  };
25869
25903
  }
25870
25904
  async function runAnalystCohortMode(ledgerHome, input, ensureIssuePage) {
@@ -26184,24 +26218,211 @@ var init_run_terminal_artifacts = __esm({
26184
26218
  }
26185
26219
  });
26186
26220
 
26187
- // src/analyst-ledger.ts
26188
- import { readdir as readdir5, readFile as readFile14 } from "node:fs/promises";
26221
+ // src/analyst-gate-cycles-read.ts
26222
+ import { readdir as readdir5 } from "node:fs/promises";
26189
26223
  import { join as join23 } from "node:path";
26224
+ function isRecord8(value) {
26225
+ return typeof value === "object" && value !== null && !Array.isArray(value);
26226
+ }
26227
+ function isMissingDirectoryError(error) {
26228
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
26229
+ }
26230
+ function normalizeOfficerArg(raw) {
26231
+ if (typeof raw !== "string") return void 0;
26232
+ return OFFICER_ARG_ALIASES[raw.trim()];
26233
+ }
26234
+ function isGateTerminatingToolName(toolName) {
26235
+ return DISPATCH_TOOLS.has(toolName) || OFFICER_TOOL_TO_FACE[toolName] !== void 0;
26236
+ }
26237
+ function acceptedGateReceiptIds(rows) {
26238
+ const accepted = /* @__PURE__ */ new Set();
26239
+ for (const row of rows) {
26240
+ const message = isRecord8(row.message) ? row.message : void 0;
26241
+ if (message?.role !== "toolResult") continue;
26242
+ if (typeof message.toolCallId !== "string" || message.toolCallId.length === 0) continue;
26243
+ if (message.isError === false) accepted.add(message.toolCallId);
26244
+ }
26245
+ return accepted;
26246
+ }
26247
+ function extractLastAcceptedGateToolCall(rows) {
26248
+ const acceptedIds = acceptedGateReceiptIds(rows);
26249
+ let last;
26250
+ for (const row of rows) {
26251
+ const message = isRecord8(row.message) ? row.message : void 0;
26252
+ if (message?.role !== "assistant" || !Array.isArray(message.content)) continue;
26253
+ for (const part of message.content) {
26254
+ if (!isRecord8(part) || part.type !== "toolCall") continue;
26255
+ if (typeof part.id !== "string" || part.id.length === 0) continue;
26256
+ if (!acceptedIds.has(part.id)) continue;
26257
+ if (typeof part.name !== "string" || part.name.length === 0) continue;
26258
+ if (!isGateTerminatingToolName(part.name)) continue;
26259
+ last = {
26260
+ toolName: part.name,
26261
+ args: isRecord8(part.arguments) ? part.arguments : void 0
26262
+ };
26263
+ }
26264
+ }
26265
+ return last;
26266
+ }
26267
+ function requireAcceptedGateStatus(args, filePath) {
26268
+ if (args === void 0 || typeof args.status !== "string" || args.status.trim() === "") {
26269
+ throw new Error(
26270
+ `accepted gate receipt missing usable status in ${filePath}`
26271
+ );
26272
+ }
26273
+ return args.status.trim();
26274
+ }
26275
+ function requireAcceptedGateSpan(rows, filePath) {
26276
+ const span = extractSessionTimestampSpan(rows);
26277
+ if (span.startedAt === void 0 || span.endedAt === void 0) {
26278
+ throw new Error(
26279
+ `accepted gate volume missing session timestamp span in ${filePath}`
26280
+ );
26281
+ }
26282
+ const startedMs = Date.parse(span.startedAt);
26283
+ const endedMs = Date.parse(span.endedAt);
26284
+ if (!Number.isFinite(startedMs) || !Number.isFinite(endedMs) || endedMs < startedMs) {
26285
+ throw new Error(
26286
+ `accepted gate volume has unusable timestamp span in ${filePath}`
26287
+ );
26288
+ }
26289
+ return {
26290
+ startedAt: span.startedAt,
26291
+ endedAt: span.endedAt,
26292
+ wallMs: endedMs - startedMs
26293
+ };
26294
+ }
26295
+ async function classifyAuditorVolume(filePath) {
26296
+ const rows = await readLedgerSessionJsonl(filePath);
26297
+ const call = extractLastAcceptedGateToolCall(rows);
26298
+ if (call === void 0) return void 0;
26299
+ const span = requireAcceptedGateSpan(rows, filePath);
26300
+ const status = requireAcceptedGateStatus(call.args, filePath);
26301
+ const findings = call.args?.findings;
26302
+ const findingsCount = Array.isArray(findings) ? findings.length : 0;
26303
+ if (DISPATCH_TOOLS.has(call.toolName)) {
26304
+ if (status !== "dispatch") {
26305
+ throw new Error(
26306
+ `accepted dispatch receipt has non-dispatch status ${JSON.stringify(status)} in ${filePath}`
26307
+ );
26308
+ }
26309
+ const officer2 = normalizeOfficerArg(call.args?.officer);
26310
+ if (officer2 === void 0) {
26311
+ throw new Error(
26312
+ `accepted dispatch receipt missing or unknown officer in ${filePath}`
26313
+ );
26314
+ }
26315
+ return {
26316
+ kind: "dispatch",
26317
+ startedAt: span.startedAt,
26318
+ officer: officer2
26319
+ };
26320
+ }
26321
+ const officer = OFFICER_TOOL_TO_FACE[call.toolName];
26322
+ if (officer === void 0) {
26323
+ throw new Error(
26324
+ `accepted gate receipt has unknown officer tool ${call.toolName} in ${filePath}`
26325
+ );
26326
+ }
26327
+ return {
26328
+ kind: "officer",
26329
+ startedAt: span.startedAt,
26330
+ endedAt: span.endedAt,
26331
+ officer,
26332
+ status,
26333
+ findingsCount,
26334
+ officerWallMs: span.wallMs
26335
+ };
26336
+ }
26337
+ function pairGateRounds(volumes) {
26338
+ const ordered = [...volumes].sort((a, b) => {
26339
+ if (a.startedAt !== b.startedAt) return a.startedAt.localeCompare(b.startedAt);
26340
+ if (a.kind !== b.kind) return a.kind === "dispatch" ? -1 : 1;
26341
+ return 0;
26342
+ });
26343
+ const usedOfficerIdx = /* @__PURE__ */ new Set();
26344
+ const rounds = [];
26345
+ for (let i = 0; i < ordered.length; i += 1) {
26346
+ const vol = ordered[i];
26347
+ if (vol.kind !== "dispatch") continue;
26348
+ let match;
26349
+ for (let j = i + 1; j < ordered.length; j += 1) {
26350
+ if (usedOfficerIdx.has(j)) continue;
26351
+ const candidate = ordered[j];
26352
+ if (candidate.kind !== "officer") continue;
26353
+ if (candidate.officer !== vol.officer) continue;
26354
+ match = { index: j, officer: candidate };
26355
+ break;
26356
+ }
26357
+ if (match === void 0) continue;
26358
+ usedOfficerIdx.add(match.index);
26359
+ rounds.push({
26360
+ roundIndex: rounds.length + 1,
26361
+ officer: match.officer.officer,
26362
+ status: match.officer.status,
26363
+ officerWallMs: match.officer.officerWallMs,
26364
+ officerStartedAt: match.officer.startedAt,
26365
+ officerEndedAt: match.officer.endedAt,
26366
+ findingsCount: match.officer.findingsCount
26367
+ });
26368
+ }
26369
+ return rounds;
26370
+ }
26371
+ async function readAnalystGateCyclesFromAuditorRoles(auditorRolesDirectory) {
26372
+ let names;
26373
+ try {
26374
+ const entries = await readdir5(auditorRolesDirectory, { withFileTypes: true });
26375
+ names = entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => e.name).sort();
26376
+ } catch (error) {
26377
+ if (isMissingDirectoryError(error)) return [];
26378
+ throw error;
26379
+ }
26380
+ const volumes = [];
26381
+ for (const name of names) {
26382
+ const classified = await classifyAuditorVolume(join23(auditorRolesDirectory, name));
26383
+ if (classified !== void 0) volumes.push(classified);
26384
+ }
26385
+ return pairGateRounds(volumes);
26386
+ }
26387
+ var DISPATCH_TOOLS, OFFICER_TOOL_TO_FACE, OFFICER_ARG_ALIASES;
26388
+ var init_analyst_gate_cycles_read = __esm({
26389
+ "src/analyst-gate-cycles-read.ts"() {
26390
+ "use strict";
26391
+ init_ledger_session_read();
26392
+ DISPATCH_TOOLS = /* @__PURE__ */ new Set(["ak_menxia_output", "ak_gatekeeper_output"]);
26393
+ OFFICER_TOOL_TO_FACE = {
26394
+ ak_jishizhong_output: "inspector",
26395
+ ak_inspector_output: "inspector",
26396
+ ak_fubaolang_output: "notary",
26397
+ ak_notary_output: "notary"
26398
+ };
26399
+ OFFICER_ARG_ALIASES = {
26400
+ jishizhong: "inspector",
26401
+ inspector: "inspector",
26402
+ fubaolang: "notary",
26403
+ notary: "notary"
26404
+ };
26405
+ }
26406
+ });
26407
+
26408
+ // src/analyst-ledger.ts
26409
+ import { readdir as readdir6, readFile as readFile14 } from "node:fs/promises";
26410
+ import { join as join24 } from "node:path";
26190
26411
  function isMissingPathError5(error) {
26191
26412
  return error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
26192
26413
  }
26193
26414
  function errorText3(error) {
26194
26415
  return error instanceof Error ? error.message : String(error);
26195
26416
  }
26196
- function isRecord8(value) {
26417
+ function isRecord9(value) {
26197
26418
  return typeof value === "object" && value !== null && !Array.isArray(value);
26198
26419
  }
26199
26420
  async function readExistingRunLifecycleState(runDirectory) {
26200
26421
  try {
26201
26422
  const raw = JSON.parse(
26202
- await readFile14(join23(runDirectory, "run-state.json"), "utf8")
26423
+ await readFile14(join24(runDirectory, "run-state.json"), "utf8")
26203
26424
  );
26204
- if (!isRecord8(raw) || typeof raw.state !== "string") return void 0;
26425
+ if (!isRecord9(raw) || typeof raw.state !== "string") return void 0;
26205
26426
  return raw.state;
26206
26427
  } catch {
26207
26428
  return void 0;
@@ -26221,7 +26442,7 @@ function tryResolveBookKeyFromProjectRoot(projectRoot) {
26221
26442
  }
26222
26443
  async function listLedgerBookNames(booksRoot) {
26223
26444
  try {
26224
- const entries = await readdir5(booksRoot, { withFileTypes: true });
26445
+ const entries = await readdir6(booksRoot, { withFileTypes: true });
26225
26446
  return entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
26226
26447
  } catch (error) {
26227
26448
  if (isMissingPathError5(error)) return [];
@@ -26231,13 +26452,13 @@ async function listLedgerBookNames(booksRoot) {
26231
26452
  async function readInvocationScopeFields(runDirectory) {
26232
26453
  let raw;
26233
26454
  try {
26234
- raw = await readFile14(join23(runDirectory, "invocation.json"), "utf8");
26455
+ raw = await readFile14(join24(runDirectory, "invocation.json"), "utf8");
26235
26456
  } catch (error) {
26236
26457
  if (isMissingPathError5(error)) return void 0;
26237
26458
  throw error;
26238
26459
  }
26239
26460
  const parsed = JSON.parse(raw);
26240
- if (!isRecord8(parsed)) return void 0;
26461
+ if (!isRecord9(parsed)) return void 0;
26241
26462
  if (typeof parsed.projectRoot !== "string" || parsed.projectRoot.trim() === "") {
26242
26463
  return void 0;
26243
26464
  }
@@ -26263,15 +26484,15 @@ function decideIssueScope(input) {
26263
26484
  }
26264
26485
  async function resolveSessionFile(runDirectory) {
26265
26486
  try {
26266
- const raw = await readFile14(join23(runDirectory, "invocation.json"), "utf8");
26487
+ const raw = await readFile14(join24(runDirectory, "invocation.json"), "utf8");
26267
26488
  const parsed = JSON.parse(raw);
26268
- if (isRecord8(parsed) && typeof parsed.sessionFile === "string" && parsed.sessionFile.trim() !== "") {
26489
+ if (isRecord9(parsed) && typeof parsed.sessionFile === "string" && parsed.sessionFile.trim() !== "") {
26269
26490
  return parsed.sessionFile;
26270
26491
  }
26271
26492
  } catch (error) {
26272
26493
  if (!isMissingPathError5(error)) throw error;
26273
26494
  }
26274
- return join23(runDirectory, "session", "session.jsonl");
26495
+ return join24(runDirectory, "session", "session.jsonl");
26275
26496
  }
26276
26497
  async function classifyScopedRun(input) {
26277
26498
  const missingSources = [];
@@ -26379,6 +26600,24 @@ async function classifyScopedRun(input) {
26379
26600
  `classifyScopedRun internal invariant: missing retained facts for ${input.runId}`
26380
26601
  );
26381
26602
  }
26603
+ let gateCycles;
26604
+ try {
26605
+ gateCycles = await readAnalystGateCyclesFromAuditorRoles(
26606
+ join24(input.runDirectory, "session", "auditor-roles")
26607
+ );
26608
+ } catch (error) {
26609
+ return {
26610
+ kind: "unreadable",
26611
+ entry: {
26612
+ runId: input.runId,
26613
+ book: input.book,
26614
+ missingSources: ["auditor-roles"],
26615
+ reason: errorText3(error),
26616
+ firstFrameAt: { status: "present", at: frameSpan.startedAt },
26617
+ lastFrameAt: { status: "present", at: frameSpan.endedAt }
26618
+ }
26619
+ };
26620
+ }
26382
26621
  return {
26383
26622
  kind: "readable",
26384
26623
  facts: {
@@ -26388,14 +26627,15 @@ async function classifyScopedRun(input) {
26388
26627
  frameSpan,
26389
26628
  toolIntervals,
26390
26629
  terminal,
26391
- models
26630
+ models,
26631
+ gateCycles
26392
26632
  }
26393
26633
  };
26394
26634
  }
26395
26635
  async function scanAnalystIssueRuns(input) {
26396
26636
  const ledgerHome = resolveActivationLedgerHome();
26397
26637
  const scopeTicketNumber = input.ticketNumber;
26398
- const booksRoot = join23(ledgerHome, "books");
26638
+ const booksRoot = join24(ledgerHome, "books");
26399
26639
  let wholeBook = false;
26400
26640
  let scopeRootIdentity;
26401
26641
  let bookNames;
@@ -26427,10 +26667,10 @@ async function scanAnalystIssueRuns(input) {
26427
26667
  const unreadable = [];
26428
26668
  const scopeConflicts = [];
26429
26669
  for (const book of bookNames) {
26430
- const runsDir = join23(booksRoot, book, "runs");
26670
+ const runsDir = join24(booksRoot, book, "runs");
26431
26671
  let runNames;
26432
26672
  try {
26433
- const entries = await readdir5(runsDir, { withFileTypes: true });
26673
+ const entries = await readdir6(runsDir, { withFileTypes: true });
26434
26674
  runNames = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
26435
26675
  } catch (error) {
26436
26676
  if (isMissingPathError5(error)) continue;
@@ -26439,7 +26679,7 @@ async function scanAnalystIssueRuns(input) {
26439
26679
  for (const runName of runNames) {
26440
26680
  const parsed = parseRunDirectoryName(runName);
26441
26681
  if (parsed === void 0) continue;
26442
- const runDirectory = join23(runsDir, runName);
26682
+ const runDirectory = join24(runsDir, runName);
26443
26683
  let scopeFields;
26444
26684
  try {
26445
26685
  scopeFields = await readInvocationScopeFields(runDirectory);
@@ -26481,12 +26721,13 @@ var init_analyst_ledger = __esm({
26481
26721
  init_activation_ledger_topology();
26482
26722
  init_ledger_session_read();
26483
26723
  init_run_terminal_artifacts();
26724
+ init_analyst_gate_cycles_read();
26484
26725
  LIVE_RUN_STATES = /* @__PURE__ */ new Set(["admitted", "running", "resumable"]);
26485
26726
  }
26486
26727
  });
26487
26728
 
26488
26729
  // src/analyst-metric-families/acceptance-success-rework.ts
26489
- function isRecord9(value) {
26730
+ function isRecord10(value) {
26490
26731
  return typeof value === "object" && value !== null && !Array.isArray(value);
26491
26732
  }
26492
26733
  function wallMsFromSpan(span) {
@@ -26495,21 +26736,21 @@ function wallMsFromSpan(span) {
26495
26736
  function findCollectorGroups(body) {
26496
26737
  if (Array.isArray(body.groups)) return body.groups;
26497
26738
  const receipt = body.receipt;
26498
- if (isRecord9(receipt) && Array.isArray(receipt.groups)) return receipt.groups;
26739
+ if (isRecord10(receipt) && Array.isArray(receipt.groups)) return receipt.groups;
26499
26740
  const outcome = body.outcome;
26500
- if (isRecord9(outcome)) {
26741
+ if (isRecord10(outcome)) {
26501
26742
  const facts = outcome.decisiveFacts;
26502
- if (isRecord9(facts) && Array.isArray(facts.groups)) return facts.groups;
26743
+ if (isRecord10(facts) && Array.isArray(facts.groups)) return facts.groups;
26503
26744
  }
26504
26745
  return void 0;
26505
26746
  }
26506
26747
  function extractStatus(body) {
26507
26748
  const outcome = body.outcome;
26508
- if (isRecord9(outcome) && typeof outcome.status === "string" && outcome.status.trim() !== "") {
26749
+ if (isRecord10(outcome) && typeof outcome.status === "string" && outcome.status.trim() !== "") {
26509
26750
  return outcome.status;
26510
26751
  }
26511
26752
  const receipt = body.receipt;
26512
- if (isRecord9(receipt) && typeof receipt.status === "string" && receipt.status.trim() !== "") {
26753
+ if (isRecord10(receipt) && typeof receipt.status === "string" && receipt.status.trim() !== "") {
26513
26754
  return receipt.status;
26514
26755
  }
26515
26756
  if (typeof body.status === "string" && body.status.trim() !== "") {
@@ -27007,6 +27248,83 @@ var init_b2_frame_buckets_actions = __esm({
27007
27248
  }
27008
27249
  });
27009
27250
 
27251
+ // src/analyst-metric-families/gate-cycles.ts
27252
+ function projectRound(round) {
27253
+ return {
27254
+ roundIndex: round.roundIndex,
27255
+ officer: round.officer,
27256
+ status: round.status,
27257
+ officerWallMs: round.officerWallMs,
27258
+ findingsCount: round.findingsCount
27259
+ };
27260
+ }
27261
+ function projectLeg(facts) {
27262
+ const rounds = facts.gateCycles.map(projectRound);
27263
+ return {
27264
+ runId: facts.runId,
27265
+ book: facts.book,
27266
+ role: facts.role,
27267
+ roundCount: rounds.length,
27268
+ rounds
27269
+ };
27270
+ }
27271
+ function compareLegs(a, b) {
27272
+ if (a.book !== b.book) return a.book.localeCompare(b.book);
27273
+ if (a.role !== b.role) return a.role.localeCompare(b.role);
27274
+ return a.runId.localeCompare(b.runId);
27275
+ }
27276
+ function emptyAccum() {
27277
+ return { rounds: 0, bounceCount: 0, passCount: 0, wallSum: 0 };
27278
+ }
27279
+ function absorbRound(accum, round) {
27280
+ accum.rounds += 1;
27281
+ accum.wallSum += round.officerWallMs;
27282
+ if (round.status === "bounce") accum.bounceCount += 1;
27283
+ if (round.status === "pass") accum.passCount += 1;
27284
+ }
27285
+ function finishOfficer(officer, accum) {
27286
+ return {
27287
+ officer,
27288
+ rounds: accum.rounds,
27289
+ bounceCount: accum.bounceCount,
27290
+ passCount: accum.passCount,
27291
+ bounceRate: accum.rounds === 0 ? void 0 : accum.bounceCount / accum.rounds,
27292
+ meanOfficerWallMs: accum.rounds === 0 ? void 0 : accum.wallSum / accum.rounds
27293
+ };
27294
+ }
27295
+ function summarizeByOfficer(legs) {
27296
+ const byOfficer = /* @__PURE__ */ new Map();
27297
+ for (const leg of legs) {
27298
+ for (const round of leg.rounds) {
27299
+ const accum = byOfficer.get(round.officer) ?? emptyAccum();
27300
+ absorbRound(accum, round);
27301
+ byOfficer.set(round.officer, accum);
27302
+ }
27303
+ }
27304
+ const officers = ["inspector", "notary"].filter((o) => byOfficer.has(o));
27305
+ return officers.map((officer) => finishOfficer(officer, byOfficer.get(officer)));
27306
+ }
27307
+ var gateCyclesFamily, gate_cycles_default;
27308
+ var init_gate_cycles = __esm({
27309
+ "src/analyst-metric-families/gate-cycles.ts"() {
27310
+ "use strict";
27311
+ gateCyclesFamily = {
27312
+ id: "gate-cycles",
27313
+ contribute(input) {
27314
+ if (input.runs.length === 0) return void 0;
27315
+ const legs = input.runs.map(projectLeg).sort(compareLegs);
27316
+ const section = {
27317
+ kind: "analyst-gate-cycles",
27318
+ legs,
27319
+ byOfficer: summarizeByOfficer(legs)
27320
+ };
27321
+ return { gateCycles: section };
27322
+ }
27323
+ };
27324
+ gate_cycles_default = gateCyclesFamily;
27325
+ }
27326
+ });
27327
+
27010
27328
  // src/analyst-metric-families/leg-wall-clock.ts
27011
27329
  function frameSpanWallMs(span) {
27012
27330
  return Date.parse(span.endedAt) - Date.parse(span.startedAt);
@@ -27058,21 +27376,21 @@ var init_leg_wall_clock = __esm({
27058
27376
  });
27059
27377
 
27060
27378
  // src/analyst-metric-families/round-timeline.ts
27061
- function isRecord10(value) {
27379
+ function isRecord11(value) {
27062
27380
  return typeof value === "object" && value !== null && !Array.isArray(value);
27063
27381
  }
27064
27382
  function wallMsFromSpan2(startedAt, endedAt) {
27065
27383
  return Date.parse(endedAt) - Date.parse(startedAt);
27066
27384
  }
27067
27385
  function readOutcomeStatus(body) {
27068
- if (!isRecord10(body.outcome)) return void 0;
27386
+ if (!isRecord11(body.outcome)) return void 0;
27069
27387
  const status = body.outcome.status;
27070
27388
  if (typeof status !== "string" || status.trim() === "") return void 0;
27071
27389
  return status;
27072
27390
  }
27073
27391
  function readClassCount(body) {
27074
- if (!isRecord10(body.outcome)) return void 0;
27075
- if (!isRecord10(body.outcome.decisiveFacts)) return void 0;
27392
+ if (!isRecord11(body.outcome)) return void 0;
27393
+ if (!isRecord11(body.outcome.decisiveFacts)) return void 0;
27076
27394
  const classCount = body.outcome.decisiveFacts.classCount;
27077
27395
  if (typeof classCount !== "number" || !Number.isFinite(classCount)) {
27078
27396
  return void 0;
@@ -27185,11 +27503,13 @@ var init_analyst_metric_families = __esm({
27185
27503
  "use strict";
27186
27504
  init_acceptance_success_rework();
27187
27505
  init_b2_frame_buckets_actions();
27506
+ init_gate_cycles();
27188
27507
  init_leg_wall_clock();
27189
27508
  init_round_timeline();
27190
27509
  ISSUE_METRIC_FAMILIES = [
27191
27510
  acceptance_success_rework_default,
27192
27511
  b2_frame_buckets_actions_default,
27512
+ gate_cycles_default,
27193
27513
  leg_wall_clock_default,
27194
27514
  round_timeline_default
27195
27515
  ].sort((a, b) => a.id.localeCompare(b.id));
@@ -27214,7 +27534,7 @@ var init_analyst_metric_family = __esm({
27214
27534
 
27215
27535
  // src/analyst-page.ts
27216
27536
  import { createHash as createHash4 } from "node:crypto";
27217
- import { dirname as dirname10, join as join24 } from "node:path";
27537
+ import { dirname as dirname10, join as join25 } from "node:path";
27218
27538
  function analystIssuePageKey(address) {
27219
27539
  const parts = ["book", address.bookKey];
27220
27540
  if (address.issueNumber !== void 0) {
@@ -27225,7 +27545,7 @@ function analystIssuePageKey(address) {
27225
27545
  return createHash4("sha256").update(parts.join("\0")).digest("hex").slice(0, 32);
27226
27546
  }
27227
27547
  function analystIssuePagePath(ledgerHome, address) {
27228
- return join24(ledgerHome, "analyst", "issues", `${analystIssuePageKey(address)}.json`);
27548
+ return join25(ledgerHome, "analyst", "issues", `${analystIssuePageKey(address)}.json`);
27229
27549
  }
27230
27550
  function analystIssuePageAddressFromPage(page) {
27231
27551
  return {
@@ -27756,7 +28076,7 @@ __export(cli_exports, {
27756
28076
  });
27757
28077
  import { realpath as realpath5 } from "node:fs/promises";
27758
28078
  import { homedir as homedir3 } from "node:os";
27759
- import { join as join25 } from "node:path";
28079
+ import { join as join26 } from "node:path";
27760
28080
  function takePublicGlobalFlag(argv, index, options) {
27761
28081
  const tokens = argv.slice(index);
27762
28082
  const taken = options.takeDashed(tokens);
@@ -27802,7 +28122,7 @@ function resolveHome(env) {
27802
28122
  return env.home ?? process.env.HOME ?? homedir3();
27803
28123
  }
27804
28124
  function resolveAgentDir(env, home) {
27805
- return env.agentDir ?? process.env.PI_CODING_AGENT_DIR ?? join25(home, ".pi", "agent");
28125
+ return env.agentDir ?? process.env.PI_CODING_AGENT_DIR ?? join26(home, ".pi", "agent");
27806
28126
  }
27807
28127
  function parseThinking(value) {
27808
28128
  if (!THINKING_LEVELS2.has(value)) {
@@ -28672,7 +28992,7 @@ var init_cli = __esm({
28672
28992
 
28673
28993
  // src/public-cli/main.ts
28674
28994
  import { existsSync as existsSync3 } from "node:fs";
28675
- import { dirname as dirname11, join as join26 } from "node:path";
28995
+ import { dirname as dirname11, join as join27 } from "node:path";
28676
28996
  import { fileURLToPath } from "node:url";
28677
28997
 
28678
28998
  // src/public-cli/host-pi-runtime.ts
@@ -28761,8 +29081,8 @@ function linkPackage(packageRoot2, name, targetDir) {
28761
29081
  // src/public-cli/main.ts
28762
29082
  var here = dirname11(fileURLToPath(import.meta.url));
28763
29083
  function resolvePackageRoot(binDir) {
28764
- const canonical = join26(binDir, "..", "..");
28765
- if (existsSync3(join26(canonical, "package.json"))) {
29084
+ const canonical = join27(binDir, "..", "..");
29085
+ if (existsSync3(join27(canonical, "package.json"))) {
28766
29086
  return canonical;
28767
29087
  }
28768
29088
  return binDir;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akagilnc/pi-workflow-roles",
3
- "version": "0.1.2276",
3
+ "version": "0.1.2296",
4
4
  "description": "Soul-bound workflow roles for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -24,6 +24,7 @@ import type { AnalystIssueMetricsPage } from "./analyst-page.ts";
24
24
  import type { AnalystRoleAcceptanceStats } from "./analyst-metric-families/acceptance-success-rework.ts";
25
25
  import type { AnalystLegWallClockSection } from "./analyst-metric-families/leg-wall-clock.ts";
26
26
  import type { AnalystAcceptanceSuccessReworkSection } from "./analyst-metric-families/acceptance-success-rework.ts";
27
+ import type { AnalystGateCyclesSection } from "./analyst-metric-families/gate-cycles.ts";
27
28
 
28
29
  /**
29
30
  * #338 page ensurer — read existing page or compute via sole issue kernel.
@@ -83,6 +84,19 @@ export type AnalystCohortRoleStats = {
83
84
  readonly successRate: AnalystCohortOptionalMetric;
84
85
  };
85
86
 
87
+ /** Per-officer gate-cycle contrast stats within one cohort group (#446). */
88
+ export type AnalystCohortGateOfficerStats = {
89
+ readonly officer: "inspector" | "notary";
90
+ /** Merged paired-round count across present issues. */
91
+ readonly rounds: number;
92
+ readonly bounceCount: number;
93
+ readonly passCount: number;
94
+ /** bounceCount / rounds; absent when rounds === 0. */
95
+ readonly bounceRate: AnalystCohortOptionalMetric;
96
+ /** Mean officer subsession wall across merged rounds; absent when rounds === 0. */
97
+ readonly meanOfficerWallMs: AnalystCohortOptionalMetric;
98
+ };
99
+
86
100
  export type AnalystCohortGroupResult = {
87
101
  readonly groupLabel: string;
88
102
  /** One entry per input issue number, in input order (vacancy single-listed). */
@@ -90,6 +104,11 @@ export type AnalystCohortGroupResult = {
90
104
  readonly byRole: readonly AnalystCohortRoleStats[];
91
105
  readonly reworkRatio: AnalystCohortOptionalMetric;
92
106
  readonly medianWallMs: AnalystCohortOptionalMetric;
107
+ /**
108
+ * Gate-cycle by-officer fold from ensured issue pages (#446).
109
+ * Empty when no present page contributed a gateCycles section with rounds.
110
+ */
111
+ readonly gateCyclesByOfficer: readonly AnalystCohortGateOfficerStats[];
93
112
  };
94
113
 
95
114
  export type AnalystCohortModeResult = {
@@ -97,10 +116,11 @@ export type AnalystCohortModeResult = {
97
116
  readonly groups: readonly [AnalystCohortGroupResult, AnalystCohortGroupResult];
98
117
  };
99
118
 
100
- /** Issue page shape cohort actually reads (envelope + B-family sections). */
119
+ /** Issue page shape cohort actually reads (envelope + metric-family sections). */
101
120
  type AnalystCohortSourcePage = AnalystIssueMetricsPage & {
102
121
  readonly acceptanceSuccessRework?: AnalystAcceptanceSuccessReworkSection;
103
122
  readonly legWallClock?: AnalystLegWallClockSection;
123
+ readonly gateCycles?: AnalystGateCyclesSection;
104
124
  };
105
125
 
106
126
  const ABSENT: AnalystCohortOptionalMetric = { status: "absent" };
@@ -155,6 +175,55 @@ function finishRole(role: string, accum: RoleAccum): AnalystCohortRoleStats {
155
175
  };
156
176
  }
157
177
 
178
+ /**
179
+ * Cross-page fold of page-level gateCycles.byOfficer numerators (#446).
180
+ * status→bounce/pass classification stays sole in gate-cycles family;
181
+ * cohort only merges already-projected counts (same ratio-merge nail as rework).
182
+ */
183
+ type GateOfficerNumeratorAccum = {
184
+ rounds: number;
185
+ bounceCount: number;
186
+ passCount: number;
187
+ /** Σ (meanOfficerWallMs × rounds) recovered from page summaries. */
188
+ wallSum: number;
189
+ };
190
+
191
+ function emptyGateOfficerNumeratorAccum(): GateOfficerNumeratorAccum {
192
+ return { rounds: 0, bounceCount: 0, passCount: 0, wallSum: 0 };
193
+ }
194
+
195
+ function absorbGateOfficerSummary(
196
+ accum: GateOfficerNumeratorAccum,
197
+ summary: {
198
+ readonly rounds: number;
199
+ readonly bounceCount: number;
200
+ readonly passCount: number;
201
+ readonly meanOfficerWallMs: number | undefined;
202
+ },
203
+ ): void {
204
+ accum.rounds += summary.rounds;
205
+ accum.bounceCount += summary.bounceCount;
206
+ accum.passCount += summary.passCount;
207
+ if (summary.meanOfficerWallMs !== undefined) {
208
+ accum.wallSum += summary.meanOfficerWallMs * summary.rounds;
209
+ }
210
+ }
211
+
212
+ function finishGateOfficerNumerators(
213
+ officer: "inspector" | "notary",
214
+ accum: GateOfficerNumeratorAccum,
215
+ ): AnalystCohortGateOfficerStats {
216
+ return {
217
+ officer,
218
+ rounds: accum.rounds,
219
+ bounceCount: accum.bounceCount,
220
+ passCount: accum.passCount,
221
+ bounceRate: rateMetric(accum.bounceCount, accum.rounds),
222
+ meanOfficerWallMs:
223
+ accum.rounds === 0 ? ABSENT : presentMetric(accum.wallSum / accum.rounds),
224
+ };
225
+ }
226
+
158
227
  async function aggregateGroup(
159
228
  index: AnalystLibraryIndexPage | undefined,
160
229
  input: AnalystCohortGroupInput,
@@ -162,6 +231,10 @@ async function aggregateGroup(
162
231
  ): Promise<AnalystCohortGroupResult> {
163
232
  const issueEntries: AnalystCohortIssueEntry[] = [];
164
233
  const roleAccums = new Map<string, RoleAccum>();
234
+ const gateOfficerAccums = new Map<
235
+ "inspector" | "notary",
236
+ GateOfficerNumeratorAccum
237
+ >();
165
238
  let reworkWallMs = 0;
166
239
  let totalWallMs = 0;
167
240
  let hasReworkSample = false;
@@ -212,18 +285,37 @@ async function aggregateGroup(
212
285
  legWalls.push(leg.wallMs);
213
286
  }
214
287
  }
288
+
289
+ // Gate-cycle fold: merge page-projected byOfficer numerators (no rescan,
290
+ // no second status→bounce/pass classifier — sole owner is gate-cycles family).
291
+ const gateCycles = page.gateCycles;
292
+ if (gateCycles !== undefined) {
293
+ for (const summary of gateCycles.byOfficer) {
294
+ const accum =
295
+ gateOfficerAccums.get(summary.officer) ?? emptyGateOfficerNumeratorAccum();
296
+ absorbGateOfficerSummary(accum, summary);
297
+ gateOfficerAccums.set(summary.officer, accum);
298
+ }
299
+ }
215
300
  }
216
301
 
217
302
  const byRole = [...roleAccums.keys()]
218
303
  .sort((a, b) => a.localeCompare(b))
219
304
  .map((role) => finishRole(role, roleAccums.get(role)!));
220
305
 
306
+ const gateCyclesByOfficer = (["inspector", "notary"] as const)
307
+ .filter((officer) => gateOfficerAccums.has(officer))
308
+ .map((officer) =>
309
+ finishGateOfficerNumerators(officer, gateOfficerAccums.get(officer)!),
310
+ );
311
+
221
312
  return {
222
313
  groupLabel: input.groupLabel,
223
314
  issues: issueEntries,
224
315
  byRole,
225
316
  reworkRatio: hasReworkSample ? rateMetric(reworkWallMs, totalWallMs) : ABSENT,
226
317
  medianWallMs: optionalMedian(legWalls),
318
+ gateCyclesByOfficer,
227
319
  };
228
320
  }
229
321
 
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Sole nested-volume reader for gate-cycle facts under session/auditor-roles/.
3
+ *
4
+ * Called only from the Analyst sole ledger scan (classifyScopedRun). Metric
5
+ * families must not open a second disk scan — they consume retained facts.
6
+ *
7
+ * Naming: records may carry pre-#440 menxia/jishizhong/fubaolang tool faces or
8
+ * the current gatekeeper/inspector/notary English face. Projection always uses
9
+ * the current English officer identity (inspector | notary).
10
+ *
11
+ * Missing auditor-roles directory (ENOENT only) → empty rounds (lawful zero).
12
+ * Path present but not a directory (ENOTDIR) and discovered nested JSONL that
13
+ * fails canonical read/parse must fail loudly (never silently under-count).
14
+ * An accepted gate terminating receipt (isError:false pair on dispatch/officer
15
+ * tool) whose required typed facts are unusable — status, dispatch officer, or
16
+ * first/last span missing/unknown/unparseable/inverted — also fails loudly via
17
+ * the same throw→ledger `auditor-roles` unreadable seam. True non-gate volumes
18
+ * (soul-audit noise, etc.) stay omitted from pairing.
19
+ */
20
+ import { readdir } from "node:fs/promises";
21
+ import { join } from "node:path";
22
+
23
+ import {
24
+ extractSessionTimestampSpan,
25
+ readLedgerSessionJsonl,
26
+ type LedgerSessionRow,
27
+ } from "./ledger-session-read.ts";
28
+
29
+ /** One completed gate round: province dispatch paired with its officer volume. */
30
+ export type AnalystGateCycleRound = {
31
+ /** 1-based chronological order among paired rounds on this leg. */
32
+ readonly roundIndex: number;
33
+ /** Current English officer face after historical alias fold. */
34
+ readonly officer: "inspector" | "notary";
35
+ /** Typed officer terminal status (pass / bounce / incomplete / …). */
36
+ readonly status: string;
37
+ /** Officer subsession first→last usable timestamp delta (ms). */
38
+ readonly officerWallMs: number;
39
+ readonly officerStartedAt: string;
40
+ readonly officerEndedAt: string;
41
+ /** findings[] length only — prose never retained. */
42
+ readonly findingsCount: number;
43
+ };
44
+
45
+ const DISPATCH_TOOLS = new Set(["ak_menxia_output", "ak_gatekeeper_output"]);
46
+
47
+ /** Officer terminating tool → current English officer identity. */
48
+ const OFFICER_TOOL_TO_FACE: Readonly<Record<string, "inspector" | "notary">> = {
49
+ ak_jishizhong_output: "inspector",
50
+ ak_inspector_output: "inspector",
51
+ ak_fubaolang_output: "notary",
52
+ ak_notary_output: "notary",
53
+ };
54
+
55
+ /** Dispatch `officer` argument aliases → current English face. */
56
+ const OFFICER_ARG_ALIASES: Readonly<Record<string, "inspector" | "notary">> = {
57
+ jishizhong: "inspector",
58
+ inspector: "inspector",
59
+ fubaolang: "notary",
60
+ notary: "notary",
61
+ };
62
+
63
+ function isRecord(value: unknown): value is Record<string, unknown> {
64
+ return typeof value === "object" && value !== null && !Array.isArray(value);
65
+ }
66
+
67
+ /** Only true absence (ENOENT). ENOTDIR is damaged topology — must stay loud. */
68
+ function isMissingDirectoryError(error: unknown): boolean {
69
+ return (
70
+ error instanceof Error
71
+ && "code" in error
72
+ && error.code === "ENOENT"
73
+ );
74
+ }
75
+
76
+ function normalizeOfficerArg(raw: unknown): "inspector" | "notary" | undefined {
77
+ if (typeof raw !== "string") return undefined;
78
+ return OFFICER_ARG_ALIASES[raw.trim()];
79
+ }
80
+
81
+ type AcceptedGateToolCall = {
82
+ readonly toolName: string;
83
+ readonly args: Record<string, unknown> | undefined;
84
+ };
85
+
86
+ function isGateTerminatingToolName(toolName: string): boolean {
87
+ return DISPATCH_TOOLS.has(toolName) || OFFICER_TOOL_TO_FACE[toolName] !== undefined;
88
+ }
89
+
90
+ /**
91
+ * toolCallIds whose paired toolResult is an accepted receipt (`isError === false`).
92
+ * Receipt is the sole lawful role product — rejected / missing results never qualify.
93
+ */
94
+ function acceptedGateReceiptIds(
95
+ rows: readonly LedgerSessionRow[],
96
+ ): ReadonlySet<string> {
97
+ const accepted = new Set<string>();
98
+ for (const row of rows) {
99
+ const message = isRecord(row.message) ? row.message : undefined;
100
+ if (message?.role !== "toolResult") continue;
101
+ if (typeof message.toolCallId !== "string" || message.toolCallId.length === 0) continue;
102
+ if (message.isError === false) accepted.add(message.toolCallId);
103
+ }
104
+ return accepted;
105
+ }
106
+
107
+ /**
108
+ * Last accepted gate terminating toolCall on a nested volume (dispatch or officer).
109
+ * Identity only — typed args are validated after recognition so unusable facts
110
+ * fail loud instead of being skipped as "not a gate volume". Soul-audit and
111
+ * other non-gate tools never qualify.
112
+ */
113
+ function extractLastAcceptedGateToolCall(
114
+ rows: readonly LedgerSessionRow[],
115
+ ): AcceptedGateToolCall | undefined {
116
+ const acceptedIds = acceptedGateReceiptIds(rows);
117
+ let last: AcceptedGateToolCall | undefined;
118
+ for (const row of rows) {
119
+ const message = isRecord(row.message) ? row.message : undefined;
120
+ if (message?.role !== "assistant" || !Array.isArray(message.content)) continue;
121
+ for (const part of message.content) {
122
+ if (!isRecord(part) || part.type !== "toolCall") continue;
123
+ // Unpaired / rejected calls have no lawful receipt — skip before reading args.
124
+ if (typeof part.id !== "string" || part.id.length === 0) continue;
125
+ if (!acceptedIds.has(part.id)) continue;
126
+ if (typeof part.name !== "string" || part.name.length === 0) continue;
127
+ if (!isGateTerminatingToolName(part.name)) continue;
128
+ last = {
129
+ toolName: part.name,
130
+ args: isRecord(part.arguments) ? part.arguments : undefined,
131
+ };
132
+ }
133
+ }
134
+ return last;
135
+ }
136
+
137
+ function requireAcceptedGateStatus(
138
+ args: Record<string, unknown> | undefined,
139
+ filePath: string,
140
+ ): string {
141
+ if (args === undefined || typeof args.status !== "string" || args.status.trim() === "") {
142
+ throw new Error(
143
+ `accepted gate receipt missing usable status in ${filePath}`,
144
+ );
145
+ }
146
+ return args.status.trim();
147
+ }
148
+
149
+ function requireAcceptedGateSpan(
150
+ rows: readonly LedgerSessionRow[],
151
+ filePath: string,
152
+ ): { readonly startedAt: string; readonly endedAt: string; readonly wallMs: number } {
153
+ const span = extractSessionTimestampSpan(rows);
154
+ if (span.startedAt === undefined || span.endedAt === undefined) {
155
+ throw new Error(
156
+ `accepted gate volume missing session timestamp span in ${filePath}`,
157
+ );
158
+ }
159
+ const startedMs = Date.parse(span.startedAt);
160
+ const endedMs = Date.parse(span.endedAt);
161
+ if (!Number.isFinite(startedMs) || !Number.isFinite(endedMs) || endedMs < startedMs) {
162
+ throw new Error(
163
+ `accepted gate volume has unusable timestamp span in ${filePath}`,
164
+ );
165
+ }
166
+ return {
167
+ startedAt: span.startedAt,
168
+ endedAt: span.endedAt,
169
+ wallMs: endedMs - startedMs,
170
+ };
171
+ }
172
+
173
+ type ClassifiedVolume =
174
+ | {
175
+ readonly kind: "dispatch";
176
+ readonly startedAt: string;
177
+ readonly officer: "inspector" | "notary";
178
+ }
179
+ | {
180
+ readonly kind: "officer";
181
+ readonly startedAt: string;
182
+ readonly endedAt: string;
183
+ readonly officer: "inspector" | "notary";
184
+ readonly status: string;
185
+ readonly findingsCount: number;
186
+ readonly officerWallMs: number;
187
+ };
188
+
189
+ async function classifyAuditorVolume(
190
+ filePath: string,
191
+ ): Promise<ClassifiedVolume | undefined> {
192
+ // Canonical JSONL errors propagate — failure honesty (never wash to fewer rounds).
193
+ const rows = await readLedgerSessionJsonl(filePath);
194
+ // Recognize accepted gate tool first. Non-gate volumes stay omitted; once a
195
+ // gate receipt is present, required typed facts must not silently under-count.
196
+ const call = extractLastAcceptedGateToolCall(rows);
197
+ if (call === undefined) return undefined;
198
+
199
+ const span = requireAcceptedGateSpan(rows, filePath);
200
+ const status = requireAcceptedGateStatus(call.args, filePath);
201
+ const findings = call.args?.findings;
202
+ const findingsCount = Array.isArray(findings) ? findings.length : 0;
203
+
204
+ if (DISPATCH_TOOLS.has(call.toolName)) {
205
+ if (status !== "dispatch") {
206
+ throw new Error(
207
+ `accepted dispatch receipt has non-dispatch status ${JSON.stringify(status)} in ${filePath}`,
208
+ );
209
+ }
210
+ const officer = normalizeOfficerArg(call.args?.officer);
211
+ if (officer === undefined) {
212
+ throw new Error(
213
+ `accepted dispatch receipt missing or unknown officer in ${filePath}`,
214
+ );
215
+ }
216
+ return {
217
+ kind: "dispatch",
218
+ startedAt: span.startedAt,
219
+ officer,
220
+ };
221
+ }
222
+
223
+ const officer = OFFICER_TOOL_TO_FACE[call.toolName];
224
+ if (officer === undefined) {
225
+ // isGateTerminatingToolName already screened; keep loud if tables drift.
226
+ throw new Error(
227
+ `accepted gate receipt has unknown officer tool ${call.toolName} in ${filePath}`,
228
+ );
229
+ }
230
+ return {
231
+ kind: "officer",
232
+ startedAt: span.startedAt,
233
+ endedAt: span.endedAt,
234
+ officer,
235
+ status,
236
+ findingsCount,
237
+ officerWallMs: span.wallMs,
238
+ };
239
+ }
240
+
241
+ function pairGateRounds(
242
+ volumes: readonly ClassifiedVolume[],
243
+ ): readonly AnalystGateCycleRound[] {
244
+ const ordered = [...volumes].sort((a, b) => {
245
+ if (a.startedAt !== b.startedAt) return a.startedAt.localeCompare(b.startedAt);
246
+ // Stable tie-break: dispatch before officer at identical start (should not happen).
247
+ if (a.kind !== b.kind) return a.kind === "dispatch" ? -1 : 1;
248
+ return 0;
249
+ });
250
+
251
+ const usedOfficerIdx = new Set<number>();
252
+ const rounds: AnalystGateCycleRound[] = [];
253
+
254
+ for (let i = 0; i < ordered.length; i += 1) {
255
+ const vol = ordered[i]!;
256
+ if (vol.kind !== "dispatch") continue;
257
+ let match: { index: number; officer: Extract<ClassifiedVolume, { kind: "officer" }> } | undefined;
258
+ for (let j = i + 1; j < ordered.length; j += 1) {
259
+ if (usedOfficerIdx.has(j)) continue;
260
+ const candidate = ordered[j]!;
261
+ if (candidate.kind !== "officer") continue;
262
+ if (candidate.officer !== vol.officer) continue;
263
+ // First unused later officer with matching identity.
264
+ match = { index: j, officer: candidate };
265
+ break;
266
+ }
267
+ if (match === undefined) continue;
268
+ usedOfficerIdx.add(match.index);
269
+ rounds.push({
270
+ roundIndex: rounds.length + 1,
271
+ officer: match.officer.officer,
272
+ status: match.officer.status,
273
+ officerWallMs: match.officer.officerWallMs,
274
+ officerStartedAt: match.officer.startedAt,
275
+ officerEndedAt: match.officer.endedAt,
276
+ findingsCount: match.officer.findingsCount,
277
+ });
278
+ }
279
+
280
+ return rounds;
281
+ }
282
+
283
+ /**
284
+ * Read and pair gate-cycle rounds from a run's session/auditor-roles directory.
285
+ * ENOENT (directory truly absent) → []. ENOTDIR and other errors propagate
286
+ * (failure honesty — damaged topology must not wash to zero rounds).
287
+ */
288
+ export async function readAnalystGateCyclesFromAuditorRoles(
289
+ auditorRolesDirectory: string,
290
+ ): Promise<readonly AnalystGateCycleRound[]> {
291
+ let names: string[];
292
+ try {
293
+ const entries = await readdir(auditorRolesDirectory, { withFileTypes: true });
294
+ names = entries
295
+ .filter((e) => e.isFile() && e.name.endsWith(".jsonl"))
296
+ .map((e) => e.name)
297
+ .sort();
298
+ } catch (error) {
299
+ if (isMissingDirectoryError(error)) return [];
300
+ throw error;
301
+ }
302
+
303
+ const volumes: ClassifiedVolume[] = [];
304
+ for (const name of names) {
305
+ const classified = await classifyAuditorVolume(join(auditorRolesDirectory, name));
306
+ if (classified !== undefined) volumes.push(classified);
307
+ }
308
+ return pairGateRounds(volumes);
309
+ }
@@ -39,6 +39,12 @@ import type {
39
39
  AnalystScopeConflict,
40
40
  AnalystUnreadableRun,
41
41
  } from "./analyst-page.ts";
42
+ import {
43
+ readAnalystGateCyclesFromAuditorRoles,
44
+ type AnalystGateCycleRound,
45
+ } from "./analyst-gate-cycles-read.ts";
46
+
47
+ export type { AnalystGateCycleRound } from "./analyst-gate-cycles-read.ts";
42
48
 
43
49
  function isMissingPathError(error: unknown): boolean {
44
50
  return (
@@ -233,6 +239,12 @@ export type AnalystReadableRunFacts = {
233
239
  * Model-groups mode lists empty as typed session-model vacancy, never as "".
234
240
  */
235
241
  readonly models: readonly string[];
242
+ /**
243
+ * Paired gate-cycle rounds from session/auditor-roles/ (#446).
244
+ * Missing directory → empty (lawful zero rounds).
245
+ * Damaged discovered nested JSONL → leg unreadable (`auditor-roles` source).
246
+ */
247
+ readonly gateCycles: readonly AnalystGateCycleRound[];
236
248
  };
237
249
 
238
250
  export type AnalystScopedRunScan = {
@@ -384,6 +396,28 @@ async function classifyScopedRun(input: {
384
396
  );
385
397
  }
386
398
 
399
+ // Nested auditor-roles gate pairs stay inside the sole scan (families must
400
+ // not readdir this tree again). Missing directory → []. Damaged discovered
401
+ // nested JSONL is page-local unreadable — never silently under-count rounds.
402
+ let gateCycles: readonly AnalystGateCycleRound[];
403
+ try {
404
+ gateCycles = await readAnalystGateCyclesFromAuditorRoles(
405
+ join(input.runDirectory, "session", "auditor-roles"),
406
+ );
407
+ } catch (error) {
408
+ return {
409
+ kind: "unreadable",
410
+ entry: {
411
+ runId: input.runId,
412
+ book: input.book,
413
+ missingSources: ["auditor-roles"],
414
+ reason: errorText(error),
415
+ firstFrameAt: { status: "present", at: frameSpan.startedAt },
416
+ lastFrameAt: { status: "present", at: frameSpan.endedAt },
417
+ },
418
+ };
419
+ }
420
+
387
421
  return {
388
422
  kind: "readable",
389
423
  facts: {
@@ -394,6 +428,7 @@ async function classifyScopedRun(input: {
394
428
  toolIntervals,
395
429
  terminal,
396
430
  models,
431
+ gateCycles,
397
432
  },
398
433
  };
399
434
  }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Gate-cycle metric family (#446).
3
+ *
4
+ * Consumes sole-scan retained gateCycles facts only — no second ledger scan.
5
+ * Emits per-leg round counts / officer wall / terminal status, plus by-officer
6
+ * bounce rate and mean officer wall. findings prose is never read (count only).
7
+ */
8
+ import type { AnalystGateCycleRound } from "../analyst-gate-cycles-read.ts";
9
+ import type { AnalystReadableRunFacts } from "../analyst-ledger.ts";
10
+ import type { AnalystMetricFamilyModule } from "../analyst-metric-family.ts";
11
+
12
+ export type AnalystGateCyclesRoundRow = {
13
+ readonly roundIndex: number;
14
+ readonly officer: "inspector" | "notary";
15
+ readonly status: string;
16
+ readonly officerWallMs: number;
17
+ readonly findingsCount: number;
18
+ };
19
+
20
+ export type AnalystGateCyclesLeg = {
21
+ readonly runId: string;
22
+ readonly book: string;
23
+ readonly role: string;
24
+ /** Paired dispatch↔officer volume count (0 when no auditor-roles). */
25
+ readonly roundCount: number;
26
+ readonly rounds: readonly AnalystGateCyclesRoundRow[];
27
+ };
28
+
29
+ export type AnalystGateCyclesOfficerSummary = {
30
+ readonly officer: "inspector" | "notary";
31
+ readonly rounds: number;
32
+ readonly bounceCount: number;
33
+ readonly passCount: number;
34
+ /**
35
+ * bounceCount / rounds. Absent when rounds === 0 (never encode as 0 rate
36
+ * with empty denominator).
37
+ */
38
+ readonly bounceRate: number | undefined;
39
+ /** Mean officer subsession wall across this officer's rounds; absent if none. */
40
+ readonly meanOfficerWallMs: number | undefined;
41
+ };
42
+
43
+ export type AnalystGateCyclesSection = {
44
+ readonly kind: "analyst-gate-cycles";
45
+ readonly legs: readonly AnalystGateCyclesLeg[];
46
+ readonly byOfficer: readonly AnalystGateCyclesOfficerSummary[];
47
+ };
48
+
49
+ function projectRound(round: AnalystGateCycleRound): AnalystGateCyclesRoundRow {
50
+ return {
51
+ roundIndex: round.roundIndex,
52
+ officer: round.officer,
53
+ status: round.status,
54
+ officerWallMs: round.officerWallMs,
55
+ findingsCount: round.findingsCount,
56
+ };
57
+ }
58
+
59
+ function projectLeg(facts: AnalystReadableRunFacts): AnalystGateCyclesLeg {
60
+ const rounds = facts.gateCycles.map(projectRound);
61
+ return {
62
+ runId: facts.runId,
63
+ book: facts.book,
64
+ role: facts.role,
65
+ roundCount: rounds.length,
66
+ rounds,
67
+ };
68
+ }
69
+
70
+ function compareLegs(a: AnalystGateCyclesLeg, b: AnalystGateCyclesLeg): number {
71
+ if (a.book !== b.book) return a.book.localeCompare(b.book);
72
+ if (a.role !== b.role) return a.role.localeCompare(b.role);
73
+ return a.runId.localeCompare(b.runId);
74
+ }
75
+
76
+ type OfficerAccum = {
77
+ rounds: number;
78
+ bounceCount: number;
79
+ passCount: number;
80
+ wallSum: number;
81
+ };
82
+
83
+ function emptyAccum(): OfficerAccum {
84
+ return { rounds: 0, bounceCount: 0, passCount: 0, wallSum: 0 };
85
+ }
86
+
87
+ function absorbRound(accum: OfficerAccum, round: AnalystGateCyclesRoundRow): void {
88
+ accum.rounds += 1;
89
+ accum.wallSum += round.officerWallMs;
90
+ if (round.status === "bounce") accum.bounceCount += 1;
91
+ if (round.status === "pass") accum.passCount += 1;
92
+ }
93
+
94
+ function finishOfficer(
95
+ officer: "inspector" | "notary",
96
+ accum: OfficerAccum,
97
+ ): AnalystGateCyclesOfficerSummary {
98
+ return {
99
+ officer,
100
+ rounds: accum.rounds,
101
+ bounceCount: accum.bounceCount,
102
+ passCount: accum.passCount,
103
+ bounceRate: accum.rounds === 0 ? undefined : accum.bounceCount / accum.rounds,
104
+ meanOfficerWallMs: accum.rounds === 0 ? undefined : accum.wallSum / accum.rounds,
105
+ };
106
+ }
107
+
108
+ function summarizeByOfficer(
109
+ legs: readonly AnalystGateCyclesLeg[],
110
+ ): readonly AnalystGateCyclesOfficerSummary[] {
111
+ const byOfficer = new Map<"inspector" | "notary", OfficerAccum>();
112
+ for (const leg of legs) {
113
+ for (const round of leg.rounds) {
114
+ const accum = byOfficer.get(round.officer) ?? emptyAccum();
115
+ absorbRound(accum, round);
116
+ byOfficer.set(round.officer, accum);
117
+ }
118
+ }
119
+ // Stable officer order; only officers that appeared on the board.
120
+ const officers = (["inspector", "notary"] as const).filter((o) => byOfficer.has(o));
121
+ return officers.map((officer) => finishOfficer(officer, byOfficer.get(officer)!));
122
+ }
123
+
124
+ /** Discovered by analyst-metric-families loader (default export). */
125
+ const gateCyclesFamily: AnalystMetricFamilyModule = {
126
+ id: "gate-cycles",
127
+ contribute(input) {
128
+ if (input.runs.length === 0) return undefined;
129
+ const legs = input.runs.map(projectLeg).sort(compareLegs);
130
+ const section: AnalystGateCyclesSection = {
131
+ kind: "analyst-gate-cycles",
132
+ legs,
133
+ byOfficer: summarizeByOfficer(legs),
134
+ };
135
+ return { gateCycles: section };
136
+ },
137
+ };
138
+
139
+ export default gateCyclesFamily;
@@ -11,6 +11,7 @@
11
11
  import type { AnalystMetricFamilyModule } from "./analyst-metric-family.ts";
12
12
  import acceptanceSuccessReworkFamily from "./analyst-metric-families/acceptance-success-rework.ts";
13
13
  import b2FrameBucketsActionsFamily from "./analyst-metric-families/b2-frame-buckets-actions.ts";
14
+ import gateCyclesFamily from "./analyst-metric-families/gate-cycles.ts";
14
15
  import legWallClockFamily from "./analyst-metric-families/leg-wall-clock.ts";
15
16
  import roundTimelineFamily from "./analyst-metric-families/round-timeline.ts";
16
17
 
@@ -21,6 +22,7 @@ import roundTimelineFamily from "./analyst-metric-families/round-timeline.ts";
21
22
  const ISSUE_METRIC_FAMILIES: readonly AnalystMetricFamilyModule[] = [
22
23
  acceptanceSuccessReworkFamily,
23
24
  b2FrameBucketsActionsFamily,
25
+ gateCyclesFamily,
24
26
  legWallClockFamily,
25
27
  roundTimelineFamily,
26
28
  ].sort((a, b) => a.id.localeCompare(b.id));
@@ -25,7 +25,13 @@ export type AnalystMissingSource =
25
25
  | "tool-association"
26
26
  | "terminal-artifact"
27
27
  /** Model-groups mode: leg has no usable session model identity. */
28
- | "session-model";
28
+ | "session-model"
29
+ /**
30
+ * Nested session/auditor-roles volume was discovered but failed canonical
31
+ * read/parse (#446). Missing auditor-roles directory is lawful zero rounds,
32
+ * not this face.
33
+ */
34
+ | "auditor-roles";
29
35
 
30
36
  /**
31
37
  * First usable session timestamp retained for an unreadable run when the