@akagilnc/pi-workflow-roles 0.1.4673 → 0.1.4695

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.
@@ -17,7 +17,11 @@ export function exitCodeForTerminalOutcome(outcome) {
17
17
  * auto-resumes occurred during this single LLM call; it is not persisted to
18
18
  * run-state.json and does not participate in limit decisions.
19
19
  */
20
- /** Original payloads on a terminal — role result for accepted/audit; coexist on failure. */
20
+ /**
21
+ * Current-result payloads on a terminal — accepted/audit role result, or a
22
+ * rare intentional failure face. Run history is TerminalResult.submissions
23
+ * (#836 / #953), not this helper.
24
+ */
21
25
  export function roleResultPayloads(outcome) {
22
26
  if (outcome.kind === "accepted" || outcome.kind === "audit_escalation")
23
27
  return outcome.payloads ?? [];
@@ -25,6 +29,18 @@ export function roleResultPayloads(outcome) {
25
29
  return outcome.payloads ?? [];
26
30
  return [];
27
31
  }
32
+ /**
33
+ * Prefer non-empty current-result payloads; an empty array must not shadow
34
+ * top-level submissions (#953 / #836). Callers that need failure history must
35
+ * read TerminalResult.submissions directly — do not coalesce into failure.payloads.
36
+ */
37
+ export function coalesceSubmissionRows(payloads, submissions) {
38
+ if (payloads !== undefined && payloads.length > 0)
39
+ return payloads;
40
+ if (submissions !== undefined && submissions.length > 0)
41
+ return submissions;
42
+ return payloads ?? submissions ?? [];
43
+ }
28
44
  /**
29
45
  * Build an advice navigator fact (#959). Prose is presented as submitted;
30
46
  * code does not parse route/next or judge usability.
@@ -109,15 +125,26 @@ export function formatTerminalResult(result) {
109
125
  }
110
126
  // Role-result block: original payloads, newest first for humans (#961).
111
127
  // Typed payloads/submissions stay ledger order; only this presentation reverses.
112
- const payloads = result.roleOutcome.kind === "accepted" || result.roleOutcome.kind === "audit_escalation"
113
- ? result.roleOutcome.payloads ?? result.submissions ?? []
114
- : result.roleOutcome.kind === "failure"
115
- ? result.roleOutcome.payloads ?? result.submissions ?? []
128
+ // #953: failure history is the top-level submissions carrier (#836) — present
129
+ // as recorded-submission, never as this-turn submission/receipt.
130
+ if (result.roleOutcome.kind === "failure") {
131
+ const recorded = result.submissions ?? [];
132
+ for (let i = recorded.length - 1; i >= 0; i -= 1) {
133
+ const payload = recorded[i];
134
+ const rendered = typeof payload === "string" ? payload : JSON.stringify(payload);
135
+ lines.push(`recorded-submission\t${encodeTerminalField(rendered)}`);
136
+ }
137
+ }
138
+ else {
139
+ const payloads = result.roleOutcome.kind === "accepted" ||
140
+ result.roleOutcome.kind === "audit_escalation"
141
+ ? coalesceSubmissionRows(result.roleOutcome.payloads, result.submissions)
116
142
  : result.submissions ?? [];
117
- for (let i = payloads.length - 1; i >= 0; i -= 1) {
118
- const payload = payloads[i];
119
- const rendered = typeof payload === "string" ? payload : JSON.stringify(payload);
120
- lines.push(`submission\t${encodeTerminalField(rendered)}`);
143
+ for (let i = payloads.length - 1; i >= 0; i -= 1) {
144
+ const payload = payloads[i];
145
+ const rendered = typeof payload === "string" ? payload : JSON.stringify(payload);
146
+ lines.push(`submission\t${encodeTerminalField(rendered)}`);
147
+ }
121
148
  }
122
149
  return `${lines.join("\n")}\n`;
123
150
  }
@@ -137,6 +137,7 @@ export function runIdFromRunDirectory(runDirectory) {
137
137
  * Shared parent-directory unique fallback may be adopted only when the
138
138
  * publisher-owned body.runId equals this run directory's runId. Same-run
139
139
  * artifactsDir / runDirectory candidates keep path ownership and skip this.
140
+ * expectedRunId undefined (unparseable run dir) → never bound.
140
141
  */
141
142
  function presentUniqueFallbackBoundToRun(body, expectedRunId) {
142
143
  if (expectedRunId === undefined)
@@ -144,49 +145,87 @@ function presentUniqueFallbackBoundToRun(body, expectedRunId) {
144
145
  return typeof body.runId === "string" && body.runId === expectedRunId;
145
146
  }
146
147
  /**
147
- * Read the first present typed terminal artifact for a run directory.
148
- * Order:
148
+ * Sole authority for seam-owned unique error.<uuid>.json candidates.
149
+ * Used by both clearOpposite (settlement) and readRunTerminalArtifact — do not
150
+ * re-enumerate the same-run / parent unique set elsewhere.
151
+ * Ownership:
152
+ * - same-run dirs (artifacts/, runDir): path ownership — all unique names
153
+ * - parent runs/: only body.runId-bound faces; unparseable runId or unreadable body → none
154
+ */
155
+ export async function listSeamOwnedUniqueErrorFacePaths(runDirectory) {
156
+ const artifactsDir = roleRunArtifactsDirectory(runDirectory);
157
+ const owned = await listUniqueErrorFallbackPaths([
158
+ artifactsDir,
159
+ runDirectory,
160
+ ]);
161
+ const expectedRunId = runIdFromRunDirectory(runDirectory);
162
+ for (const path of await listUniqueErrorFallbackPaths([dirname(runDirectory)])) {
163
+ const read = await readTerminalArtifactAtPath(path, "error.json");
164
+ if (read === undefined || read.status !== "present")
165
+ continue;
166
+ if (!presentUniqueFallbackBoundToRun(read.body, expectedRunId))
167
+ continue;
168
+ owned.push(path);
169
+ }
170
+ return owned;
171
+ }
172
+ /**
173
+ * Publish contract (#953): only failure publish continues after clearOpposite
174
+ * failure, so a multi-class residue (residual report/audit beside a new error
175
+ * face) means the current settlement is failure. Prefer failure-class faces
176
+ * over success/audit — never filesystem mtime (copy/restore/utimes can lie).
177
+ */
178
+ function failureClassRank(file) {
179
+ return file === "error.json" ? 1 : 0;
180
+ }
181
+ /**
182
+ * Read the current typed terminal artifact for a run directory.
183
+ *
184
+ * Candidate set (publisher-owned faces only):
149
185
  * 1) conventional artifacts/{report,error,audit-incomplete}.json
150
186
  * 2) publisher fixed failure fallbacks (error.settlement.json faces)
151
- * 3) publisher unique error.<uuid>.json fallbacks under same-run dirs
152
- * 4) shared parent-directory unique fallbacks bound by body.runId
187
+ * 3) seam-owned unique error.<uuid>.json via listSeamOwnedUniqueErrorFacePaths
188
+ * (same-run path ownership + parent body.runId binding — shared with clear)
153
189
  *
154
- * Absence of every known durable face is a valid no-receipt state (not unreadable).
155
- * A present file that cannot be parsed as a usable typed JSON object is unreadable.
190
+ * Invariant: when more than one present face remains (e.g. clearOpposite failed
191
+ * during failure publish and a fallback was settled beside a residual report),
192
+ * adopt failure-class over success/audit by the publish contract — not mtime.
193
+ * Same-class ties keep candidate enumeration order (conventional before
194
+ * fallbacks before unique).
195
+ *
196
+ * Unreadable faces never outrank a present face. Parent unique unreadable
197
+ * files never enter the shared enumerator (cannot prove run identity).
198
+ * Absence of every known durable face is a valid no-receipt state.
156
199
  */
157
200
  export async function readRunTerminalArtifact(runDirectory) {
158
201
  const artifactsDir = roleRunArtifactsDirectory(runDirectory);
202
+ const present = [];
203
+ const unreadable = [];
204
+ const consider = (read) => {
205
+ if (read === undefined || read.status === "absent")
206
+ return;
207
+ if (read.status === "present") {
208
+ present.push(read);
209
+ return;
210
+ }
211
+ unreadable.push(read);
212
+ };
159
213
  for (const file of RUN_TERMINAL_ARTIFACT_FILES) {
160
- const path = join(artifactsDir, file);
161
- const read = await readTerminalArtifactAtPath(path, file);
162
- if (read !== undefined)
163
- return read;
214
+ consider(await readTerminalArtifactAtPath(join(artifactsDir, file), file));
164
215
  }
165
- // Publisher settled a durable failure outside the conventional error.json name.
166
216
  for (const relative of RUN_TERMINAL_ERROR_FALLBACK_RELATIVE_PATHS) {
167
- const path = join(runDirectory, relative);
168
- const read = await readTerminalArtifactAtPath(path, "error.json");
169
- if (read !== undefined)
170
- return read;
217
+ consider(await readTerminalArtifactAtPath(join(runDirectory, relative), "error.json"));
171
218
  }
172
- // Same-run unique faces: path ownership is the run itself — no cross-run risk.
173
- for (const path of await listUniqueErrorFallbackPaths([artifactsDir, runDirectory])) {
174
- const read = await readTerminalArtifactAtPath(path, "error.json");
175
- if (read !== undefined)
176
- return read;
219
+ // Unique same-run + parent-bound faces: one enumerator shared with clear.
220
+ for (const path of await listSeamOwnedUniqueErrorFacePaths(runDirectory)) {
221
+ consider(await readTerminalArtifactAtPath(path, "error.json"));
177
222
  }
178
- // Shared parent (runs/) unique faces: require publisher runId binding.
179
- const expectedRunId = runIdFromRunDirectory(runDirectory);
180
- for (const path of await listUniqueErrorFallbackPaths([dirname(runDirectory)])) {
181
- const read = await readTerminalArtifactAtPath(path, "error.json");
182
- if (read === undefined)
183
- continue;
184
- if (read.status === "present") {
185
- if (!presentUniqueFallbackBoundToRun(read.body, expectedRunId))
186
- continue;
187
- return read;
188
- }
189
- // Unreadable parent unique file cannot prove run identity — do not adopt.
223
+ if (present.length > 0) {
224
+ present.sort((a, b) => failureClassRank(b.file) - failureClassRank(a.file));
225
+ return present[0];
226
+ }
227
+ if (unreadable.length > 0) {
228
+ return unreadable[0];
190
229
  }
191
230
  return { status: "absent" };
192
231
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akagilnc/pi-workflow-roles",
3
- "version": "0.1.4673",
3
+ "version": "0.1.4695",
4
4
  "description": "Soul-bound workflow roles for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -5,6 +5,7 @@ import type { HostContext } from "./host-contracts.ts";
5
5
  import type { NoReceiptLifecycleFacts } from "./receipt-delivery-policy.ts";
6
6
  import type { PublicSummonResult } from "./public-role-summons.ts";
7
7
  import { OFFICER_CONCLUSION_REASK } from "./gatekeeper-role.ts";
8
+ import { coalesceSubmissionRows } from "./public-cli/terminal.ts";
8
9
 
9
10
  export type ComplianceNoReceipt = NoReceiptLifecycleFacts & { status: "no-receipt"; usage?: Usage };
10
11
  /**
@@ -149,7 +150,9 @@ async function projectAuditorTerminal(summoned: PublicSummonResult): Promise<Com
149
150
  };
150
151
  }
151
152
  if (outcome.kind === "failure") {
152
- const rows = outcome.payloads ?? summoned.terminal?.submissions ?? [];
153
+ // #953: history stays on the historical carrier; do not read failure.payloads
154
+ // as if they were the current failure result (#836 A.3 beside-channel).
155
+ const rows = summoned.terminal?.submissions ?? [];
153
156
  return {
154
157
  status: "transport_failure",
155
158
  diagnostic: outcome.diagnostic,
@@ -159,7 +162,10 @@ async function projectAuditorTerminal(summoned: PublicSummonResult): Promise<Com
159
162
  };
160
163
  }
161
164
  if (outcome.kind === "accepted") {
162
- const rows = outcome.payloads ?? summoned.terminal?.submissions ?? [];
165
+ const rows = coalesceSubmissionRows(
166
+ outcome.payloads,
167
+ summoned.terminal?.submissions,
168
+ );
163
169
  if (rows.length === 0) {
164
170
  return readComplianceCandidate({}, usage);
165
171
  }
@@ -260,20 +260,20 @@ function projectOfficerDecision(
260
260
  */
261
261
  function thisCourtOfficerPayloads(terminal: TerminalResult | undefined): readonly unknown[] {
262
262
  const outcome = terminal?.roleOutcome;
263
+ // This-court identity only from accepted/audit_escalation settlement payloads
264
+ // (#879). Failure history is not this-court (#953) — it rides submissions.
263
265
  if (outcome !== undefined && (outcome.kind === "accepted" || outcome.kind === "audit_escalation")) {
264
266
  if (outcome.payloads !== undefined && outcome.payloads.length > 0) return outcome.payloads;
265
267
  }
266
- if (outcome?.kind === "failure" && outcome.payloads !== undefined && outcome.payloads.length > 0) {
267
- return outcome.payloads;
268
- }
269
268
  // No sole-row identity guess: undivided submissions are not this-court (#879).
270
269
  return [];
271
270
  }
272
271
 
273
- /** Failure/transport channel may still surface every recorded payload beside the failure (#836 A.3). */
272
+ /**
273
+ * Failure/transport channel surfaces recorded history beside the failure
274
+ * (#836 A.3) via the historical carrier only (#953 — not failure.payloads).
275
+ */
274
276
  function officerFailurePayloads(terminal: TerminalResult | undefined): readonly unknown[] {
275
- const outcome = terminal?.roleOutcome;
276
- if (outcome?.kind === "failure") return outcome.payloads ?? terminal?.submissions ?? [];
277
277
  return terminal?.submissions ?? [];
278
278
  }
279
279
 
@@ -31,6 +31,7 @@ import {
31
31
  } from "../package-resources/engine-material.ts";
32
32
  import { CliUsageError } from "./cli-errors.ts";
33
33
  import { projectCourtTicketNumbers } from "../diarist-contracts.ts";
34
+ import { readableGateItem } from "../readable-gate-item.ts";
34
35
  import { isSafePositiveTicketNumber } from "../run-ticket-number.ts";
35
36
  import {
36
37
  admitCountersignInvocation,
@@ -139,13 +140,74 @@ export type CourtDiaristIdentity =
139
140
  readonly courtTicketNumbers?: readonly number[];
140
141
  }
141
142
  | { readonly kind: "unbound" }
142
- | { readonly kind: "escalate" };
143
+ | {
144
+ readonly kind: "escalate";
145
+ /** Honest diagnostic from diarist escalate payload (#953) — never invent 认不出. */
146
+ readonly diagnostic: string;
147
+ };
143
148
 
144
149
  export type CourtDiaristInvocationResult = {
145
150
  readonly identity: CourtDiaristIdentity;
146
151
  readonly failedWithoutEscalate?: { readonly diagnostic: string };
147
152
  };
148
153
 
154
+ function isRecord(value: unknown): value is Record<string, unknown> {
155
+ return typeof value === "object" && value !== null && !Array.isArray(value);
156
+ }
157
+
158
+ function isEscalatePayload(payload: unknown): boolean {
159
+ if (!isRecord(payload)) return false;
160
+ return (
161
+ payload.status === "escalate" || payload.countersignStatus === "escalate"
162
+ );
163
+ }
164
+
165
+ /**
166
+ * Rows a parent may read from a nested diarist terminal (#836 / #953).
167
+ * Accepted/audit use role result payloads; failure history is submissions only.
168
+ */
169
+ function courtDiaristPayloadRows(
170
+ roleOutcome: TerminalRoleOutcome | undefined,
171
+ submissions: readonly unknown[] | undefined,
172
+ ): readonly unknown[] {
173
+ if (roleOutcome === undefined) return submissions ?? [];
174
+ if (roleOutcome.kind === "accepted" || roleOutcome.kind === "audit_escalation") {
175
+ return roleOutcome.payloads ?? [];
176
+ }
177
+ if (roleOutcome.kind === "failure") return submissions ?? [];
178
+ return [];
179
+ }
180
+
181
+ /**
182
+ * Parent diagnostic for court-diarist escalate (#953 / 失败诚实 / 传话).
183
+ * Relays diarist escalate payload(s) via readableGateItem — never invents
184
+ * "cannot identify court target" when a payload already carries other facts
185
+ * (e.g. ticketNumber present with a different reason).
186
+ *
187
+ * Multiple escalate payloads remain reachable after the run (ADR 0003/0041;
188
+ * no courtAttempt sole-filter on diarist). Same law as secretariat parent face:
189
+ * every escalate receipt as-is, last = currentConclusion.
190
+ */
191
+ function courtDiaristEscalateDiagnostic(
192
+ roleOutcome: TerminalRoleOutcome | undefined,
193
+ submissions?: readonly unknown[],
194
+ ): string {
195
+ const payloads = courtDiaristPayloadRows(roleOutcome, submissions);
196
+ const escalatePayloads: unknown[] = [];
197
+ for (const payload of payloads) {
198
+ if (isEscalatePayload(payload)) escalatePayloads.push(payload);
199
+ }
200
+ if (escalatePayloads.length === 0) return "court diarist station escalated";
201
+ // Single escalate: prior carrier (payload body only) — no shape churn.
202
+ if (escalatePayloads.length === 1) {
203
+ return `court diarist station escalated: ${readableGateItem(escalatePayloads[0])}`;
204
+ }
205
+ return `court diarist station escalated: ${readableGateItem({
206
+ receipts: escalatePayloads,
207
+ currentConclusion: escalatePayloads[escalatePayloads.length - 1],
208
+ })}`;
209
+ }
210
+
149
211
  /** Env slice shared by court diarist identity summons (countersign / secretariat). */
150
212
  export type CourtDiaristSummonEnv = Pick<
151
213
  CountersignRunEnv,
@@ -171,14 +233,10 @@ export type CourtDiaristSummonEnv = Pick<
171
233
  function courtTicketNumbersFromOutcome(
172
234
  roleOutcome: TerminalRoleOutcome | undefined,
173
235
  principalTicket: number,
236
+ submissions?: readonly unknown[],
174
237
  ): readonly number[] | undefined {
175
238
  if (roleOutcome === undefined) return undefined;
176
- const payloads =
177
- roleOutcome.kind === "accepted" ||
178
- roleOutcome.kind === "audit_escalation" ||
179
- roleOutcome.kind === "failure"
180
- ? (roleOutcome.payloads ?? [])
181
- : [];
239
+ const payloads = courtDiaristPayloadRows(roleOutcome, submissions);
182
240
  let latest: readonly number[] | undefined;
183
241
  for (const payload of payloads) {
184
242
  if (
@@ -216,18 +274,7 @@ function courtDiaristEscalated(
216
274
  if (roleOutcome === undefined) return false;
217
275
  if (roleOutcome.kind === "audit_escalation") return true;
218
276
  if (roleOutcome.kind !== "accepted") return false;
219
- return (roleOutcome.payloads ?? []).some((payload) => {
220
- if (
221
- typeof payload !== "object" ||
222
- payload === null ||
223
- Array.isArray(payload)
224
- )
225
- return false;
226
- const record = payload as Record<string, unknown>;
227
- return (
228
- record.status === "escalate" || record.countersignStatus === "escalate"
229
- );
230
- });
277
+ return (roleOutcome.payloads ?? []).some(isEscalatePayload);
231
278
  }
232
279
 
233
280
  /**
@@ -292,11 +339,15 @@ export async function invokeCourtDiarist(
292
339
  });
293
340
 
294
341
  const roleOutcome = result.terminal?.roleOutcome;
342
+ const submissions = result.terminal?.submissions;
295
343
  // Escalate routing is a boolean over the preserved sequence (#881). Reasons and
296
344
  // payload bodies stay on roleOutcome — never rewritten into a sole identity reason.
297
345
  if (courtDiaristEscalated(roleOutcome)) {
298
346
  return {
299
- identity: { kind: "escalate" },
347
+ identity: {
348
+ kind: "escalate",
349
+ diagnostic: courtDiaristEscalateDiagnostic(roleOutcome, submissions),
350
+ },
300
351
  };
301
352
  }
302
353
 
@@ -319,6 +370,7 @@ export async function invokeCourtDiarist(
319
370
  const courtTicketNumbers = courtTicketNumbersFromOutcome(
320
371
  roleOutcome,
321
372
  asserted,
373
+ submissions,
322
374
  );
323
375
  return {
324
376
  identity: {
@@ -390,9 +442,7 @@ export async function runCountersignCourtDiaristStation(
390
442
  );
391
443
 
392
444
  if (outcome.identity.kind === "escalate") {
393
- throw new StationChildExhaustedError(
394
- "court diarist station escalated (cannot identify court target)",
395
- );
445
+ throw new StationChildExhaustedError(outcome.identity.diagnostic);
396
446
  }
397
447
  if (outcome.failedWithoutEscalate !== undefined) {
398
448
  throw new StationChildExhaustedError(
@@ -509,6 +559,7 @@ export async function runPublicCountersign(
509
559
 
510
560
  if (outcome.identity.kind === "escalate") {
511
561
  // 御批: 识别不了就上抛 — materialize and settle this countersign run.
562
+ // Diagnostic relays diarist escalate payload facts (#953).
512
563
  await materializeAdmission();
513
564
  await markRunAdmitted(admitted, env.principalAuthority);
514
565
  return await presentControlledFailure(
@@ -517,9 +568,7 @@ export async function runPublicCountersign(
517
568
  timedOut: false,
518
569
  code: null,
519
570
  stderr: "",
520
- thrown: new Error(
521
- "court diarist station escalated (cannot identify court target)",
522
- ),
571
+ thrown: new Error(outcome.identity.diagnostic),
523
572
  },
524
573
  countersignAdapters(),
525
574
  env.principalAuthority,
@@ -139,15 +139,14 @@ export async function runPublicSecretariat(
139
139
  );
140
140
 
141
141
  if (outcome.identity.kind === "escalate") {
142
+ // Diagnostic relays diarist escalate payload facts (#953) — never invent 认不出.
142
143
  return await presentControlledFailure(
143
144
  admitted,
144
145
  {
145
146
  timedOut: false,
146
147
  code: null,
147
148
  stderr: "",
148
- thrown: new Error(
149
- "court diarist station escalated (cannot identify court target)",
150
- ),
149
+ thrown: new Error(outcome.identity.diagnostic),
151
150
  },
152
151
  secretariatAdapters(),
153
152
  env.principalAuthority,