@akagilnc/pi-workflow-roles 0.1.4620 → 0.1.4641

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.zh-CN.md +1 -1
  2. package/dist/acp-host/production-host.js +962 -547
  3. package/dist/activation-ledger-topology.js +0 -4
  4. package/dist/gatekeeper-role.js +76 -6
  5. package/dist/headless-host/production-host.js +923 -508
  6. package/dist/migrate-book-topology.js +9 -0
  7. package/dist/pi/role-turn-host.js +0 -3
  8. package/dist/public-cli/auto-resume.js +26 -0
  9. package/dist/public-cli/countersign-run.js +31 -10
  10. package/dist/public-cli/main.js +1456 -1068
  11. package/dist/public-cli/post-admission.js +101 -7
  12. package/dist/public-cli/process-cancel.js +57 -0
  13. package/dist/public-cli/settlement.js +95 -2
  14. package/dist/public-role-summons.js +19 -0
  15. package/dist/secretariat-contracts.js +4 -0
  16. package/package.json +1 -1
  17. package/scripts/build-package.mjs +0 -1
  18. package/src/activation-ledger-topology.ts +1 -6
  19. package/src/activation-ledger.ts +5 -236
  20. package/src/analyst-entry.ts +19 -2
  21. package/src/analyst-ledger.ts +111 -6
  22. package/src/archivist-record-entry.ts +3 -3
  23. package/src/audit-escalation.ts +1 -1
  24. package/src/gatekeeper-pass-envelope.ts +37 -5
  25. package/src/gatekeeper-role.ts +125 -20
  26. package/src/host-contracts.ts +22 -2
  27. package/src/pi/adapter.ts +5 -1
  28. package/src/pi/role-turn-host.ts +0 -3
  29. package/src/public-cli/auto-resume.ts +34 -0
  30. package/src/public-cli/cli.ts +30 -0
  31. package/src/public-cli/countersign-run.ts +50 -13
  32. package/src/public-cli/main.ts +25 -3
  33. package/src/public-cli/post-admission.ts +173 -6
  34. package/src/public-cli/process-cancel.ts +75 -0
  35. package/src/public-cli/settlement.ts +117 -1
  36. package/src/public-role-summons.ts +30 -4
  37. package/src/role-envelope.ts +27 -10
  38. package/src/role-runtime.ts +146 -55
  39. package/src/secretariat-contracts.ts +18 -0
  40. package/dist/activation-reconciliation.js +0 -61
  41. package/src/activation-reconciliation.ts +0 -163
@@ -1,25 +1,8 @@
1
- import {
2
- constants,
3
- closeSync,
4
- fstatSync,
5
- openSync,
6
- writeSync,
7
- } from "node:fs";
8
- import { dirname, isAbsolute, resolve } from "node:path";
9
-
10
- import type { ActivationSessionPointer } from "./activation-ledger-session.ts";
11
- import {
12
- ActivationLedgerError,
13
- activationWaitingLedgerPath,
14
- assertLedgerFileInsideHome,
15
- ensureRealDirectoryTree,
16
- errorText,
17
- } from "./activation-ledger-topology.ts";
18
-
19
- /** Private open flags: O_NOFOLLOW closes the lstat→open TOCTOU window. */
20
- const ACTIVATION_LEDGER_APPEND_OPEN_FLAGS =
21
- constants.O_APPEND | constants.O_CREAT | constants.O_WRONLY | constants.O_NOFOLLOW;
22
-
1
+ /**
2
+ * Activation ledger surface: durable session principal + book/home topology.
3
+ * #855: two-face waiting.jsonl reconciliation deleted — this module no longer
4
+ * appends accepted-activation facts or own a waiting path.
5
+ */
23
6
  export {
24
7
  ActivationGitRepositoryRequiredError,
25
8
  resolveBookKeyFromGit,
@@ -33,219 +16,5 @@ export {
33
16
  export {
34
17
  ActivationLedgerError,
35
18
  activationBookDirectory,
36
- activationWaitingLedgerPath,
37
19
  resolveActivationLedgerHome,
38
20
  } from "./activation-ledger-topology.ts";
39
-
40
- /** Caller-preassigned correlation id, or an explicit absent identity (never empty string). */
41
- export type ActivationCorrelationIdentity =
42
- | { readonly kind: "caller"; readonly id: string }
43
- | { readonly kind: "absent" };
44
-
45
- export const ACCEPTED_ACTIVATION_EVENT = "accepted-activation" as const;
46
-
47
- /**
48
- * Package-owned index-only top-level keys for accepted-activation facts (ADR 0049).
49
- * Sole machine key contract: projection and tests consume this descriptor — not prose markers.
50
- */
51
- export const ACCEPTED_ACTIVATION_FACT_KEYS = Object.freeze([
52
- "event",
53
- "role",
54
- "observedAt",
55
- "bookKey",
56
- "session",
57
- "correlation",
58
- ] as const);
59
-
60
- export type AcceptedActivationFactKey = (typeof ACCEPTED_ACTIVATION_FACT_KEYS)[number];
61
-
62
- /**
63
- * Closed activation fact: index fields only (ADR 0049).
64
- * No prompt, transcript, argv, excerpt, or other content.
65
- */
66
- export type AcceptedActivationFact = {
67
- readonly event: typeof ACCEPTED_ACTIVATION_EVENT;
68
- readonly role: string;
69
- readonly observedAt: string;
70
- readonly bookKey: string;
71
- readonly session: ActivationSessionPointer;
72
- readonly correlation: ActivationCorrelationIdentity;
73
- };
74
-
75
- // Keys tuple ↔ fact type must stay exact (compile fail on drift; no second field list).
76
- type ExactKeyMatch<T, K extends PropertyKey> =
77
- Exclude<keyof T, K> | Exclude<K, keyof T> extends never ? true : never;
78
- const _acceptedActivationFactKeysMatch: ExactKeyMatch<
79
- AcceptedActivationFact,
80
- AcceptedActivationFactKey
81
- > = true;
82
- void _acceptedActivationFactKeysMatch;
83
-
84
- /** Trusted typed inputs for building the closed fact (canonical fact minus event discriminant). */
85
- export type AcceptedActivationFactInput = Omit<AcceptedActivationFact, "event">;
86
-
87
- /**
88
- * Host correlation channel (not a CLI flag): a non-blank AK_CORRELATION_ID carries
89
- * the caller id verbatim; missing/blank/whitespace-only yields the typed absent identity.
90
- */
91
- export function correlationIdentityFromEnv(
92
- env: NodeJS.ProcessEnv = process.env,
93
- ): ActivationCorrelationIdentity {
94
- const raw = env.AK_CORRELATION_ID;
95
- if (typeof raw === "string" && raw.trim().length > 0) {
96
- return { kind: "caller", id: raw };
97
- }
98
- return { kind: "absent" };
99
- }
100
-
101
- /**
102
- * Descriptor-driven top-level pick: only ACCEPTED_ACTIVATION_FACT_KEYS leave this boundary.
103
- * Nested session/correlation are rebuilt closed before the pick (ADR 0049: first-phase ledger holds zero content bytes by construction).
104
- */
105
- function projectAcceptedActivationFact(
106
- input: AcceptedActivationFactInput,
107
- ): AcceptedActivationFact {
108
- const closed: AcceptedActivationFact = {
109
- event: ACCEPTED_ACTIVATION_EVENT,
110
- role: input.role,
111
- observedAt: input.observedAt,
112
- bookKey: input.bookKey,
113
- session: { kind: "session-file", path: input.session.path },
114
- correlation: input.correlation.kind === "caller"
115
- ? { kind: "caller", id: input.correlation.id }
116
- : { kind: "absent" },
117
- };
118
- // Descriptor is the sole top-level emission contract (typed pick; no content keys).
119
- return Object.fromEntries(
120
- ACCEPTED_ACTIVATION_FACT_KEYS.map((key) => [key, closed[key]]),
121
- ) as AcceptedActivationFact;
122
- }
123
-
124
- /** Construct the closed fact from trusted typed inputs only (descriptor projection — no content keys). */
125
- export function buildAcceptedActivationFact(input: AcceptedActivationFactInput): AcceptedActivationFact {
126
- return projectAcceptedActivationFact(input);
127
- }
128
-
129
- /** Serialize only the closed index fields (descriptor projection — no content keys). */
130
- export function serializeAcceptedActivationFact(fact: AcceptedActivationFact): string {
131
- return `${JSON.stringify(projectAcceptedActivationFact(fact))}\n`;
132
- }
133
-
134
- /**
135
- * Bare O_APPEND write of one complete line under an absolute ledger home.
136
- * Private helper: production-bound to writeSync; owns open/write/close honesty only.
137
- * Close failure cannot mask open/write; simultaneous close is nested evidence.
138
- */
139
- function appendActivationLedgerLine(
140
- ledgerPath: string,
141
- line: Uint8Array,
142
- options: { ledgerHome: string },
143
- ): void {
144
- if (!isAbsolute(options.ledgerHome)) {
145
- throw new ActivationLedgerError(
146
- `activation ledger home must be absolute: ${options.ledgerHome}`,
147
- );
148
- }
149
- const resolvedLedger = resolve(ledgerPath);
150
- const resolvedHome = resolve(options.ledgerHome);
151
- const parent = dirname(resolvedLedger);
152
- ensureRealDirectoryTree(resolvedHome, parent);
153
- assertLedgerFileInsideHome(resolvedLedger, resolvedHome);
154
-
155
- // Fail-closed guard (same shape as PR #418's publication seam): on platforms
156
- // without O_NOFOLLOW (e.g. Windows, nodejs/node#41590) the JS bitwise-or in
157
- // ACTIVATION_LEDGER_APPEND_OPEN_FLAGS would silently drop the flag and the
158
- // lstat→open TOCTOU anti-symlink protection would vanish while the comment
159
- // above still claims it. Refuse loudly via the existing typed ledger failure
160
- // channel instead of appending unprotected; never silently degrade.
161
- if (typeof constants.O_NOFOLLOW !== "number") {
162
- throw new ActivationLedgerError(
163
- "activation ledger append requires O_NOFOLLOW open-flag support (anti-symlink TOCTOU protection must not be silently dropped); refusing to append",
164
- );
165
- }
166
- const bytes = Buffer.isBuffer(line) ? line : Buffer.from(line);
167
- let ledgerFd: number | undefined;
168
- let primaryFailure: unknown;
169
- try {
170
- try {
171
- ledgerFd = openSync(resolvedLedger, ACTIVATION_LEDGER_APPEND_OPEN_FLAGS, 0o644);
172
- } catch (error) {
173
- // Native ELOOP/filesystem cause retained; no errno dispatch.
174
- throw new ActivationLedgerError(
175
- `activation ledger failed to open ledger file (${resolvedLedger}): ${errorText(error)}`,
176
- { cause: error },
177
- );
178
- }
179
-
180
- let opened: ReturnType<typeof fstatSync>;
181
- try {
182
- opened = fstatSync(ledgerFd);
183
- } catch (error) {
184
- throw new ActivationLedgerError(
185
- `activation ledger failed to fstat ledger file (${resolvedLedger}): ${errorText(error)}`,
186
- { cause: error },
187
- );
188
- }
189
- if (!opened.isFile()) {
190
- throw new ActivationLedgerError(
191
- `activation ledger is not a regular file: ${resolvedLedger}`,
192
- );
193
- }
194
-
195
- const written = writeSync(ledgerFd, bytes, 0, bytes.length, null);
196
- if (written !== bytes.length) {
197
- throw new ActivationLedgerError(
198
- `activation ledger short write: wrote ${written} of ${bytes.length} bytes to ${resolvedLedger}`,
199
- );
200
- }
201
- } catch (error) {
202
- primaryFailure = error;
203
- }
204
-
205
- if (ledgerFd !== undefined) {
206
- try {
207
- closeSync(ledgerFd);
208
- } catch (closeFailure) {
209
- if (primaryFailure !== undefined) {
210
- throw new AggregateError(
211
- [primaryFailure, closeFailure],
212
- "activation ledger operation and close failed",
213
- { cause: primaryFailure },
214
- );
215
- }
216
- throw closeFailure;
217
- }
218
- }
219
-
220
- if (primaryFailure !== undefined) throw primaryFailure;
221
- }
222
-
223
- /**
224
- * Append one complete JSONL record with one O_APPEND write of the full record.
225
- * Shared-ledger contract: concurrent successful append-only producers cannot
226
- * overwrite one another. A short write is an honest infrastructure failure
227
- * (ADR 0049) — no non-append rollback/truncate. Close failure cannot mask the
228
- * primary write cause; simultaneous close evidence is retained.
229
- */
230
- export function appendAcceptedActivationFact(
231
- ledgerPath: string,
232
- fact: AcceptedActivationFact,
233
- options: { ledgerHome: string },
234
- ): void {
235
- appendActivationLedgerLine(
236
- ledgerPath,
237
- Buffer.from(serializeAcceptedActivationFact(fact), "utf8"),
238
- { ledgerHome: options.ledgerHome },
239
- );
240
- }
241
-
242
- export function appendAcceptedActivationToBook(options: {
243
- ledgerHome: string;
244
- fact: AcceptedActivationFact;
245
- }): void {
246
- appendAcceptedActivationFact(
247
- activationWaitingLedgerPath(options.ledgerHome, options.fact.bookKey),
248
- options.fact,
249
- { ledgerHome: options.ledgerHome },
250
- );
251
- }
@@ -22,9 +22,12 @@ import {
22
22
  } from "./analyst-cohort.ts";
23
23
  import {
24
24
  scanAnalystIssueRuns,
25
+ type AnalystGhostLeg,
25
26
  type AnalystReadableRunFacts,
26
27
  type AnalystScopedRunScan,
27
28
  } from "./analyst-ledger.ts";
29
+
30
+ export type { AnalystGhostLeg, AnalystGhostLeaseCheck } from "./analyst-ledger.ts";
28
31
  import {
29
32
  mergeAnalystLibraryIndexRows,
30
33
  rowFromIssueMetricsPage,
@@ -173,6 +176,11 @@ export type AnalystIssueModeResult = {
173
176
  readonly mode: "issue";
174
177
  readonly page: AnalystIssueMetricsPage;
175
178
  readonly pagePath: string;
179
+ /**
180
+ * #855 live observation: admitted|running runs whose writer lease cannot prove
181
+ * a live holder. Fresh each query — never cached on the metrics page.
182
+ */
183
+ readonly ghostLegs: readonly AnalystGhostLeg[];
176
184
  };
177
185
 
178
186
  export type AnalystSweepModeResult = {
@@ -264,7 +272,16 @@ export async function readOrComputeAnalystIssuePage(
264
272
  const raw = await readFile(pagePath, "utf8");
265
273
  const page = JSON.parse(raw) as AnalystIssueMetricsPage;
266
274
  if (cachedPageMatchesRequestedScope(page, { bookKey, ...input })) {
267
- return { mode: "issue", page, pagePath };
275
+ // #855 ghost legs are live observations — always fresh, never from the page cache.
276
+ const ghostScan = await scanAnalystIssueRuns({
277
+ bookKey,
278
+ ...(issueNumber === undefined ? {} : { ticketNumber: issueNumber }),
279
+ ...(options?.scanProjectRoot === undefined
280
+ ? {}
281
+ : { projectRoot: options.scanProjectRoot }),
282
+ ...(options?.home === undefined ? {} : { home: options.home }),
283
+ });
284
+ return { mode: "issue", page, pagePath, ghostLegs: ghostScan.ghostLegs };
268
285
  }
269
286
  // Existing page is for a different / absent ticket scope — same kernel recompute.
270
287
  } catch (error) {
@@ -364,7 +381,7 @@ async function runAnalystIssueMode(
364
381
  ]);
365
382
  }
366
383
 
367
- return { mode: "issue", page, pagePath };
384
+ return { mode: "issue", page, pagePath, ghostLegs: scan.ghostLegs };
368
385
  }
369
386
 
370
387
  async function runAnalystSweepMode(
@@ -22,6 +22,7 @@ import {
22
22
  resolveActivationLedgerHome,
23
23
  } from "./activation-ledger-topology.ts";
24
24
  import { listBookRunDirectories } from "./role-run-placement.ts";
25
+ import { autopsyWriterLock } from "./public-cli/run-lifecycle.ts";
25
26
  import { readRunTicketNumber } from "./run-ticket-number.ts";
26
27
  import {
27
28
  extractSessionModelSequence,
@@ -87,6 +88,64 @@ async function readExistingRunLifecycleState(
87
88
  }
88
89
  }
89
90
 
91
+ /**
92
+ * #855 lease check projection for ghost legs — index facts only (ADR 0049).
93
+ * Does not rename autopsy verdicts into stronger claims than observed.
94
+ */
95
+ export type AnalystGhostLeaseCheck =
96
+ | { readonly kind: "holder-dead"; readonly pid: number }
97
+ | { readonly kind: "no-lease" }
98
+ | { readonly kind: "unverifiable"; readonly reason: "unreadable" | "unparseable" };
99
+
100
+ export type AnalystGhostLeg = {
101
+ readonly runId: string;
102
+ readonly runState: "admitted" | "running";
103
+ readonly leaseCheck: AnalystGhostLeaseCheck;
104
+ };
105
+
106
+ function ghostLeaseCheckFromAutopsy(
107
+ autopsy: Awaited<ReturnType<typeof autopsyWriterLock>>,
108
+ ): AnalystGhostLeaseCheck | undefined {
109
+ switch (autopsy.verdict) {
110
+ case "alive":
111
+ return undefined;
112
+ case "dead":
113
+ return { kind: "holder-dead", pid: autopsy.pid };
114
+ case "absent":
115
+ return { kind: "no-lease" };
116
+ case "unknown":
117
+ return {
118
+ kind: "unverifiable",
119
+ reason: autopsy.reason === "unreadable" ? "unreadable" : "unparseable",
120
+ };
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Classify one admitted|running run against writer lease liveness (#855).
126
+ * Alive holder → still in-flight (omit). Otherwise → ghost report fact only.
127
+ */
128
+ async function classifyGhostCandidate(input: {
129
+ readonly runId: string;
130
+ readonly runState: "admitted" | "running";
131
+ readonly runDirectory: string;
132
+ }): Promise<
133
+ | { readonly kind: "live" }
134
+ | { readonly kind: "ghost"; readonly leg: AnalystGhostLeg }
135
+ > {
136
+ const autopsy = await autopsyWriterLock(join(input.runDirectory, "writer.lock"));
137
+ const leaseCheck = ghostLeaseCheckFromAutopsy(autopsy);
138
+ if (leaseCheck === undefined) return { kind: "live" };
139
+ return {
140
+ kind: "ghost",
141
+ leg: {
142
+ runId: input.runId,
143
+ runState: input.runState,
144
+ leaseCheck,
145
+ },
146
+ };
147
+ }
148
+
90
149
  function parseRunDirectoryName(
91
150
  name: string,
92
151
  ): { runId: string; role: string } | undefined {
@@ -335,6 +394,8 @@ export type AnalystScopedRunScan = {
335
394
  readonly unreadable: readonly AnalystUnreadableRun[];
336
395
  /** C4: typed-ticket admits whose projectRoot mechanical key conflicted. */
337
396
  readonly scopeConflicts: readonly AnalystScopeConflict[];
397
+ /** #855: admitted|running runs whose writer lease cannot prove a live holder. */
398
+ readonly ghostLegs: readonly AnalystGhostLeg[];
338
399
  };
339
400
 
340
401
  async function classifyScopedRun(input: {
@@ -343,9 +404,20 @@ async function classifyScopedRun(input: {
343
404
  readonly role: string;
344
405
  readonly runDirectory: string;
345
406
  }): Promise<
346
- | { readonly kind: "readable"; readonly facts: AnalystReadableRunFacts }
347
- | { readonly kind: "unreadable"; readonly entry: AnalystUnreadableRun }
407
+ | {
408
+ readonly kind: "readable";
409
+ readonly facts: AnalystReadableRunFacts;
410
+ /** #855: present terminal still lists ghost when lease cannot prove live. */
411
+ readonly ghostLeg?: AnalystGhostLeg;
412
+ }
413
+ | {
414
+ readonly kind: "unreadable";
415
+ readonly entry: AnalystUnreadableRun;
416
+ /** #855: unreadable terminal still lists ghost when lease cannot prove live. */
417
+ readonly ghostLeg?: AnalystGhostLeg;
418
+ }
348
419
  | { readonly kind: "live" }
420
+ | { readonly kind: "ghost"; readonly leg: AnalystGhostLeg }
349
421
  > {
350
422
  const missingSources: AnalystMissingSource[] = [];
351
423
  const reasons: string[] = [];
@@ -434,13 +506,31 @@ async function classifyScopedRun(input: {
434
506
 
435
507
  // 3) typed terminal artifact — absence is no-receipt only for terminal runs.
436
508
  // Live admitted/running/resumable runs (existing run-state) are not dead legs.
509
+ // #855: writer-lease ghost check is independent of terminal artifact status.
510
+ // present → still emit metrics; unreadable → still enter missingSources;
511
+ // admitted|running + non-live lease → still list in ghostLegs (ticket #4).
512
+ const lifecycle = await readExistingRunLifecycleState(input.runDirectory);
513
+ let ghostLeg: AnalystGhostLeg | undefined;
514
+ if (lifecycle === "admitted" || lifecycle === "running") {
515
+ const ghost = await classifyGhostCandidate({
516
+ runId: input.runId,
517
+ runState: lifecycle,
518
+ runDirectory: input.runDirectory,
519
+ });
520
+ if (ghost.kind === "ghost") ghostLeg = ghost.leg;
521
+ }
522
+
437
523
  try {
438
524
  const artifact = await readRunTerminalArtifact(input.runDirectory);
439
525
  if (artifact.status === "unreadable") {
440
526
  missingSources.push("terminal-artifact");
441
527
  reasons.push(`${artifact.file}: ${artifact.reason}`);
442
528
  } else if (artifact.status === "absent") {
443
- const lifecycle = await readExistingRunLifecycleState(input.runDirectory);
529
+ if (lifecycle === "admitted" || lifecycle === "running") {
530
+ // No terminal yet: ghost report only, or live in-flight omit.
531
+ if (ghostLeg !== undefined) return { kind: "ghost", leg: ghostLeg };
532
+ return { kind: "live" };
533
+ }
444
534
  if (lifecycle !== undefined && LIVE_RUN_STATES.has(lifecycle)) {
445
535
  return { kind: "live" };
446
536
  }
@@ -479,6 +569,7 @@ async function classifyScopedRun(input: {
479
569
  firstFrameAt,
480
570
  lastFrameAt,
481
571
  },
572
+ ...(ghostLeg !== undefined ? { ghostLeg } : {}),
482
573
  };
483
574
  }
484
575
 
@@ -510,6 +601,7 @@ async function classifyScopedRun(input: {
510
601
  firstFrameAt: { status: "present", at: frameSpan.startedAt },
511
602
  lastFrameAt: { status: "present", at: frameSpan.endedAt },
512
603
  },
604
+ ...(ghostLeg !== undefined ? { ghostLeg } : {}),
513
605
  };
514
606
  }
515
607
 
@@ -525,6 +617,7 @@ async function classifyScopedRun(input: {
525
617
  models,
526
618
  gateCycles,
527
619
  },
620
+ ...(ghostLeg !== undefined ? { ghostLeg } : {}),
528
621
  };
529
622
  }
530
623
 
@@ -588,11 +681,12 @@ export async function scanAnalystIssueRuns(input: {
588
681
  }
589
682
 
590
683
  if (bookNames.length === 0) {
591
- return { runs: [], unreadable: [], scopeConflicts: [] };
684
+ return { runs: [], unreadable: [], scopeConflicts: [], ghostLegs: [] };
592
685
  }
593
686
 
594
687
  const runs: AnalystReadableRunFacts[] = [];
595
688
  const unreadable: AnalystUnreadableRun[] = [];
689
+ const ghostLegs: AnalystGhostLeg[] = [];
596
690
  // scopeConflicts retained on the scan face for page envelope compat; book×ticket
597
691
  // scope no longer emits projectRoot dual-key conflicts on the CLI path.
598
692
  const scopeConflicts: AnalystScopeConflict[] = [];
@@ -638,9 +732,19 @@ export async function scanAnalystIssueRuns(input: {
638
732
  role: parsed.role,
639
733
  runDirectory,
640
734
  });
641
- if (classified.kind === "readable") runs.push(classified.facts);
642
- else if (classified.kind === "unreadable") unreadable.push(classified.entry);
735
+ if (classified.kind === "readable") {
736
+ runs.push(classified.facts);
737
+ if (classified.ghostLeg !== undefined) ghostLegs.push(classified.ghostLeg);
738
+ } else if (classified.kind === "unreadable") {
739
+ unreadable.push(classified.entry);
740
+ if (classified.ghostLeg !== undefined) ghostLegs.push(classified.ghostLeg);
741
+ } else if (classified.kind === "ghost") {
742
+ ghostLegs.push(classified.leg);
743
+ }
643
744
  // live in-flight runs are omitted from legs and unreadable (not failure/death).
745
+ // #855 ghost legs are reported separately — not counted as in-flight.
746
+ // Lease check is independent of terminal artifact: present/unreadable may
747
+ // still carry a ghostLeg alongside metrics or missingSources.
644
748
  }
645
749
  }
646
750
 
@@ -648,5 +752,6 @@ export async function scanAnalystIssueRuns(input: {
648
752
  runs,
649
753
  unreadable,
650
754
  scopeConflicts,
755
+ ghostLegs,
651
756
  };
652
757
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * 司天台唯一 Pi session 记录落盘入口(ADR 0065)。
3
3
  * 调用方只声明自己是谁的什么;落点由候簿拓扑算出,签名不含任何落点/路径参数。
4
- * 「谁调了谁」复用 Pi parentSession + ADR 0047 correlation,不新增 caller 字段。
4
+ * 「谁调了谁」复用 Pi parentSession,不新增 caller 字段(#855 两面对账 correlation 键已删)。
5
5
  */
6
6
  import {
7
7
  closeSync,
@@ -479,7 +479,7 @@ export const DIRECT_OFFICER_RUN_POINTER_KIND = "direct-officer-run-pointer" as c
479
479
  export type DirectOfficerRunPointer = {
480
480
  readonly version: 1;
481
481
  readonly kind: typeof DIRECT_OFFICER_RUN_POINTER_KIND;
482
- readonly officer: "inspector" | "notary" | "auditor";
482
+ readonly officer: "inspector" | "notary" | "auditor" | "countersign";
483
483
  /** Absolute path to the officer session.jsonl 正本. */
484
484
  readonly sessionFile: string;
485
485
  /** Officer run directory when known. */
@@ -497,7 +497,7 @@ export type DirectOfficerRunPointer = {
497
497
  */
498
498
  export function bookDirectOfficerRunPointer(options: {
499
499
  readonly parentSessionFile: string;
500
- readonly officer: "inspector" | "notary" | "auditor";
500
+ readonly officer: "inspector" | "notary" | "auditor" | "countersign";
501
501
  readonly sessionFile: string;
502
502
  readonly runDirectory?: string;
503
503
  }): DirectOfficerRunPointer {
@@ -38,7 +38,7 @@ export type AuditEscalationToolResult = {
38
38
 
39
39
  export type OfficerAuditEscalationDecision = {
40
40
  readonly status: "escalate";
41
- readonly officer?: "inspector" | "notary";
41
+ readonly officer?: "inspector" | "notary" | "countersign";
42
42
  readonly reason?: unknown;
43
43
  readonly findings?: unknown;
44
44
  readonly conflicts?: unknown;
@@ -7,9 +7,10 @@
7
7
  * parent submit → summon officer → read conclusion field
8
8
  * pass → accept end
9
9
  * bounce | escalate → raw officer receipt as tool result back to parent
10
+ * — except secretariat_verdict escalate: throw → parent audit_escalation, not raw receipt (#969)
10
11
  * not three-state → resume officer with plain-language re-ask (no round cap)
11
12
  * transport / no_receipt → present honestly
12
- * Three pairs: countersign↔notary, judge↔auditor, worker↔inspector.
13
+ * Four pairs: countersign↔notary, judge↔auditor, worker↔inspector, secretariat↔countersign (#969).
13
14
  * Code does not judge content, map next-step for parent, or label unreadable/unusable.
14
15
  */
15
16
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
@@ -17,7 +18,7 @@ import { bookDirectOfficerRunPointer } from "./archivist-record-entry.ts";
17
18
  import type { HostContext, RoleTurnHost } from "./host-contracts.ts";
18
19
  import {
19
20
  GatekeeperDecisionError,
20
- OFFICER_CONCLUSION_REASK,
21
+ officerConclusionReask,
21
22
  projectGatekeeperRun,
22
23
  type GatekeeperPassHostActions,
23
24
  type GatekeeperResult,
@@ -37,6 +38,8 @@ export function createDefaultGateOfficerSummon(options: {
37
38
  readonly home?: string;
38
39
  readonly packageRoot?: string;
39
40
  readonly roleTurnHost?: RoleTurnHost;
41
+ /** Composition-root adapters for nested court stations (tests / #969). */
42
+ readonly hostAdapters?: readonly import("./public-cli/role-turn-host-resolution.ts").NamedRoleTurnHostAdapter[];
40
43
  readonly createRunId?: () => string;
41
44
  }): GateOfficerSummon {
42
45
  return async (officer, sourceRunDirectory, signal, reask, submission) => {
@@ -51,6 +54,7 @@ export function createDefaultGateOfficerSummon(options: {
51
54
  ...(options.home === undefined ? {} : { home: options.home }),
52
55
  ...(options.packageRoot === undefined ? {} : { packageRoot: options.packageRoot }),
53
56
  ...(options.roleTurnHost === undefined ? {} : { roleTurnHost: options.roleTurnHost }),
57
+ ...(options.hostAdapters === undefined ? {} : { hostAdapters: options.hostAdapters }),
54
58
  ...(options.createRunId === undefined ? {} : { createRunId: options.createRunId }),
55
59
  });
56
60
  };
@@ -92,9 +96,21 @@ function bookDirectOfficerPointer(
92
96
  });
93
97
  }
94
98
 
99
+ /**
100
+ * Pass snapshot returned to the parent seat (#969).
101
+ * Carries officer receipt + nested runId so seat settlement can project the
102
+ * public terminal without a second authority.
103
+ */
104
+ export type GatekeeperPassOutcome = {
105
+ readonly officer: GateOfficer;
106
+ readonly receipt: unknown;
107
+ readonly runId?: string;
108
+ };
109
+
95
110
  /**
96
111
  * Shared envelope: project gate, book officer pointer, map onto host actions.
97
112
  * Review loop has no round cap (#753 no-round-cap).
113
+ * On pass, returns the officer snapshot (receipt + nested runId) for seat projection.
98
114
  */
99
115
  export async function requireGatekeeperPass(options: {
100
116
  readonly context: ExtensionContext | HostContext;
@@ -109,7 +125,7 @@ export async function requireGatekeeperPass(options: {
109
125
  readonly submission?: unknown;
110
126
  /** Lowest seam: same as runGatekeeper options.summonOfficer — offline tracers only. */
111
127
  readonly summonOfficer?: GateOfficerSummon;
112
- }): Promise<void> {
128
+ }): Promise<GatekeeperPassOutcome | void> {
113
129
  let reask: string | undefined;
114
130
  // No round cap — end only on pass, bounce/escalate-to-parent, or real failure.
115
131
  const summonOfficer =
@@ -140,9 +156,17 @@ export async function requireGatekeeperPass(options: {
140
156
  options.hostActions.failInfrastructure(error, options.context, options.toolCallId);
141
157
  }
142
158
  }
143
- if (gatekeeper.status === "pass") return;
159
+ if (gatekeeper.status === "pass") {
160
+ return {
161
+ officer: projected.officer,
162
+ receipt: gatekeeper.receipt,
163
+ ...(typeof gatekeeper.runId === "string" && gatekeeper.runId.trim() !== ""
164
+ ? { runId: gatekeeper.runId }
165
+ : {}),
166
+ };
167
+ }
144
168
  if (gatekeeper.status === "needs_reask") {
145
- reask = OFFICER_CONCLUSION_REASK;
169
+ reask = officerConclusionReask(projected.officer);
146
170
  continue;
147
171
  }
148
172
  if (gatekeeper.status === "transport_failure") {
@@ -152,6 +176,14 @@ export async function requireGatekeeperPass(options: {
152
176
  });
153
177
  options.hostActions.failInfrastructure(failure, options.context, options.toolCallId);
154
178
  }
179
+ // #969: 给事中上呈 ends the secretariat parent — no bind/retry (不回中书省擅改).
180
+ // Parent beforeAccept converts this into audit_escalation with the officer receipt.
181
+ if (
182
+ gatekeeper.status === "escalate"
183
+ && options.subject.kind === "secretariat_verdict"
184
+ ) {
185
+ throw new GatekeeperDecisionError(gatekeeper);
186
+ }
155
187
  // bounce | escalate | no_receipt: parent stands and may resubmit. Not a run abort.
156
188
  options.hostActions.bindSubmissionNonPass(options.toolCallId, gatekeeper);
157
189
  throw new GatekeeperDecisionError(gatekeeper);