@akagilnc/pi-workflow-roles 0.1.1941 → 0.1.2004

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akagilnc/pi-workflow-roles",
3
- "version": "0.1.1941",
3
+ "version": "0.1.2004",
4
4
  "description": "Soul-bound workflow roles for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Same-directory temp + rename atomic file replace.
3
+ * Shared primitive for ledger-adjacent typed pages (taishi metrics, etc.).
4
+ * Does not open/truncate an existing destination inode, so hard-linked twins
5
+ * keep prior bytes until the directory entry is swapped.
6
+ * Parent directory must already exist — callers that write under the package
7
+ * ledger home own confinement via ensureRealDirectoryTree (ADR 0038).
8
+ */
9
+ import { randomUUID } from "node:crypto";
10
+ import { rename, rm, writeFile } from "node:fs/promises";
11
+ import { dirname, join } from "node:path";
12
+
13
+ export async function writeFileAtomically(
14
+ destination: string,
15
+ contents: string | Uint8Array,
16
+ ): Promise<void> {
17
+ const parent = dirname(destination);
18
+ const temporary = join(parent, `.atomic-write-${randomUUID()}.tmp`);
19
+ try {
20
+ await writeFile(temporary, contents);
21
+ await rename(temporary, destination);
22
+ } catch (error) {
23
+ await rm(temporary, { force: true }).catch(() => undefined);
24
+ throw error;
25
+ }
26
+ }
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Canonical ledger session JSONL read primitives (shared owner).
3
+ * Consumers (ticket-trajectory, taishi, …) must import here — no second parse kernel.
4
+ */
5
+ import { readFile } from "node:fs/promises";
6
+
7
+ export type LedgerSessionRow = Record<string, unknown>;
8
+
9
+ /**
10
+ * Loud JSONL failure that still retains rows parsed before the bad line.
11
+ * Callers that only need the throw keep catching Error; owners that must
12
+ * surface partial typed facts (e.g. first-frame timestamp) read prefixRows.
13
+ */
14
+ export class LedgerSessionJsonlError extends Error {
15
+ readonly path: string;
16
+ readonly line: number;
17
+ readonly prefixRows: readonly LedgerSessionRow[];
18
+
19
+ constructor(
20
+ message: string,
21
+ init: {
22
+ readonly path: string;
23
+ readonly line: number;
24
+ readonly prefixRows: readonly LedgerSessionRow[];
25
+ },
26
+ ) {
27
+ super(message);
28
+ this.name = "LedgerSessionJsonlError";
29
+ this.path = init.path;
30
+ this.line = init.line;
31
+ this.prefixRows = init.prefixRows;
32
+ }
33
+ }
34
+
35
+ function isRecord(value: unknown): value is Record<string, unknown> {
36
+ return typeof value === "object" && value !== null && !Array.isArray(value);
37
+ }
38
+
39
+ /**
40
+ * Read session JSONL with honest live-tail semantics:
41
+ * a malformed line is tolerated only when it is an unfinished final
42
+ * fragment at EOF (no record terminator after it). Any malformed line
43
+ * completed by a line terminator must fail loudly with file and 1-based
44
+ * line context — even when no non-empty record follows — never silently
45
+ * under-count.
46
+ *
47
+ * Loud failures throw LedgerSessionJsonlError carrying prefixRows so the
48
+ * single parse kernel can still expose facts obtained before the bad line.
49
+ */
50
+ export async function readLedgerSessionJsonl(path: string): Promise<LedgerSessionRow[]> {
51
+ const text = await readFile(path, "utf8");
52
+ // split keeps a trailing empty segment iff text ends with "\n", so
53
+ // index < lines.length - 1 means this segment was terminated.
54
+ const lines = text.split("\n");
55
+ const rows: LedgerSessionRow[] = [];
56
+ for (let index = 0; index < lines.length; index += 1) {
57
+ const line = lines[index]!;
58
+ if (!line.trim()) continue;
59
+ let row: unknown;
60
+ try {
61
+ row = JSON.parse(line);
62
+ } catch (error) {
63
+ if (!(error instanceof SyntaxError)) throw error;
64
+ const completedByTerminator = index < lines.length - 1;
65
+ if (completedByTerminator) {
66
+ throw new LedgerSessionJsonlError(
67
+ `malformed JSONL record in ${path} at line ${index + 1}: ${error.message}`,
68
+ { path, line: index + 1, prefixRows: rows },
69
+ );
70
+ }
71
+ // unfinished fragment at EOF — keep prior complete rows
72
+ break;
73
+ }
74
+ // Syntactically complete line: must be a session object. Silent omission
75
+ // would under-count ledger evidence (failure honesty).
76
+ if (!isRecord(row)) {
77
+ const kind = row === null ? "null" : Array.isArray(row) ? "array" : typeof row;
78
+ throw new LedgerSessionJsonlError(
79
+ `complete non-object JSONL record in ${path} at line ${index + 1}: expected object, got ${kind}`,
80
+ { path, line: index + 1, prefixRows: rows },
81
+ );
82
+ }
83
+ rows.push(row);
84
+ }
85
+ return rows;
86
+ }
87
+
88
+ /** First and last record timestamps in encounter order. */
89
+ export function extractSessionTimestampSpan(
90
+ rows: readonly LedgerSessionRow[],
91
+ ): { startedAt?: string; endedAt?: string } {
92
+ let startedAt: string | undefined;
93
+ let endedAt: string | undefined;
94
+ for (const row of rows) {
95
+ if (typeof row.timestamp !== "string" || !row.timestamp) continue;
96
+ if (startedAt === undefined) startedAt = row.timestamp;
97
+ endedAt = row.timestamp;
98
+ }
99
+ return {
100
+ ...(startedAt !== undefined ? { startedAt } : {}),
101
+ ...(endedAt !== undefined ? { endedAt } : {}),
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Ordered-unique model ids from session frames (first-seen order).
107
+ * Sources (same faces ticket-trajectory already reads — single parse kernel):
108
+ * - `model_change.modelId`
109
+ * - assistant `message.model`
110
+ * Blank / non-string values are skipped. Does not invent a default model.
111
+ */
112
+ export function extractSessionModelSequence(
113
+ rows: readonly LedgerSessionRow[],
114
+ ): string[] {
115
+ const seen = new Set<string>();
116
+ const ordered: string[] = [];
117
+ const push = (raw: string): void => {
118
+ const model = raw.trim();
119
+ if (model === "" || seen.has(model)) return;
120
+ seen.add(model);
121
+ ordered.push(model);
122
+ };
123
+ for (const row of rows) {
124
+ if (row.type === "model_change" && typeof row.modelId === "string") {
125
+ push(row.modelId);
126
+ }
127
+ const message = isRecord(row.message) ? row.message : undefined;
128
+ if (message?.role === "assistant" && typeof message.model === "string") {
129
+ push(message.model);
130
+ }
131
+ }
132
+ return ordered;
133
+ }
134
+
135
+ /** First line of a bash `command` argument (sole owner of this summary). */
136
+ export function bashCommandFirstLine(command: string): string {
137
+ const match = /^[^\r\n]*/.exec(command);
138
+ return match?.[0] ?? "";
139
+ }
140
+
141
+ export type SessionToolInterval = {
142
+ readonly toolCallId: string;
143
+ readonly toolName: string;
144
+ readonly startedAt: string;
145
+ readonly endedAt?: string;
146
+ /**
147
+ * Bash-only first-line command summary from `arguments.command`.
148
+ * Omitted for non-bash tools and when the argument is absent/non-string.
149
+ * Full multi-line bodies are never retained on this typed fact face.
150
+ */
151
+ readonly command?: string;
152
+ };
153
+
154
+ /**
155
+ * Pair toolCall frames → toolResult frames by toolCallId.
156
+ * Throws when a tool-bearing frame is structurally unreadable for association
157
+ * (toolCall missing string id, toolResult missing string toolCallId).
158
+ * Unpaired open calls remain without endedAt — that is incomplete, not unreadable.
159
+ */
160
+ export function extractSessionToolIntervals(
161
+ rows: readonly LedgerSessionRow[],
162
+ ): SessionToolInterval[] {
163
+ type Open = {
164
+ toolCallId: string;
165
+ toolName: string;
166
+ startedAt: string;
167
+ endedAt?: string;
168
+ command?: string;
169
+ };
170
+ const order: Open[] = [];
171
+ const openById = new Map<string, Open>();
172
+
173
+ for (const row of rows) {
174
+ const rowTimestamp = typeof row.timestamp === "string" ? row.timestamp : undefined;
175
+ const message = isRecord(row.message) ? row.message : undefined;
176
+
177
+ if (message?.role === "assistant" && Array.isArray(message.content)) {
178
+ const callTimestamp =
179
+ typeof message.timestamp === "string" && message.timestamp
180
+ ? message.timestamp
181
+ : rowTimestamp;
182
+ for (const part of message.content) {
183
+ if (!isRecord(part) || part.type !== "toolCall") continue;
184
+ if (typeof part.id !== "string" || part.id.length === 0) {
185
+ throw new Error("toolCall frame missing string id");
186
+ }
187
+ if (typeof part.name !== "string" || part.name.length === 0) {
188
+ throw new Error(`toolCall ${part.id} missing string name`);
189
+ }
190
+ if (callTimestamp === undefined || callTimestamp.length === 0) {
191
+ throw new Error(`toolCall ${part.id} missing timestamp`);
192
+ }
193
+ if (openById.has(part.id)) {
194
+ throw new Error(`duplicate toolCall id ${part.id}`);
195
+ }
196
+ const args = isRecord(part.arguments) ? part.arguments : undefined;
197
+ // Ticket surface: only bash first-line summary is authorized here.
198
+ const command =
199
+ part.name === "bash" &&
200
+ args !== undefined &&
201
+ typeof args.command === "string"
202
+ ? bashCommandFirstLine(args.command)
203
+ : undefined;
204
+ const interval: Open = {
205
+ toolCallId: part.id,
206
+ toolName: part.name,
207
+ startedAt: callTimestamp,
208
+ ...(command !== undefined ? { command } : {}),
209
+ };
210
+ order.push(interval);
211
+ openById.set(part.id, interval);
212
+ }
213
+ }
214
+
215
+ if (message?.role === "toolResult") {
216
+ if (typeof message.toolCallId !== "string" || message.toolCallId.length === 0) {
217
+ throw new Error("toolResult frame missing string toolCallId");
218
+ }
219
+ const resultTimestamp =
220
+ typeof message.timestamp === "string" && message.timestamp
221
+ ? message.timestamp
222
+ : rowTimestamp;
223
+ if (resultTimestamp === undefined || resultTimestamp.length === 0) {
224
+ throw new Error(`toolResult ${message.toolCallId} missing timestamp`);
225
+ }
226
+ const open = openById.get(message.toolCallId);
227
+ if (open === undefined) {
228
+ // Result without a prior call is still associable as a closed interval
229
+ // once a name is known; keep structural readability without inventing a call.
230
+ const toolName =
231
+ typeof message.toolName === "string" && message.toolName.length > 0
232
+ ? message.toolName
233
+ : "unknown";
234
+ order.push({
235
+ toolCallId: message.toolCallId,
236
+ toolName,
237
+ startedAt: resultTimestamp,
238
+ endedAt: resultTimestamp,
239
+ });
240
+ continue;
241
+ }
242
+ if (open.endedAt !== undefined) {
243
+ throw new Error(`duplicate toolResult for toolCallId ${message.toolCallId}`);
244
+ }
245
+ open.endedAt = resultTimestamp;
246
+ }
247
+ }
248
+
249
+ return order.map((interval) => {
250
+ const base = {
251
+ toolCallId: interval.toolCallId,
252
+ toolName: interval.toolName,
253
+ startedAt: interval.startedAt,
254
+ ...(interval.command !== undefined ? { command: interval.command } : {}),
255
+ };
256
+ return interval.endedAt === undefined
257
+ ? base
258
+ : { ...base, endedAt: interval.endedAt };
259
+ });
260
+ }
@@ -32,6 +32,7 @@ import {
32
32
  parseJudgeArgv,
33
33
  parseMergerArgv,
34
34
  parseReviewerArgv,
35
+ parseTaishiArgv,
35
36
  } from "./invocation.ts";
36
37
  import { runPublicCoder, runPublicCoderResume } from "./coder-run.ts";
37
38
  import { runPublicCollector } from "./collector-run.ts";
@@ -40,6 +41,7 @@ import { runPublicFixer, runPublicFixerResume } from "./fixer-run.ts";
40
41
  import { runPublicJudge, runPublicResume } from "./judge-run.ts";
41
42
  import { runPublicMerger, runPublicMergerResume } from "./merger-run.ts";
42
43
  import { runPublicReviewer, runPublicReviewerResume } from "./reviewer-run.ts";
44
+ import { runPublicTaishi } from "./taishi-run.ts";
43
45
  import { peekRoleRunRole } from "./run-lifecycle.ts";
44
46
  import {
45
47
  INTERNAL_ROLE_ENTRYPOINT_RELATIVE,
@@ -73,6 +75,8 @@ export const PUBLIC_ROLE_ARGV = {
73
75
  doctor: { parse: parseDoctorArgv },
74
76
  merger: { parse: parseMergerArgv },
75
77
  reviewer: { parse: parseReviewerArgv },
78
+ /** Deterministic analysis seat (#336) — argv parse only; no LLM admission. */
79
+ taishi: { parse: parseTaishiArgv },
76
80
  } as const;
77
81
 
78
82
  type TakenPublicGlobalFlag =
@@ -314,6 +318,12 @@ function renderHelp(): string {
314
318
  lines.push(` ${cap.name} — ${phaseText}`);
315
319
  }
316
320
  }
321
+ lines.push("", "Deterministic commands:");
322
+ for (const cap of doc.capabilities) {
323
+ if (cap.kind === "deterministic") {
324
+ lines.push(` ${cap.name}`);
325
+ }
326
+ }
317
327
  lines.push(
318
328
  "",
319
329
  "Global options: --model provider/model --thinking level",
@@ -430,6 +440,8 @@ export async function runAkRole(
430
440
  }
431
441
  if (match.kind === "support") {
432
442
  io.stdout(`command\t${match.name}\tkind\tsupport\n`);
443
+ } else if (match.kind === "deterministic") {
444
+ io.stdout(`command\t${match.name}\tkind\tdeterministic\n`);
433
445
  } else {
434
446
  io.stdout(
435
447
  `command\t${match.name}\tkind\trole\tphases\t${match.phases
@@ -949,6 +961,18 @@ export async function runAkRole(
949
961
  };
950
962
  }
951
963
 
964
+ // Taishi public run path: deterministic analysis seat (#336 issue / #337 sweep).
965
+ // Not an LLM PUBLIC_CALLABLE_ROLE — registered only on PUBLIC_ROLE_ARGV (#176).
966
+ if (parsed.command === "taishi") {
967
+ const result = await runPublicTaishi(
968
+ parsed.args,
969
+ { home },
970
+ io,
971
+ PUBLIC_ROLE_ARGV.taishi.parse,
972
+ );
973
+ return { exitCode: result.exitCode };
974
+ }
975
+
952
976
  // #115: every PUBLIC_CALLABLE_ROLE has a completed handler above. Unknown
953
977
  // tokens (including misspelled role names) are structural rejects.
954
978
  throw new CliUsageError(`unknown command: ${parsed.command}`);