@akagilnc/pi-workflow-roles 0.1.1893 → 0.1.1941

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.
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Typed ticket face contract (#176 option A).
3
+ *
4
+ * Machine field lives in YAML frontmatter only:
5
+ * ---
6
+ * ticketNumber: <positive integer>
7
+ * ---
8
+ *
9
+ * Admission reads frozen attachment bytes for this field alone.
10
+ * No filename, path, or free-text inference. Absent / invalid / conflicting
11
+ * values are unbound (undefined) — never a reject gate.
12
+ *
13
+ * Buffer bodies use strict UTF-8 (decode failure → unbound). Opening and
14
+ * closing fences must each be an exclusive line of exactly `---`.
15
+ */
16
+
17
+ import { exactUtf8 } from "./exact-utf8.ts";
18
+
19
+ /** Positive integer GitHub/issue identity carried on a typed ticket face. */
20
+ export type TicketIdentity = number;
21
+
22
+ /**
23
+ * Parse one attachment body for the typed frontmatter ticketNumber field.
24
+ * Returns undefined when the contract is absent or not well-formed.
25
+ */
26
+ export function parseTicketNumberFrontmatter(
27
+ bytes: Buffer | string,
28
+ ): TicketIdentity | undefined {
29
+ let text: string;
30
+ if (typeof bytes === "string") {
31
+ text = bytes;
32
+ } else {
33
+ try {
34
+ text = exactUtf8(bytes, "ticket face");
35
+ } catch {
36
+ return undefined;
37
+ }
38
+ }
39
+
40
+ // Opening fence must be an exclusive first line of exactly `---`.
41
+ let cursor: number;
42
+ if (text.startsWith("---\n")) {
43
+ cursor = 4;
44
+ } else if (text.startsWith("---\r\n")) {
45
+ cursor = 5;
46
+ } else {
47
+ return undefined;
48
+ }
49
+
50
+ // Closing fence must likewise be an exclusive line of exactly `---`.
51
+ const fmLines: string[] = [];
52
+ let closed = false;
53
+ while (cursor < text.length) {
54
+ const nl = text.indexOf("\n", cursor);
55
+ const lineEnd = nl === -1 ? text.length : nl;
56
+ let line = text.slice(cursor, lineEnd);
57
+ if (line.endsWith("\r")) line = line.slice(0, -1);
58
+ if (line === "---") {
59
+ closed = true;
60
+ break;
61
+ }
62
+ fmLines.push(line);
63
+ if (nl === -1) break;
64
+ cursor = nl + 1;
65
+ }
66
+ if (!closed) return undefined;
67
+
68
+ let found: number | undefined;
69
+ for (const line of fmLines) {
70
+ const match = /^[ \t]*ticketNumber[ \t]*:[ \t]*(\d+)[ \t]*$/.exec(line);
71
+ if (match === null) continue;
72
+ const value = Number(match[1]);
73
+ if (!Number.isInteger(value) || value < 1) return undefined;
74
+ if (found !== undefined && found !== value) return undefined;
75
+ found = value;
76
+ }
77
+ return found;
78
+ }
79
+
80
+ /**
81
+ * Resolve at most one ticketNumber across frozen attachment bodies.
82
+ * Zero matches → unbound. Multiple distinct values → unbound (do not guess).
83
+ */
84
+ export function resolveTicketNumberFromAttachmentBodies(
85
+ bodies: readonly (Buffer | string)[],
86
+ ): TicketIdentity | undefined {
87
+ const found = new Set<number>();
88
+ for (const body of bodies) {
89
+ const value = parseTicketNumberFrontmatter(body);
90
+ if (value !== undefined) found.add(value);
91
+ }
92
+ if (found.size !== 1) return undefined;
93
+ return found.values().next().value;
94
+ }
@@ -38,6 +38,7 @@ import {
38
38
  } from "./package-contracts/terminating-tools.ts";
39
39
  import { PACKAGED_ROLE_REGISTRY } from "./packaged-role-registry.ts";
40
40
 
41
+
41
42
  /** Declared refresh bound for the same viewing surface (seconds). */
42
43
  export const DEFAULT_REFRESH_BOUNDARY_SECONDS = 30;
43
44
 
@@ -328,6 +329,8 @@ type InvocationInfo = {
328
329
  model?: string;
329
330
  provider?: string;
330
331
  thinking?: string;
332
+ ticketNumber?: number;
333
+ correlationId?: string;
331
334
  };
332
335
 
333
336
  async function readInvocation(runDir: string): Promise<InvocationInfo | undefined> {
@@ -348,6 +351,16 @@ async function readInvocation(runDir: string): Promise<InvocationInfo | undefine
348
351
  info.model = rawModel;
349
352
  }
350
353
  }
354
+ if (
355
+ typeof parsed.ticketNumber === "number" &&
356
+ Number.isInteger(parsed.ticketNumber) &&
357
+ parsed.ticketNumber >= 1
358
+ ) {
359
+ info.ticketNumber = parsed.ticketNumber;
360
+ }
361
+ if (typeof parsed.correlationId === "string" && parsed.correlationId.trim() !== "") {
362
+ info.correlationId = parsed.correlationId;
363
+ }
351
364
  return info;
352
365
  } catch (error) {
353
366
  // Only genuine absence activates the invocation fallback. Malformed JSON and
@@ -408,9 +421,8 @@ async function maxMtimeMs(paths: readonly string[]): Promise<number> {
408
421
  return max;
409
422
  }
410
423
 
411
- async function parseRun(ledgerDir: string, issueNumber: number, runId: string): Promise<ParsedRun> {
412
- const runDir = join(ledgerDir, "issues", String(issueNumber), "runs", runId);
413
- const ledgerCoord = ["issues", String(issueNumber), "runs", runId].join("/");
424
+ async function parseRunDirectory(runDir: string, ledgerCoord: string): Promise<ParsedRun> {
425
+ const runId = basename(runDir);
414
426
  const evidenceTarget = await realpathOrLexicalIfMissing(runDir);
415
427
  const evidenceHref = pathToFileURL(evidenceTarget).href;
416
428
  const sessionDir = join(runDir, "session");
@@ -496,6 +508,12 @@ async function parseRun(ledgerDir: string, issueNumber: number, runId: string):
496
508
  };
497
509
  }
498
510
 
511
+ async function parseRun(ledgerDir: string, issueNumber: number, runId: string): Promise<ParsedRun> {
512
+ const runDir = join(ledgerDir, "issues", String(issueNumber), "runs", runId);
513
+ const ledgerCoord = ["issues", String(issueNumber), "runs", runId].join("/");
514
+ return parseRunDirectory(runDir, ledgerCoord);
515
+ }
516
+
499
517
  async function listRunIds(ledgerDir: string, issueNumber: number): Promise<string[]> {
500
518
  const runsDir = join(ledgerDir, "issues", String(issueNumber), "runs");
501
519
  try {
@@ -686,20 +704,142 @@ ${stationBlocks || "<p data-empty=\"true\">no runs</p>"}
686
704
  /**
687
705
  * Load one ticket's runs via the S1 tracer path (read-only ledger scan).
688
706
  * Factory board reuses this — no parallel receipt parser.
707
+ *
708
+ * Flat-run attribution (#176 option A): read typed `ticketNumber` from each
709
+ * `runs/<run>@<role>/invocation.json`. Legacy `issues/<N>/runs` remains a
710
+ * read-only compatibility entrance. Unbound flat runs never join a ticket.
711
+ */
712
+
713
+ /** One flat run's typed binding projection from invocation.json. */
714
+ export type FlatRunTicketBinding = {
715
+ readonly runDir: string;
716
+ readonly runFolder: string;
717
+ readonly ticketNumber?: number;
718
+ readonly role?: string;
719
+ readonly correlationId?: string;
720
+ };
721
+
722
+ /**
723
+ * Book-level flat-run index built once per lane/render and reused per ticket.
724
+ * Pure derived view of flat runs' invocation.json files — not a parallel ledger.
725
+ */
726
+ export type TicketTrajectoryBookIndex = {
727
+ /** ticket number → flat runs bound via invocation.json ticketNumber. */
728
+ readonly runsByTicket: ReadonlyMap<number, readonly FlatRunTicketBinding[]>;
729
+ /** Flat runs with no typed ticketNumber (unknown/unbound seam). */
730
+ readonly unboundRuns: readonly FlatRunTicketBinding[];
731
+ };
732
+
733
+ async function listFlatRunDirectories(ledgerDir: string): Promise<string[]> {
734
+ const runsDir = join(resolve(ledgerDir), "runs");
735
+ try {
736
+ const entries = await readdir(runsDir, { withFileTypes: true });
737
+ return entries
738
+ .filter((entry) => entry.isDirectory())
739
+ .map((entry) => join(runsDir, entry.name))
740
+ .sort();
741
+ } catch (error) {
742
+ if (isMissingPathError(error)) return [];
743
+ throw error;
744
+ }
745
+ }
746
+
747
+ export async function buildTicketTrajectoryBookIndex(
748
+ ledgerDir: string,
749
+ ): Promise<TicketTrajectoryBookIndex> {
750
+ const root = resolve(ledgerDir);
751
+ const runsByTicket = new Map<number, FlatRunTicketBinding[]>();
752
+ const unboundRuns: FlatRunTicketBinding[] = [];
753
+ for (const runDir of await listFlatRunDirectories(root)) {
754
+ const invocation = await readInvocation(runDir);
755
+ const binding: FlatRunTicketBinding = {
756
+ runDir,
757
+ runFolder: basename(runDir),
758
+ ...(invocation?.ticketNumber !== undefined
759
+ ? { ticketNumber: invocation.ticketNumber }
760
+ : {}),
761
+ ...(invocation?.role !== undefined ? { role: invocation.role } : {}),
762
+ ...(invocation?.correlationId !== undefined
763
+ ? { correlationId: invocation.correlationId }
764
+ : {}),
765
+ };
766
+ if (binding.ticketNumber !== undefined) {
767
+ const list = runsByTicket.get(binding.ticketNumber) ?? [];
768
+ list.push(binding);
769
+ runsByTicket.set(binding.ticketNumber, list);
770
+ } else {
771
+ unboundRuns.push(binding);
772
+ }
773
+ }
774
+ return { runsByTicket, unboundRuns };
775
+ }
776
+
777
+ export type UnboundTrajectoryRun = {
778
+ readonly run: TicketTrajectoryRun;
779
+ readonly role: string;
780
+ readonly observedAt: string;
781
+ readonly correlationId?: string;
782
+ };
783
+
784
+ /**
785
+ * Load unbound flat runs for the board unknown seam.
786
+ * Never joins any ticket. Built from the book index (once).
689
787
  */
788
+ export async function loadUnboundTrajectoryRuns(
789
+ ledgerDir: string,
790
+ bookIndex?: TicketTrajectoryBookIndex,
791
+ ): Promise<readonly UnboundTrajectoryRun[]> {
792
+ const root = resolve(ledgerDir);
793
+ const index = bookIndex ?? (await buildTicketTrajectoryBookIndex(root));
794
+ const out: UnboundTrajectoryRun[] = [];
795
+ for (const binding of index.unboundRuns) {
796
+ const ledgerCoord = ["runs", binding.runFolder].join("/");
797
+ const run = await parseRunDirectory(binding.runDir, ledgerCoord);
798
+ out.push({
799
+ run,
800
+ role: binding.role ?? run.station,
801
+ observedAt: run.startedAt ?? run.lastActivityAt ?? "",
802
+ ...(binding.correlationId === undefined
803
+ ? {}
804
+ : { correlationId: binding.correlationId }),
805
+ });
806
+ }
807
+ return out;
808
+ }
809
+
690
810
  export async function loadTicketTrajectoryRuns(
691
811
  ledgerDir: string,
692
812
  issueNumber: number,
813
+ bookIndex?: TicketTrajectoryBookIndex,
693
814
  ): Promise<TicketTrajectoryRun[]> {
694
815
  if (!Number.isInteger(issueNumber) || issueNumber < 1) {
695
816
  throw new Error("issueNumber must be a positive integer");
696
817
  }
697
818
  const root = resolve(ledgerDir);
698
- const runIds = await listRunIds(root, issueNumber);
819
+ const index = bookIndex ?? (await buildTicketTrajectoryBookIndex(root));
820
+
699
821
  const runs: ParsedRun[] = [];
822
+ const seenRunDirs = new Set<string>();
823
+
824
+ // Legacy hand-dispatch path: issues/<N>/runs (read-only compatibility).
825
+ const runIds = await listRunIds(root, issueNumber);
700
826
  for (const runId of runIds) {
701
- runs.push(await parseRun(root, issueNumber, runId));
827
+ const parsed = await parseRun(root, issueNumber, runId);
828
+ const legacyDir = join(root, "issues", String(issueNumber), "runs", runId);
829
+ seenRunDirs.add(resolve(legacyDir));
830
+ runs.push(parsed);
702
831
  }
832
+
833
+ // Flat runs bound by typed invocation.json ticketNumber.
834
+ const flatBindings = index.runsByTicket.get(issueNumber) ?? [];
835
+ for (const binding of flatBindings) {
836
+ const resolvedRunDir = resolve(binding.runDir);
837
+ if (seenRunDirs.has(resolvedRunDir)) continue;
838
+ seenRunDirs.add(resolvedRunDir);
839
+ const ledgerCoord = ["runs", binding.runFolder].join("/");
840
+ runs.push(await parseRunDirectory(binding.runDir, ledgerCoord));
841
+ }
842
+
703
843
  return runs;
704
844
  }
705
845