@akagilnc/pi-workflow-roles 0.1.1857 → 0.1.1876

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.
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { existsSync, writeFileSync } from "node:fs";
2
3
  import { dirname, resolve, join } from "node:path";
3
4
  import { SessionManager } from "@earendil-works/pi-coding-agent";
4
5
  import { resolveBookKeyFromGit } from "./activation-ledger-git.js";
@@ -12,31 +13,54 @@ import {
12
13
  resolveActivationLedgerHome
13
14
  } from "./activation-ledger-topology.js";
14
15
  const { findMostRecentSession } = await import(new URL("./core/session-manager.js", import.meta.resolve("@earendil-works/pi-coding-agent")).href);
16
+ const WORKER_SUBMISSION_GATE_KIND = "worker-submission-gate";
15
17
  function createRecordSession(options) {
16
18
  const cwd = options.cwd;
17
19
  const parentFile = options.parent?.getSessionFile();
18
20
  const ledgerHome = resolveActivationLedgerHome();
21
+ let sessionDir;
22
+ let parentSession;
19
23
  if (options.subject !== void 0) {
20
24
  const digest = createHash("sha256").update(options.subject).digest("hex").slice(0, 32);
21
- const sessionDir2 = join(
25
+ sessionDir = join(
22
26
  activationBookDirectory(ledgerHome, resolveBookKeyFromGit(cwd)),
23
27
  options.kind,
24
28
  digest
25
29
  );
26
- ensureRealDirectoryTree(ledgerHome, sessionDir2);
27
- const recentFile = findMostRecentSession(sessionDir2, cwd);
28
- if (recentFile !== null) return SessionManager.open(recentFile, sessionDir2, cwd);
29
- return SessionManager.create(cwd, sessionDir2, parentFile ? { parentSession: parentFile } : void 0);
30
- }
31
- if (parentFile === void 0 || parentFile.length === 0) {
30
+ parentSession = parentFile && parentFile.length > 0 ? parentFile : void 0;
31
+ } else if (parentFile === void 0 || parentFile.length === 0) {
32
32
  return SessionManager.inMemory(cwd);
33
+ } else {
34
+ const parentResolved = resolve(parentFile);
35
+ sessionDir = physicallyContainedIn(ledgerHome, parentResolved) ? join(dirname(parentResolved), options.kind) : join(activationBookDirectory(ledgerHome, resolveBookKeyFromGit(cwd)), options.kind);
36
+ parentSession = parentFile;
33
37
  }
34
- const parentResolved = resolve(parentFile);
35
- const sessionDir = physicallyContainedIn(ledgerHome, parentResolved) ? join(dirname(parentResolved), options.kind) : join(activationBookDirectory(ledgerHome, resolveBookKeyFromGit(cwd)), options.kind);
36
38
  ensureRealDirectoryTree(ledgerHome, sessionDir);
37
- return SessionManager.create(cwd, sessionDir, { parentSession: parentFile });
39
+ const mayResumeSameNest = options.subject !== void 0 || options.kind === WORKER_SUBMISSION_GATE_KIND;
40
+ if (mayResumeSameNest) {
41
+ const recentFile = findMostRecentSession(sessionDir, cwd);
42
+ if (recentFile !== null) return SessionManager.open(recentFile, sessionDir, cwd);
43
+ }
44
+ const session = SessionManager.create(
45
+ cwd,
46
+ sessionDir,
47
+ parentSession === void 0 ? void 0 : { parentSession }
48
+ );
49
+ if (session.isPersisted()) {
50
+ const file = session.getSessionFile();
51
+ if (file !== void 0 && !existsSync(file)) {
52
+ const header = session.getHeader();
53
+ if (header !== null && header.type === "session") {
54
+ writeFileSync(file, `${JSON.stringify(header)}
55
+ `, { flag: "wx" });
56
+ session.setSessionFile(file);
57
+ }
58
+ }
59
+ }
60
+ return session;
38
61
  }
39
62
  export {
63
+ WORKER_SUBMISSION_GATE_KIND,
40
64
  createRecordSession,
41
65
  roleRunSessionCoordinates
42
66
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akagilnc/pi-workflow-roles",
3
- "version": "0.1.1857",
3
+ "version": "0.1.1876",
4
4
  "description": "Soul-bound workflow roles for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -673,6 +673,7 @@ export function createRoleRuntimeExtension(
673
673
  return dependencies.loadFixPacket(path);
674
674
  },
675
675
  },
676
+ hostActions,
676
677
  );
677
678
  const coder = createCoderRoleRuntime(
678
679
  pi,
@@ -4,6 +4,7 @@
4
4
  * 「谁调了谁」复用 Pi parentSession + ADR 0047 correlation,不新增 caller 字段。
5
5
  */
6
6
  import { createHash } from "node:crypto";
7
+ import { existsSync, writeFileSync } from "node:fs";
7
8
  import { dirname, resolve, join } from "node:path";
8
9
  import { SessionManager } from "@earendil-works/pi-coding-agent";
9
10
 
@@ -42,48 +43,79 @@ export type CreateRecordSessionOptions = {
42
43
  readonly subject?: string;
43
44
  };
44
45
 
46
+ /** Authorized no-subject kind that may resume the most recent same-nest peer (ADR 0066). Sole string true source for gate resume identity. */
47
+ export const WORKER_SUBMISSION_GATE_KIND = "worker-submission-gate";
48
+
45
49
  /**
46
50
  * Sole package entry that constructs a durable Pi session record (ADR 0065).
47
51
  * No destination/path parameters — location is computed from ledger topology only.
52
+ * Resume via Pi findMostRecentSession is limited to subject-keyed identity and the
53
+ * authorized worker-submission-gate durable path. Ordinary no-subject children
54
+ * (evidence-children, auditor-roles, …) always mint a fresh session — never reopen
55
+ * a sibling volume selected only by kind/cwd/mtime.
56
+ * New persisted principals materialize their deferred session header before return so
57
+ * custom-entry-only writers do not need a parallel delayed-header helper.
48
58
  */
49
59
  export function createRecordSession(options: CreateRecordSessionOptions): SessionManager {
50
60
  const cwd = options.cwd;
51
61
  const parentFile = options.parent?.getSessionFile();
52
62
  const ledgerHome = resolveActivationLedgerHome();
53
63
 
64
+ let sessionDir: string;
65
+ let parentSession: string | undefined;
66
+
54
67
  if (options.subject !== undefined) {
55
68
  const digest = createHash("sha256").update(options.subject).digest("hex").slice(0, 32);
56
- const sessionDir = join(
69
+ sessionDir = join(
57
70
  activationBookDirectory(ledgerHome, resolveBookKeyFromGit(cwd)),
58
71
  options.kind,
59
72
  digest,
60
73
  );
61
- ensureRealDirectoryTree(ledgerHome, sessionDir);
62
- // Delegate discovery to Pi so custom-directory continuation retains its
63
- // mtime ordering, valid-header scan, and cwd filtering semantics.
64
- const recentFile = findMostRecentSession(sessionDir, cwd);
65
- if (recentFile !== null) return SessionManager.open(recentFile, sessionDir, cwd);
66
- return SessionManager.create(cwd, sessionDir, parentFile
67
- ? { parentSession: parentFile }
68
- : undefined);
69
- }
70
-
71
- if (parentFile === undefined || parentFile.length === 0) {
74
+ parentSession = parentFile && parentFile.length > 0 ? parentFile : undefined;
75
+ } else if (parentFile === undefined || parentFile.length === 0) {
72
76
  // No durable parent principal — preserve prior in-memory child behavior.
73
77
  return SessionManager.inMemory(cwd);
78
+ } else {
79
+ const parentResolved = resolve(parentFile);
80
+ // Nest under parent only when the parent record already lives under the package home.
81
+ // Nest base is dirname(parent file) — the durable principal's directory — never a
82
+ // separate getSessionDir() that can diverge (empty in-memory dir + durable file).
83
+ // Otherwise the book is resolved from cwd (ADR 0048) and the kind sits under that book —
84
+ // workspace / foreign parents cannot drag records out of home.
85
+ sessionDir = physicallyContainedIn(ledgerHome, parentResolved)
86
+ ? join(dirname(parentResolved), options.kind)
87
+ : join(activationBookDirectory(ledgerHome, resolveBookKeyFromGit(cwd)), options.kind);
88
+ parentSession = parentFile;
74
89
  }
75
90
 
76
- const parentResolved = resolve(parentFile);
77
-
78
- // Nest under parent only when the parent record already lives under the package home.
79
- // Nest base is dirname(parent file) — the durable principal's directory — never a
80
- // separate getSessionDir() that can diverge (empty in-memory dir + durable file).
81
- // Otherwise the book is resolved from cwd (ADR 0048) and the kind sits under that book —
82
- // workspace / foreign parents cannot drag records out of home.
83
- const sessionDir = physicallyContainedIn(ledgerHome, parentResolved)
84
- ? join(dirname(parentResolved), options.kind)
85
- : join(activationBookDirectory(ledgerHome, resolveBookKeyFromGit(cwd)), options.kind);
86
-
87
91
  ensureRealDirectoryTree(ledgerHome, sessionDir);
88
- return SessionManager.create(cwd, sessionDir, { parentSession: parentFile });
92
+ // Subject-keyed nests continue by subject digest; gate durable resume is the only
93
+ // authorized no-subject same-nest continuation. All other kinds mint fresh.
94
+ const mayResumeSameNest =
95
+ options.subject !== undefined || options.kind === WORKER_SUBMISSION_GATE_KIND;
96
+ if (mayResumeSameNest) {
97
+ const recentFile = findMostRecentSession(sessionDir, cwd);
98
+ if (recentFile !== null) return SessionManager.open(recentFile, sessionDir, cwd);
99
+ }
100
+
101
+ const session = SessionManager.create(
102
+ cwd,
103
+ sessionDir,
104
+ parentSession === undefined ? undefined : { parentSession },
105
+ );
106
+ // Pi defers session-file create until the first assistant message. Custom-entry-only
107
+ // records never get that turn, so the sole record entry materializes the in-memory
108
+ // header onto the UUIDv7 path before returning. Existing path → early return.
109
+ if (session.isPersisted()) {
110
+ const file = session.getSessionFile();
111
+ if (file !== undefined && !existsSync(file)) {
112
+ const header = session.getHeader();
113
+ if (header !== null && header.type === "session") {
114
+ writeFileSync(file, `${JSON.stringify(header)}\n`, { flag: "wx" });
115
+ // Rebind so subsequent appendCustomEntry uses O_APPEND (flushed=true).
116
+ session.setSessionFile(file);
117
+ }
118
+ }
119
+ }
120
+ return session;
89
121
  }
@@ -26,7 +26,11 @@ import {
26
26
  parseFixerPrerequisites,
27
27
  type FixerInvocationInput,
28
28
  } from "./package-contracts/fixer-packet.ts";
29
- import { createWorkerSubmissionGate } from "./worker-submission-gates.ts";
29
+ import {
30
+ createWorkerSubmissionGate,
31
+ WorkerCommitReminderError,
32
+ WorkerUnfinishedReasonReminderError,
33
+ } from "./worker-submission-gates.ts";
30
34
 
31
35
  export {
32
36
  CODER_OUTPUT_TOOL_NAME,
@@ -171,9 +175,32 @@ function requireSingletonSubmissionCall(
171
175
  }
172
176
  }
173
177
 
178
+ /** Reminder bounces stay typed rejects; IO/infrastructure keep identity via host failInfrastructure. */
179
+ function assertAcceptableThroughHost(
180
+ submissionGate: { assertAcceptable(status: string, details?: unknown): void },
181
+ status: string,
182
+ details: unknown,
183
+ hostActions: WorkerRoleHostActions,
184
+ ctx: ExtensionContext,
185
+ toolCallId: string,
186
+ ): void {
187
+ try {
188
+ submissionGate.assertAcceptable(status, details);
189
+ } catch (error) {
190
+ if (
191
+ error instanceof WorkerCommitReminderError ||
192
+ error instanceof WorkerUnfinishedReasonReminderError
193
+ ) {
194
+ throw error;
195
+ }
196
+ hostActions.failInfrastructure(error, ctx, toolCallId);
197
+ }
198
+ }
199
+
174
200
  export function createFixerRoleRuntime(
175
201
  pi: ExtensionAPI,
176
202
  dependencies: FixerRoleDependencies,
203
+ hostActions: WorkerRoleHostActions,
177
204
  ): WorkerRoleRuntime {
178
205
  let soul: string | undefined;
179
206
  let packet: FixerInvocationInput | undefined;
@@ -251,7 +278,14 @@ export function createFixerRoleRuntime(
251
278
  ctx,
252
279
  );
253
280
  const output = deepFreeze(validateFixerOutputForPacket(parameters, phase, packet));
254
- submissionGate.assertAcceptable(output.status, output);
281
+ assertAcceptableThroughHost(
282
+ submissionGate,
283
+ output.status,
284
+ output,
285
+ hostActions,
286
+ ctx,
287
+ toolCallId,
288
+ );
255
289
  return {
256
290
  content: [{ type: "text" as const, text: "Fixer report accepted" }],
257
291
  details: output,
@@ -383,7 +417,14 @@ export function createCoderRoleRuntime(
383
417
  "Coder completed requires the Matt tdd skill to be expanded through Pi /skill:tdd",
384
418
  );
385
419
  }
386
- submissionGate.assertAcceptable(output.status, output);
420
+ assertAcceptableThroughHost(
421
+ submissionGate,
422
+ output.status,
423
+ output,
424
+ hostActions,
425
+ ctx,
426
+ toolCallId,
427
+ );
387
428
  return {
388
429
  content: [{ type: "text" as const, text: "Coder report accepted" }],
389
430
  details: output,
@@ -1,17 +1,24 @@
1
1
  /** #242 worker gates ①②④. ① durability: ADR 0065/#216 createRecordSession only — no appendCustomEntry bypass / parallel ledger. */
2
2
  import { execFileSync } from "node:child_process";
3
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import {
4
+ chmodSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ readFileSync,
8
+ writeFileSync,
9
+ } from "node:fs";
4
10
  import { resolve } from "node:path";
5
11
  import { SessionManager } from "@earendil-works/pi-coding-agent";
6
12
 
7
13
  import {
8
14
  createRecordSession,
9
15
  type RecordSessionParent,
16
+ WORKER_SUBMISSION_GATE_KIND,
10
17
  } from "./sitian-record-entry.ts";
11
18
 
12
19
  export const WORKER_COMMIT_SUBJECT_PREFIX = "ak-roles:";
13
20
  /** Sitian kind for gate ① durable baseline / bounce records (single path segment, not a destination). */
14
- export const WORKER_SUBMISSION_GATE_RECORD_KIND = "worker-submission-gate";
21
+ export const WORKER_SUBMISSION_GATE_RECORD_KIND = WORKER_SUBMISSION_GATE_KIND;
15
22
  export const WORKER_COMMIT_BASELINE_ENTRY_TYPE = "commit-baseline";
16
23
  export const WORKER_COMMIT_REMINDER_BOUNCE_ENTRY_TYPE = "commit-reminder-bounce";
17
24
 
@@ -163,34 +170,15 @@ function isRecord(value: unknown): value is Record<string, unknown> {
163
170
  }
164
171
 
165
172
  /**
166
- * Pi defers session-file create until the first assistant message. Gate records are
167
- * custom-entry only (no LLM turn), so materialize the header first same shape as
168
- * activation-ledger-session materializeDeferredSessionFile — then custom entries append.
173
+ * Open gate record via sitian entry only resume/lifecycle live in createRecordSession.
174
+ * Gate consumes the returned session; no nest scan, unlink, or peer reopen.
169
175
  */
170
- function materializeSessionHeader(session: SessionManager): void {
171
- if (!session.isPersisted()) return;
172
- const file = session.getSessionFile();
173
- if (file === undefined || existsSync(file)) return;
174
- const header = session.getHeader();
175
- if (header === null || header.type !== "session") return;
176
- writeFileSync(file, `${JSON.stringify(header)}\n`, { flag: "wx" });
177
- // Rebind so subsequent appendCustomEntry uses O_APPEND (flushed=true).
178
- session.setSessionFile(file);
179
- }
180
-
181
- /** Open gate ① record via sitian entry; resume continues the nest the entry resolved (no self-computed destination). */
182
176
  function openGateRecord(cwd: string, parent?: WorkerSubmissionGateParent): SessionManager {
183
- const discovered = createRecordSession({
177
+ return createRecordSession({
184
178
  cwd,
185
179
  kind: WORKER_SUBMISSION_GATE_RECORD_KIND,
186
180
  ...(parent === undefined ? {} : { parent }),
187
181
  });
188
- // No durable parent principal → in-memory only (same-process bounce still works).
189
- if (!discovered.isPersisted()) return discovered;
190
- // Destination came from createRecordSession; continueRecent only picks the latest file in that nest.
191
- const session = SessionManager.continueRecent(cwd, discovered.getSessionDir());
192
- materializeSessionHeader(session);
193
- return session;
194
182
  }
195
183
 
196
184
  function readGateState(session: SessionManager): {