@akagilnc/pi-workflow-roles 0.1.5614 → 0.1.5650

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 (35) hide show
  1. package/dist/acp-host/production-host.js +960 -465
  2. package/dist/activation-ledger-topology.js +12 -0
  3. package/dist/external-host-turn-loop.js +281 -0
  4. package/dist/headless-host/production-host.js +900 -406
  5. package/dist/migrate-book-topology.js +8 -5
  6. package/dist/pi/role-turn-host.js +33 -3
  7. package/dist/public-cli/instruction-seat-run.js +177 -46
  8. package/dist/public-cli/invocation.js +5 -12
  9. package/dist/public-cli/main.js +541 -165
  10. package/dist/public-cli/post-admission.js +171 -18
  11. package/dist/public-cli/settlement.js +20 -10
  12. package/dist/report-ticket-tool.js +136 -0
  13. package/dist/role-run-placement.js +1 -2
  14. package/dist/role-run-relocation.js +24 -4
  15. package/package.json +1 -1
  16. package/resources/engine-dispatch.md +6 -3
  17. package/resources/engines/claude-code.md +4 -5
  18. package/resources/missing-ticket-reask.md +1 -0
  19. package/src/acp-host/role-turn-host.ts +41 -31
  20. package/src/activation-ledger-topology.ts +16 -0
  21. package/src/external-host-turn-loop.ts +103 -12
  22. package/src/headless-host/role-turn-host.ts +41 -25
  23. package/src/pi/role-turn-host.ts +37 -3
  24. package/src/public-cli/instruction-seat-run.ts +248 -50
  25. package/src/public-cli/invocation.ts +15 -27
  26. package/src/public-cli/post-admission.ts +250 -33
  27. package/src/public-cli/run-lifecycle.ts +5 -5
  28. package/src/public-cli/settlement.ts +38 -11
  29. package/src/public-cli/terminal.ts +6 -0
  30. package/src/report-ticket-tool.ts +176 -0
  31. package/src/role-envelope.ts +28 -3
  32. package/src/role-run-placement.ts +0 -2
  33. package/src/role-run-relocation.ts +45 -4
  34. package/src/role-runtime.ts +6 -0
  35. package/src/worker-submission-gates.ts +26 -3
@@ -6,17 +6,19 @@
6
6
  * Role runners supply only turn request projection and narrow settlement adapters.
7
7
  */
8
8
  import { randomUUID } from "node:crypto";
9
+ import { existsSync } from "node:fs";
9
10
  import { writeFile } from "node:fs/promises";
10
11
  import { buildAutoResumeContinuationPrompt, findRunDirectoryById, readRoleRunState, RESUME_TRANSPORT_ENVELOPE, } from "./run-lifecycle.js";
11
12
  import { CliUsageError } from "./cli-errors.js";
12
13
  import { projectPublicTurnAxes } from "./turn-request.js";
13
14
  import { appendCallerFileFlagPaths, bindAdmittedTicketNumber, relocateAdmittedRunToTicket, } from "./invocation.js";
14
15
  import { readRecordedSubmissionRows } from "../submission-ledger.js";
15
- import { parseTicketNumber, readDeclaredTicketNumber } from "../run-ticket-number.js";
16
+ import { parseTicketNumber, readBoardTicketNumber, readDeclaredTicketNumber } from "../run-ticket-number.js";
17
+ import { homeFromRunDirectory } from "../activation-ledger-topology.js";
16
18
  import { readStoredHostSessionId } from "../session-identity.js";
17
19
  import { reportRunRecord } from "../sitian-facade.js";
18
20
  import { createReceiptDeliveryPolicy, deliveryLimitFromConfig, } from "../receipt-delivery-policy.js";
19
- import { rewriteRunDirectoryPathValue } from "../role-run-relocation.js";
21
+ import { rewritePrincipalSessionPaths, rewriteRunDirectoryPathValue, rewriteAdmittedRoleRunPage } from "../role-run-relocation.js";
20
22
  import { isOfficerReviewSeat } from "../packaged-role-registry.js";
21
23
  /** Original error bytes, never relabeled — a secondary fact riding beside a classified cause. */
22
24
  function describeCaughtError(error) {
@@ -62,6 +64,89 @@ function projectRelocatedTurnIdentity(request, result, admitted, relocation) {
62
64
  }
63
65
  }
64
66
  }
67
+ /**
68
+ * #1171: after the host returns, re-locate the leg by run id (report-ticket may
69
+ * have moved it mid-turn in the tool process). Settlement and render use the
70
+ * live path; lease cleanup follows when the held path changed.
71
+ * Failure is not best-effort: a missing former path with no replacement must not
72
+ * settle or write back under the vanished unbound leaf (失败诚实宪法).
73
+ */
74
+ export async function refreshAdmittedPlacementAfterHostTurn(admitted, authority, heldLease) {
75
+ const home = homeFromRunDirectory(admitted.runDirectory);
76
+ const found = await findRunDirectoryById(home, admitted.runId, admitted.bookKey, admitted.role);
77
+ if (found === undefined && !existsSync(admitted.runDirectory)) {
78
+ throw new Error(`admitted run ${admitted.runId} missing after host turn; not found under ${home}`);
79
+ }
80
+ // Live identity first: later board-ticket read must not leave settlement on the
81
+ // pre-relocate path when facts are damaged (#1171 F4-R1).
82
+ let relocation;
83
+ if (found !== undefined && found !== admitted.runDirectory) {
84
+ const oldRunDirectory = admitted.runDirectory;
85
+ const coords = { ...authority.decode(admitted.principal) };
86
+ rewritePrincipalSessionPaths(coords, oldRunDirectory, found);
87
+ const principal = authority.seal(coords);
88
+ rewriteAdmittedRoleRunPage(admitted, [{ oldRunDirectory, newRunDirectory: found }]);
89
+ admitted.principal = principal;
90
+ heldLease?.relocate(found);
91
+ relocation = { oldRunDirectory, newRunDirectory: found };
92
+ }
93
+ // Board re-read discovers a ticket written mid-turn while still unbound
94
+ // (report-ticket board page). Already-bound identity needs no discovery —
95
+ // path relocate already used findRunDirectoryById. This is not damage
96
+ // recovery: when unbound, readBoardTicketNumber damage/non-ENOENT IO still
97
+ // propagates (失败诚实; notary: no damaged-control-plane → no-ticket wash).
98
+ if (admitted.ticketNumber === undefined) {
99
+ const boardTicket = await readBoardTicketNumber(found ?? admitted.runDirectory);
100
+ if (boardTicket !== undefined) {
101
+ admitted.ticketNumber = boardTicket;
102
+ }
103
+ }
104
+ return relocation;
105
+ }
106
+ /** Apply a successful post-host placement refresh onto the live turn request. */
107
+ function applyRefreshedPlacementToTurn(request, turnRequest, admitted, refreshed) {
108
+ projectRelocatedTurnIdentity(request, {}, admitted, refreshed);
109
+ return {
110
+ ...turnRequest,
111
+ runDirectory: admitted.runDirectory,
112
+ principal: admitted.principal,
113
+ };
114
+ }
115
+ /**
116
+ * #1171: host throw after a started turn — one live placement refresh, then the
117
+ * one settlement authority (ADR 0080). Shared by first turn and 催交; never
118
+ * settle under a vanished unbound leaf.
119
+ */
120
+ async function settleHostThrowAfterLivePlacementRefresh(input) {
121
+ let refreshError;
122
+ let turnRequest = input.turnRequest;
123
+ try {
124
+ const refreshed = await refreshAdmittedPlacementAfterHostTurn(input.admitted, input.env.principalAuthority, input.lease);
125
+ if (refreshed !== undefined) {
126
+ turnRequest = applyRefreshedPlacementToTurn(input.request, turnRequest, input.admitted, refreshed);
127
+ }
128
+ }
129
+ catch (err) {
130
+ refreshError = err;
131
+ }
132
+ const processCancelName = processCancelSignalName(input.env.signal);
133
+ // Secondary hostThrow only when the host actually threw and refresh also failed.
134
+ // Package-side refresh failure must not borrow that label (#1171 F10).
135
+ const hostThrowSecondary = refreshError !== undefined && input.hostTurnCompleted === false
136
+ ? { knownDetails: { hostThrow: describeCaughtError(input.hostError) } }
137
+ : {};
138
+ const settled = await settleAfterTurnStarted(input.admitted, withEngineDetourInvocationScope({
139
+ timedOut: false,
140
+ code: null,
141
+ stderr: "",
142
+ // Prefer the refresh failure when the live path is gone; otherwise the
143
+ // host throw remains the cause (失败诚实:不在旧 unbound 上结算写回).
144
+ ...(processCancelName === undefined ? {} : { cancelName: processCancelName }),
145
+ thrown: refreshError ?? input.hostError,
146
+ ...hostThrowSecondary,
147
+ }, input.request.invocationScopeId), input.adapters, input.env.principalAuthority, input.io, input.persistRunState, input.notePackageFault, input.courtAttemptId);
148
+ return { settled, turnRequest };
149
+ }
65
150
  /** #855: process-cancel settlement never re-enters auto-resume. */
66
151
  function withProcessCancelSkipAutoResume(result, signal) {
67
152
  if (processCancelSignalName(signal) === undefined) {
@@ -573,6 +658,8 @@ export async function dispatchPostAdmissionTurn(input) {
573
658
  if (afterDispatchApplied)
574
659
  return result;
575
660
  afterDispatchApplied = true;
661
+ // #1171: placement refresh runs once after each host return (below), before
662
+ // settlement — not again here. finishAfterTurn only binds/relocates sealed tickets.
576
663
  // #855: re-read cancel before committing the terminal and afterDispatch.
577
664
  // A signal after the history-first commit cannot retroactively erase it.
578
665
  // skipRunStateWrite: run-state
@@ -603,12 +690,31 @@ export async function dispatchPostAdmissionTurn(input) {
603
690
  pendingTerminal !== undefined &&
604
691
  result.terminal === pendingTerminal) {
605
692
  try {
693
+ // #1171 F2-R2 / F2-R3 (#419) / F2-R5: only the soft-reask turn itself
694
+ // preserves the sealed terminal face. ticketReasksSpent is budget across
695
+ // rounds — after audit continue, ordinary no-volume turns publish
696
+ // no_receipt honestly. previewOnly skips the terminal pointer;
697
+ // recordAttemptHistory still appends.
698
+ const softTicketReaskNoSeal = pendingSettlement !== "sealed" && env.softTicketReaskTurn === true;
699
+ const noSealScope = softTicketReaskNoSeal
700
+ ? {
701
+ ...courtScope,
702
+ previewOnly: true,
703
+ recordAttemptHistory: true,
704
+ }
705
+ : { ...courtScope, recordAttemptHistory: true };
606
706
  const terminal = pendingSettlement === "sealed"
607
707
  ? await adapters.trySettle(admitted, env.principalAuthority, { ...courtScope, recordAttemptHistory: true })
608
- : await attachRecordedSubmissions(admitted, await settleHostEndedNoReceipt(admitted, env.principalAuthority, { ...courtScope, recordAttemptHistory: true }, receiptDelivery.issuedDeliveryRequests()), courtScope);
708
+ : await attachRecordedSubmissions(admitted, await settleHostEndedNoReceipt(admitted, env.principalAuthority, noSealScope, receiptDelivery.issuedDeliveryRequests()), noSealScope);
609
709
  if (terminal === undefined)
610
710
  throw new Error("settled host attempt vanished before publication");
611
711
  result = { ...result, terminal, exitCode: exitCodeForTerminalOutcome(terminal.roleOutcome) };
712
+ // #1171 F2-R7: ownership evidence at this soft-reask turn's seal —
713
+ // mutate the live ownership cell carried on env (survives spreads).
714
+ if (pendingSettlement === "sealed" && env.softTicketReaskTurn === true
715
+ && env.softTicketReaskOwnership !== undefined) {
716
+ env.softTicketReaskOwnership.sealedSubstitute = true;
717
+ }
612
718
  if (pendingSettlement === "sealed" && terminal.roleOutcome.kind === "accepted"
613
719
  && request.courtAttemptId !== undefined && request.courtAttemptId.length > 0) {
614
720
  try {
@@ -719,6 +825,7 @@ export async function dispatchPostAdmissionTurn(input) {
719
825
  // (runPostAdmissionResumable / ManualResume / station-child), not here:
720
826
  // auto-resume re-enters this dispatch and must reuse the same scope.
721
827
  let result;
828
+ let hostTurnCompleted = false;
722
829
  try {
723
830
  // A bare `ak-role resume` only forwards the caller's instruction to the
724
831
  // host CLI's native resume. Nothing here may gate, redirect or reshape
@@ -726,19 +833,36 @@ export async function dispatchPostAdmissionTurn(input) {
726
833
  // the authoritative post-turn settlement reads whatever really happened.
727
834
  turnDispatched = true;
728
835
  result = await env.roleTurnHost.executeTurn(turnRequest);
836
+ hostTurnCompleted = true;
837
+ // #1171: one placement refresh before settlement, same try as executeTurn.
838
+ // Failure shares the catch below (refresh-then-settle + #855 cancel wrap).
839
+ const refreshed = await refreshAdmittedPlacementAfterHostTurn(admitted, env.principalAuthority, lease);
840
+ if (refreshed !== undefined) {
841
+ turnRequest = applyRefreshedPlacementToTurn(request, turnRequest, admitted, refreshed);
842
+ }
729
843
  }
730
844
  catch (error) {
731
- const processCancelName = processCancelSignalName(env.signal);
732
- const settled = await settleAfterTurnStarted(admitted, withEngineDetourInvocationScope({
733
- timedOut: false,
734
- code: null,
735
- stderr: "",
736
- // The thrown value is the host's report. A cancel received on this
737
- // call is a second fact; it must not become a new Error in its place.
738
- ...(processCancelName === undefined ? {} : { cancelName: processCancelName }),
739
- thrown: error,
740
- }, request.invocationScopeId), adapters, env.principalAuthority, io, persistRunState, courtScope.notePackageFault, courtScope.courtAttemptId);
741
- return await finishAfterTurn(withProcessCancelSkipAutoResume({ ...settled, turnDispatched: true, ...deferredPersist }, env.signal));
845
+ // #1171: one placement refresh before settlement — failure is a real cause,
846
+ // never catch-and-continue onto a vanished unbound leaf.
847
+ // Host throw and post-success refresh throw share this catch (#1171 F9).
848
+ const thrown = await settleHostThrowAfterLivePlacementRefresh({
849
+ admitted,
850
+ hostError: error,
851
+ hostTurnCompleted,
852
+ request,
853
+ turnRequest,
854
+ lease,
855
+ adapters,
856
+ env,
857
+ io,
858
+ persistRunState,
859
+ notePackageFault: courtScope.notePackageFault,
860
+ ...(courtScope.courtAttemptId === undefined
861
+ ? {}
862
+ : { courtAttemptId: courtScope.courtAttemptId }),
863
+ });
864
+ turnRequest = thrown.turnRequest;
865
+ return await finishAfterTurn(withProcessCancelSkipAutoResume({ ...thrown.settled, turnDispatched: true, ...deferredPersist }, env.signal));
742
866
  }
743
867
  // #1132: settle this turn through the ONE post-turn settlement authority,
744
868
  // then — if it simply produced no receipt — 催交 before settling no_receipt.
@@ -771,6 +895,7 @@ export async function dispatchPostAdmissionTurn(input) {
771
895
  // that never reached the host.
772
896
  let deliveryResult;
773
897
  let deliveryTurnRequest;
898
+ let deliveryHostTurnCompleted = false;
774
899
  try {
775
900
  // Host and the other axes already selected on this turn ride the
776
901
  // shared projection. The pre-projection request does not have them.
@@ -788,15 +913,43 @@ export async function dispatchPostAdmissionTurn(input) {
788
913
  };
789
914
  receiptDelivery.recordDeliveryRequest();
790
915
  deliveryResult = await env.roleTurnHost.executeTurn(deliveryTurnRequest);
916
+ deliveryHostTurnCompleted = true;
917
+ // Same single refresh-before-settle rule as the first turn (#1171):
918
+ // stay inside this try so cancel wrap is not dropped (#1171 F9 / #855).
919
+ const refreshed = await refreshAdmittedPlacementAfterHostTurn(admitted, env.principalAuthority, lease);
920
+ if (refreshed !== undefined) {
921
+ turnRequest = applyRefreshedPlacementToTurn(request, turnRequest, admitted, refreshed);
922
+ deliveryTurnRequest = {
923
+ ...deliveryTurnRequest,
924
+ runDirectory: admitted.runDirectory,
925
+ principal: admitted.principal,
926
+ };
927
+ }
791
928
  }
792
929
  catch (error) {
793
930
  // 催交 cannot substitute for host failure recovery: a real failure with
794
931
  // its true cause, through the one settlement authority (ADR 0080).
795
- // The turn started, so the caller's loop resumes instead of replaying
796
- // the initial payload. The issued count survives that re-entry.
797
- const settled = await settleAfterTurnStarted(admitted, withEngineDetourInvocationScope({ timedOut: false, code: null, stderr: "", thrown: error }, request.invocationScopeId), adapters, env.principalAuthority, io, persistRunState, courtScope.notePackageFault, courtScope.courtAttemptId);
932
+ // Same refresh-before-settle seam as the first turn (#1171 F4-R1).
933
+ // Host throw and post-success refresh throw share this catch (#1171 F9).
934
+ const thrown = await settleHostThrowAfterLivePlacementRefresh({
935
+ admitted,
936
+ hostError: error,
937
+ hostTurnCompleted: deliveryHostTurnCompleted,
938
+ request,
939
+ turnRequest,
940
+ lease,
941
+ adapters,
942
+ env,
943
+ io,
944
+ persistRunState,
945
+ notePackageFault: courtScope.notePackageFault,
946
+ ...(courtScope.courtAttemptId === undefined
947
+ ? {}
948
+ : { courtAttemptId: courtScope.courtAttemptId }),
949
+ });
950
+ turnRequest = thrown.turnRequest;
798
951
  return await finishAfterTurn(withProcessCancelSkipAutoResume({
799
- ...settled,
952
+ ...thrown.settled,
800
953
  turnDispatched: true,
801
954
  ...deferredPersist,
802
955
  }, env.signal));
@@ -116,16 +116,23 @@ function roleOutcomeFromRows(role, rows) {
116
116
  // keep that earlier receipt (#1057).
117
117
  const payloads = mine.map((row) => row.accepted);
118
118
  const latest = terminal[terminal.length - 1];
119
+ const submissionToolCallId = typeof latest.toolCallId === "string" && latest.toolCallId.length > 0
120
+ ? latest.toolCallId
121
+ : undefined;
122
+ const belonging = submissionToolCallId === undefined ? {} : { submissionToolCallId };
119
123
  if (latest.kind === "audit-escalation") {
120
124
  return {
121
- kind: "audit_escalation", role, status: "audit_escalation", payloads,
122
- ...(Object.hasOwn(latest, "auditReceipt") ? { decisiveFacts: {
123
- auditEscalationReceipt: latest.auditReceipt,
124
- ...(Object.hasOwn(latest, "auditOfficer") ? { auditEscalationOfficer: latest.auditOfficer } : {}),
125
- } } : {}),
125
+ outcome: {
126
+ kind: "audit_escalation", role, status: "audit_escalation", payloads,
127
+ ...(Object.hasOwn(latest, "auditReceipt") ? { decisiveFacts: {
128
+ auditEscalationReceipt: latest.auditReceipt,
129
+ ...(Object.hasOwn(latest, "auditOfficer") ? { auditEscalationOfficer: latest.auditOfficer } : {}),
130
+ } } : {}),
131
+ },
132
+ ...belonging,
126
133
  };
127
134
  }
128
- return { kind: "accepted", role, payloads };
135
+ return { outcome: { kind: "accepted", role, payloads }, ...belonging };
129
136
  }
130
137
  async function sealedLedgerOutcome(admitted, role, scope) {
131
138
  const home = sealedLedgerHome(admitted);
@@ -970,17 +977,17 @@ function entriesOf(read) {
970
977
  /** Sealed seats present the ledger. A session tool error does not invent a host failure. */
971
978
  async function settleSealedSeat(admitted, authority, scope, options) {
972
979
  const coordinates = coordinatesFromAdmitted(authority, admitted);
973
- const roleOutcome = await sealedLedgerOutcome(admitted, admitted.role, scope);
974
- if (roleOutcome === undefined || (options.acceptedOnly && roleOutcome.kind !== "accepted")) {
980
+ const settled = await sealedLedgerOutcome(admitted, admitted.role, scope);
981
+ if (settled === undefined || (options.acceptedOnly && settled.outcome.kind !== "accepted")) {
975
982
  return undefined;
976
983
  }
977
984
  const read = await readLawfulSettlementEntries(coordinates.sessionFile);
978
985
  if (read.kind === "fault") {
979
986
  await noteSettlementFault(admitted.runDirectory, scope, `session read failed beside lawful terminal: ${describeErrorIdentity(read.error)}`);
980
987
  }
981
- return finishLawfulSeat(admitted, coordinates, entriesOf(read), roleOutcome, scope);
988
+ return finishLawfulSeat(admitted, coordinates, entriesOf(read), settled.outcome, scope, settled.submissionToolCallId);
982
989
  }
983
- async function finishLawfulSeat(admitted, coordinates, entries, roleOutcome, scope) {
990
+ async function finishLawfulSeat(admitted, coordinates, entries, roleOutcome, scope, submissionToolCallId) {
984
991
  const artifacts = scope?.previewOnly === true
985
992
  ? []
986
993
  : await publishDeclaredSeatTerminal(admitted, roleOutcome, coordinates, entries, scope);
@@ -989,6 +996,9 @@ async function finishLawfulSeat(admitted, coordinates, entries, roleOutcome, sco
989
996
  navigator: extractNavigatorFact(entries),
990
997
  artifacts,
991
998
  runId: admitted.runId,
999
+ ...(submissionToolCallId === undefined || submissionToolCallId.length === 0
1000
+ ? {}
1001
+ : { submissionToolCallId }),
992
1002
  }, coordinates.sessionDirectory, detourUsageContext(admitted, scope));
993
1003
  return attachRecordedSubmissions(admitted, terminal, scope);
994
1004
  }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Mid-turn ticket report (#1171): roles call this as soon as they know the
3
+ * ticket number; the executing process binds and relocates immediately.
4
+ * When to call lives in this tool's description — not Soul.
5
+ */
6
+ import { Type } from "typebox";
7
+ import { existsSync } from "node:fs";
8
+ import { runDirectoryFromHostContext } from "./host-contracts.js";
9
+ import { piDurablePrincipalAuthority } from "./pi/durable-principal.js";
10
+ import { bindAdmittedTicketNumber, relocateAdmittedRunToTicket, } from "./public-cli/invocation.js";
11
+ import { isUnboundRunDirectory, sessionDirectoryOf, sessionFileOf } from "./role-run-placement.js";
12
+ import { readPageSync } from "./run-dossier.js";
13
+ import { readDeclaredTicketNumber } from "./run-ticket-number.js";
14
+ import { isRecord } from "./unknown-value.js";
15
+ export const REPORT_TICKET_TOOL_NAME = "ak_report_ticket";
16
+ const reportTicketArgsSchema = Type.Object({
17
+ ticketNumber: Type.Unknown({
18
+ description: "本票号(正整数、数字串或前导 #N)。已知票号时开工即报;尚无票号时不要调用。",
19
+ }),
20
+ }, { additionalProperties: true });
21
+ export const REPORT_TICKET_TOOL_DESCRIPTION = "向包申报本票号。已知票号时开工第一件事调用;包当场把本腿归到票号目录。"
22
+ + "尚无票号(例如中书省建票前)不要调用,取得后再报。"
23
+ + "交卷时若仍未报过且交卷亦未带票号,包会经既有重交通道再问一次。";
24
+ function pageString(page, key) {
25
+ const value = page?.[key];
26
+ return typeof value === "string" && value.trim() !== "" ? value : undefined;
27
+ }
28
+ /**
29
+ * Build the relocate seam's admitted face from durable pages + live principal.
30
+ * The executing process owns this snapshot for the bind/relocate call only.
31
+ */
32
+ export function admittedForLiveTicketReport(runDirectory, authority) {
33
+ const admittedPage = readPageSync(runDirectory, "admitted") ?? {};
34
+ const invocationPage = readPageSync(runDirectory, "invocation") ?? {};
35
+ const role = pageString(admittedPage, "role") ?? pageString(invocationPage, "role");
36
+ const runId = pageString(admittedPage, "runId") ?? pageString(invocationPage, "runId");
37
+ const bookKey = pageString(admittedPage, "bookKey") ?? pageString(invocationPage, "bookKey");
38
+ const projectRoot = pageString(admittedPage, "projectRoot")
39
+ ?? pageString(invocationPage, "projectRoot");
40
+ if (role === undefined || runId === undefined || bookKey === undefined || projectRoot === undefined) {
41
+ throw new Error("ak_report_ticket requires durable role/runId/bookKey/projectRoot on the run");
42
+ }
43
+ const attachments = Array.isArray(admittedPage.attachments)
44
+ ? admittedPage.attachments
45
+ : [];
46
+ const sessionDirectory = sessionDirectoryOf(runDirectory);
47
+ const sessionFile = sessionFileOf(runDirectory);
48
+ const principal = authority.seal({ sessionDirectory, sessionFile });
49
+ const ticketNumber = readDeclaredTicketNumber(admittedPage.ticketNumber)
50
+ ?? readDeclaredTicketNumber(invocationPage.ticketNumber);
51
+ return {
52
+ role,
53
+ runId,
54
+ bookKey,
55
+ projectRoot,
56
+ instruction: typeof admittedPage.instruction === "string" ? admittedPage.instruction : "",
57
+ instructionEmpty: admittedPage.instructionEmpty === true
58
+ || (typeof admittedPage.instruction === "string" && admittedPage.instruction.trim() === ""),
59
+ attachments,
60
+ runDirectory,
61
+ principal,
62
+ ...(ticketNumber === undefined ? {} : { ticketNumber }),
63
+ };
64
+ }
65
+ /**
66
+ * Bind + relocate under the existing post-admission seams, then update the
67
+ * executing process's live path handles (HostContext + optional env + setSessionFile).
68
+ * Pi: native SessionManager.setSessionFile so later appends stay on the moved file.
69
+ */
70
+ export async function reportTicketFromHostContext(context, rawTicketNumber, authority = piDurablePrincipalAuthority) {
71
+ const ticketNumber = readDeclaredTicketNumber(rawTicketNumber);
72
+ if (ticketNumber === undefined) {
73
+ throw new Error("ak_report_ticket requires an identifiable ticketNumber");
74
+ }
75
+ const runDirectory = runDirectoryFromHostContext(context);
76
+ if (runDirectory === undefined) {
77
+ throw new Error("ak_report_ticket requires a per-turn run directory");
78
+ }
79
+ if (!existsSync(runDirectory)) {
80
+ throw new Error(`ak_report_ticket run directory missing: ${runDirectory}`);
81
+ }
82
+ const admitted = admittedForLiveTicketReport(runDirectory, authority);
83
+ const alreadyPlaced = !isUnboundRunDirectory(runDirectory);
84
+ // #1171 B2 / #1025: already under a ticket leaf — do not rewrite durable identity.
85
+ // Placement stays put; no heterogenous-ticket refuse policy (#1171 judge).
86
+ if (!alreadyPlaced) {
87
+ await bindAdmittedTicketNumber(admitted, ticketNumber);
88
+ }
89
+ // Live handles belong with rename ownership — before derived renderCurrentSync
90
+ // (#1171 F4-R2). Reuse relocate's heldLease.relocate seam; keep raw I/O throw.
91
+ const applyLiveHandles = (nextDirectory) => {
92
+ context.runDirectory = nextDirectory;
93
+ // Same-leg spawn env only (#1171 F1): never overwrite another leg's ambient
94
+ // AK_ROLE_RUN_DIR in a shared process. HostContext is the per-turn authority
95
+ // (host-contracts); Pi dedicated children still refresh when env === old path.
96
+ if (process.env.AK_ROLE_RUN_DIR === runDirectory) {
97
+ process.env.AK_ROLE_RUN_DIR = nextDirectory;
98
+ }
99
+ // Native host rebind only — SessionManager has no setSessionDir; appends follow sessionFile.
100
+ context.sessionManager.setSessionFile?.(sessionFileOf(nextDirectory));
101
+ };
102
+ const relocation = alreadyPlaced
103
+ ? undefined
104
+ : await relocateAdmittedRunToTicket(admitted, authority, { relocate: applyLiveHandles });
105
+ const nextDirectory = relocation?.newRunDirectory ?? admitted.runDirectory;
106
+ if (relocation === undefined)
107
+ applyLiveHandles(nextDirectory);
108
+ return {
109
+ ticketNumber: alreadyPlaced
110
+ ? (admitted.ticketNumber ?? ticketNumber)
111
+ : ticketNumber,
112
+ relocated: relocation !== undefined,
113
+ };
114
+ }
115
+ /** Register the report-ticket tool on the shared host tool mouth (same as submission). */
116
+ export function registerReportTicketTool(roleHost) {
117
+ if (roleHost.getAllTools().some((tool) => tool.name === REPORT_TICKET_TOOL_NAME))
118
+ return;
119
+ roleHost.registerTool({
120
+ name: REPORT_TICKET_TOOL_NAME,
121
+ label: "报票号",
122
+ description: REPORT_TICKET_TOOL_DESCRIPTION,
123
+ promptSnippet: "已知票号时立即申报,包当场归位",
124
+ parameters: reportTicketArgsSchema,
125
+ async execute(_toolCallId, parameters, _signal, _onUpdate, ctx) {
126
+ const raw = isRecord(parameters) ? parameters.ticketNumber : undefined;
127
+ const details = await reportTicketFromHostContext(ctx, raw);
128
+ return {
129
+ content: [],
130
+ details,
131
+ };
132
+ },
133
+ });
134
+ }
135
+ /** Soft re-ask when a sealed submission still leaves the leg unbound (#1171 / ADR 0073). */
136
+ export const MISSING_TICKET_REASK_MATERIAL = "resources/missing-ticket-reask.md";
@@ -97,8 +97,7 @@ function roleRunPlacement(ledgerHome, input) {
97
97
  return {
98
98
  runDirectory,
99
99
  sessionDirectory,
100
- sessionFile: sessionFileIn(sessionDirectory),
101
- attachmentsDirectory: join(runDirectory, "attachments")
100
+ sessionFile: sessionFileIn(sessionDirectory)
102
101
  };
103
102
  }
104
103
  function isUnboundRunDirectory(runDirectory) {
@@ -3,7 +3,7 @@
3
3
  * Live callers project typed fields; only historical migrators walk durable pages.
4
4
  *
5
5
  * Scope is typed machine-consumed path fields only — never free text, never
6
- * frozen attachment/artifact bytes. Nested walk is confined to package-owned
6
+ * caller file-flag path strings. Nested walk is confined to package-owned
7
7
  * `session/` seams.
8
8
  */
9
9
  import { readdir, readFile, writeFile } from "node:fs/promises";
@@ -36,6 +36,11 @@ const RUN_STATE_PAGE_FIELDS = [
36
36
  "sessionDirectory",
37
37
  "sessionFile",
38
38
  ];
39
+ /** Typed principal session coordinates rewritten when the owning run leaf moves. */
40
+ export const PRINCIPAL_SESSION_PATH_FIELDS = [
41
+ "sessionDirectory",
42
+ "sessionFile",
43
+ ];
39
44
  const SOURCE_RUN_LOCATOR_FIELDS = ["runDirectory"];
40
45
  const SUMMONS_PATH_FIELDS = ["sourceRunPath"];
41
46
  const OFFICER_POINTER_FIELDS = ["sessionFile", "runDirectory"];
@@ -115,6 +120,21 @@ export function rewriteRunDirectoryPathFields(record, fields, oldRunDirectory, n
115
120
  { oldRunDirectory, newRunDirectory },
116
121
  ]);
117
122
  }
123
+ /** Principal session paths only — shared live + durable projection after a run move. */
124
+ export function rewritePrincipalSessionPaths(principal, oldRunDirectory, newRunDirectory) {
125
+ rewriteRunDirectoryPathFields(principal, PRINCIPAL_SESSION_PATH_FIELDS, oldRunDirectory, newRunDirectory);
126
+ }
127
+ /**
128
+ * Live turn request: move runDirectory and rewrite principal session paths in place.
129
+ * Callers that lack authority.seal use this; admitted refresh seals after the same rewrite.
130
+ */
131
+ export function projectTurnRequestLiveRunDirectory(request, newRunDirectory) {
132
+ const old = request.runDirectory;
133
+ if (old === newRunDirectory)
134
+ return;
135
+ request.runDirectory = newRunDirectory;
136
+ rewritePrincipalSessionPaths(request.principal, old, newRunDirectory);
137
+ }
118
138
  /** In-place rewrite of selected fields against a rewrite set. */
119
139
  export function rewriteRunDirectoryPathFieldsAgainstRewrites(record, fields, rewrites) {
120
140
  for (const field of fields) {
@@ -163,7 +183,7 @@ export function rewriteAdmittedRoleRunPage(page, rewrites) {
163
183
  rewriteSourceRunLocator(page.sourceRun, rewrites);
164
184
  // #1165: admitted attachments are caller paths outside the run directory — do not rewrite.
165
185
  if (isRecord(page.principal)) {
166
- rewriteRunDirectoryPathFieldsAgainstRewrites(page.principal, ["sessionDirectory", "sessionFile"], rewrites);
186
+ rewriteRunDirectoryPathFieldsAgainstRewrites(page.principal, PRINCIPAL_SESSION_PATH_FIELDS, rewrites);
167
187
  }
168
188
  }
169
189
  /** Pre-#1161 `session/auditor-roles/<officer>.pointer.json` (migrator input only). */
@@ -301,7 +321,7 @@ async function rewriteSessionTranscriptBindings(path, rewrites) {
301
321
  /**
302
322
  * Package-owned nested seams under session/ only:
303
323
  * sitian records.jsonl,
304
- * session transcript typed parent bindings. Never walks attachments/.
324
+ * session transcript typed parent bindings. Never walks caller file paths.
305
325
  */
306
326
  async function rewriteNestedMachinePathPages(pagesDirectory, rewrites) {
307
327
  const sessionRoot = join(pagesDirectory, "session");
@@ -355,7 +375,7 @@ export async function rewriteRoleRunDurablePages(input) {
355
375
  runState: (page) => {
356
376
  rewriteRunDirectoryPathFieldsAgainstRewrites(page, RUN_STATE_PAGE_FIELDS, rewrites);
357
377
  if (isRecord(page.principal)) {
358
- rewriteRunDirectoryPathFieldsAgainstRewrites(page.principal, ["sessionDirectory", "sessionFile"], rewrites);
378
+ rewriteRunDirectoryPathFieldsAgainstRewrites(page.principal, PRINCIPAL_SESSION_PATH_FIELDS, rewrites);
359
379
  }
360
380
  // Open court summons: source-run locators only; caller attach paths stay (#1165).
361
381
  if (isRecord(page.currentCourt)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akagilnc/pi-workflow-roles",
3
- "version": "0.1.5614",
3
+ "version": "0.1.5650",
4
4
  "description": "Soul-bound workflow roles for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -10,11 +10,14 @@ Material is data for the model, not a code contract. Do not invent package flags
10
10
 
11
11
  ## What goes into the prompt
12
12
 
13
- The labor prompt carries **task + paths only**:
13
+ The labor prompt carries **task + caller paths only**:
14
14
 
15
15
  - the task itself: goal, constraints, required output shape;
16
- - paths the engine reads itself: worktree root, ticket/issue number, frozen
17
- attachment paths, run/dossier directory pointers (e.g. `AK_ROLE_RUN_DIR`).
16
+ - paths the caller already named (file-flag paths or paths written in the
17
+ dispatch text). The seat finds dossier records itself under the ticket
18
+ directory, or under `unbound/` when there is no ticket. Do not hand the
19
+ engine attachment copies, run-directory pointers, or `AK_ROLE_RUN_DIR`
20
+ (that variable is for the package subprocess only — not a pointer to pass on).
18
21
 
19
22
  **Never paste material bodies into argv or the prompt** — no review bundles,
20
23
  no distilled-evidence dumps, no receipt JSON, no full briefs copied out of the
@@ -39,11 +39,10 @@ returned body.
39
39
 
40
40
  `--dangerously-skip-permissions` is required in headless labor: the CLI's
41
41
  permission prompts cannot be answered without a TTY and are auto-denied. In
42
- particular, reading any path outside the project root — such as frozen
43
- attachments under `~/.ak-roles/books/<book>/runs/<run>/attachments/` — is
44
- refused without the flag ("The read was not permitted — I don't have access to
45
- that file outside the current worktree") and succeeds with it (host-verified
46
- 2026-08-28).
42
+ particular, reading any path outside the project root — such as a caller-given
43
+ absolute path under `~/.ak-roles/` — is refused without the flag ("The read was
44
+ not permitted — I don't have access to that file outside the current worktree")
45
+ and succeeds with it (host-verified 2026-08-28).
47
46
 
48
47
  Prefer `claude --help` on the host over any remembered flag set. Do not wrap
49
48
  this engine behind `ak-role` flags.
@@ -0,0 +1 @@
1
+ 本腿仍无票号。请调用 ak_report_ticket 申报,或在交卷里带上 ticketNumber。只问这一次。