@hassangameryt/freeflow 0.7.1 → 0.7.2

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 (122) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor-plugin/plugin.json +1 -1
  5. package/README.md +28 -23
  6. package/capabilities/cognitive-routing/SKILL.md +94 -311
  7. package/command-surface.json +7 -1
  8. package/gemini-extension.json +1 -1
  9. package/package.json +4 -4
  10. package/pi-extension/dist/cognitive-routing-v2/budget.js +42 -0
  11. package/pi-extension/dist/cognitive-routing-v2/config.js +120 -0
  12. package/pi-extension/dist/cognitive-routing-v2/projection.js +321 -0
  13. package/pi-extension/dist/cognitive-routing-v2/render.js +231 -0
  14. package/pi-extension/dist/cognitive-routing-v2/runtime.js +1585 -0
  15. package/pi-extension/dist/cognitive-routing-v2/schemas.js +107 -0
  16. package/pi-extension/dist/cognitive-routing-v2/state.js +612 -0
  17. package/pi-extension/dist/cognitive-routing-v2/tools.js +91 -0
  18. package/pi-extension/dist/cognitive-routing-v2/types.js +44 -0
  19. package/pi-extension/dist/freeflow-context/tool.js +5 -0
  20. package/pi-extension/dist/index.js +279 -624
  21. package/pi-extension/dist/runtime/runtime-context.js +63 -13
  22. package/pi-extension/dist/session-sources/events.js +195 -0
  23. package/pi-extension/dist/session-sources/read-only-session.js +148 -0
  24. package/pi-extension/dist/session-sources/sources.js +233 -0
  25. package/pi-extension/dist/settings/settings-ui.js +44 -117
  26. package/pi-extension/src/cognitive-routing-v2/budget.ts +42 -0
  27. package/pi-extension/src/cognitive-routing-v2/config.ts +143 -0
  28. package/pi-extension/src/cognitive-routing-v2/projection.ts +365 -0
  29. package/pi-extension/src/cognitive-routing-v2/render.ts +256 -0
  30. package/pi-extension/src/cognitive-routing-v2/runtime.ts +1634 -0
  31. package/pi-extension/src/cognitive-routing-v2/schemas.ts +109 -0
  32. package/pi-extension/src/cognitive-routing-v2/state.ts +622 -0
  33. package/pi-extension/src/cognitive-routing-v2/tools.ts +93 -0
  34. package/pi-extension/src/cognitive-routing-v2/types.ts +222 -0
  35. package/pi-extension/src/freeflow-context/tool.ts +5 -0
  36. package/pi-extension/src/index.ts +285 -674
  37. package/pi-extension/src/runtime/runtime-context.ts +75 -14
  38. package/pi-extension/src/session-sources/events.ts +208 -0
  39. package/pi-extension/src/session-sources/read-only-session.ts +170 -0
  40. package/pi-extension/src/session-sources/sources.ts +263 -0
  41. package/pi-extension/src/settings/settings-ui.ts +54 -131
  42. package/pi-extension/tests/cognitive-routing-v2/communication.test.js +484 -0
  43. package/pi-extension/tests/cognitive-routing-v2/compaction-input.test.js +183 -0
  44. package/pi-extension/tests/cognitive-routing-v2/native.test.js +441 -0
  45. package/pi-extension/tests/cognitive-routing-v2/performance.test.js +87 -0
  46. package/pi-extension/tests/cognitive-routing-v2/persistence.test.js +245 -0
  47. package/pi-extension/tests/cognitive-routing-v2/projection.test.js +241 -0
  48. package/pi-extension/tests/cognitive-routing-v2/recovery.test.js +236 -0
  49. package/pi-extension/tests/cognitive-routing-v2/render.test.js +230 -0
  50. package/pi-extension/tests/cognitive-routing-v2/runtime.test.js +153 -0
  51. package/pi-extension/tests/cognitive-routing-v2/stabilization.test.js +521 -0
  52. package/pi-extension/tests/cognitive-routing-v2/state.test.js +275 -0
  53. package/pi-extension/tests/context-virtualization/extension.test.js +16 -0
  54. package/pi-extension/tests/fixtures/routing-native.js +188 -0
  55. package/pi-extension/tests/integration/mode-free-core.test.js +3 -2
  56. package/pi-extension/tests/integration/pi-extension.test.js +154 -37
  57. package/pi-extension/tests/integration/prompt-architecture.test.js +87 -6
  58. package/pi-extension/tests/integration/unaffected-regressions.test.js +77 -10
  59. package/plugin.json +1 -1
  60. package/runtime/prompts/cognitive-routing.md +4 -5
  61. package/runtime/prompts/core.md +51 -45
  62. package/runtime/prompts/interaction-contract.md +5 -5
  63. package/skills/action-selection/SKILL.md +57 -97
  64. package/skills/design-for-depth/SKILL.md +58 -193
  65. package/skills/design-for-depth/references/implementation-decisions.md +76 -0
  66. package/skills/diagnose-failure/SKILL.md +64 -196
  67. package/skills/discuss/SKILL.md +64 -123
  68. package/skills/discuss/references/discussion-continuity.md +7 -3
  69. package/skills/execute-work/SKILL.md +69 -88
  70. package/skills/execute-work/references/execute-work-edges.md +9 -3
  71. package/skills/handoff/SKILL.md +44 -54
  72. package/skills/handoff/references/handoff-templates.md +15 -6
  73. package/skills/review-artifact/SKILL.md +80 -127
  74. package/skills/review-artifact/references/independent-artifact-reviewer-contract.md +1 -1
  75. package/skills/review-work/SKILL.md +65 -115
  76. package/skills/review-work/references/independent-work-reviewer-contract.md +1 -1
  77. package/skills/simplify-code/SKILL.md +47 -100
  78. package/skills/track-work/SKILL.md +128 -119
  79. package/skills/track-work/references/working-record-format.md +14 -2
  80. package/skills/verify-work/SKILL.md +58 -62
  81. package/skills/verify-work/references/integration-evidence.md +4 -2
  82. package/skills/verify-work/references/test-design.md +89 -0
  83. package/skills/workflow/SKILL.md +73 -119
  84. package/skills/write-plan/SKILL.md +46 -77
  85. package/skills/write-plan/references/plan-shapes.md +5 -5
  86. package/skills/write-spec/SKILL.md +51 -63
  87. package/skills/write-spec/references/artifact-standards.md +2 -2
  88. package/skills/write-spec/references/decision-records.md +3 -2
  89. package/skills/write-spec/references/spec-shapes.md +15 -0
  90. package/pi-extension/dist/cognitive-routing/commands.js +0 -96
  91. package/pi-extension/dist/cognitive-routing/config.js +0 -232
  92. package/pi-extension/dist/cognitive-routing/controller.js +0 -1292
  93. package/pi-extension/dist/cognitive-routing/history.js +0 -451
  94. package/pi-extension/dist/cognitive-routing/host.js +0 -188
  95. package/pi-extension/dist/cognitive-routing/pi-routing-session.js +0 -414
  96. package/pi-extension/dist/cognitive-routing/pi-session-control.js +0 -320
  97. package/pi-extension/dist/cognitive-routing/runtime.js +0 -43
  98. package/pi-extension/dist/cognitive-routing/tool.js +0 -212
  99. package/pi-extension/dist/cognitive-routing/types.js +0 -6
  100. package/pi-extension/src/cognitive-routing/commands.ts +0 -129
  101. package/pi-extension/src/cognitive-routing/config.ts +0 -303
  102. package/pi-extension/src/cognitive-routing/controller.ts +0 -1628
  103. package/pi-extension/src/cognitive-routing/history.ts +0 -650
  104. package/pi-extension/src/cognitive-routing/host.ts +0 -266
  105. package/pi-extension/src/cognitive-routing/pi-routing-session.ts +0 -538
  106. package/pi-extension/src/cognitive-routing/pi-session-control.ts +0 -444
  107. package/pi-extension/src/cognitive-routing/runtime.ts +0 -57
  108. package/pi-extension/src/cognitive-routing/tool.ts +0 -273
  109. package/pi-extension/src/cognitive-routing/types.ts +0 -89
  110. package/pi-extension/tests/cognitive-routing/commands.test.js +0 -134
  111. package/pi-extension/tests/cognitive-routing/controller.test.js +0 -1128
  112. package/pi-extension/tests/cognitive-routing/extension-lifecycle.test.js +0 -1142
  113. package/pi-extension/tests/cognitive-routing/history.test.js +0 -274
  114. package/pi-extension/tests/cognitive-routing/host-identity.test.js +0 -63
  115. package/pi-extension/tests/cognitive-routing/pi-normal-lifecycle.test.js +0 -434
  116. package/pi-extension/tests/cognitive-routing/pi-session-control.test.js +0 -174
  117. package/pi-extension/tests/cognitive-routing/pi-session-history.test.js +0 -91
  118. package/pi-extension/tests/cognitive-routing/profile-resolution.test.js +0 -279
  119. package/pi-extension/tests/cognitive-routing/tool.test.js +0 -230
  120. package/skills/tdd/SKILL.md +0 -143
  121. package/skills/tdd/references/test-design.md +0 -96
  122. /package/pi-extension/tests/{cognitive-routing/host-fixture.js → fixtures/pi-host.js} +0 -0
@@ -2,8 +2,8 @@ import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
 
5
- import { resolveCognitiveRoutingState } from "../cognitive-routing/runtime.js";
6
- import { supportsCognitiveRoutingModelRegistry } from "../cognitive-routing/host.js";
5
+ import { resolveCognitiveRoutingState } from "../cognitive-routing-v2/config.js";
6
+ import { supportsCognitiveRoutingModelRegistry } from "../cognitive-routing-v2/config.js";
7
7
 
8
8
  export const WORKFLOW_COMMANDS = [
9
9
  { command: "discuss", skill: "discuss" },
@@ -47,7 +47,6 @@ export const FREEFLOW_MODEL_SKILL_NAMES = [
47
47
  "setup-freeflow",
48
48
  "launch-work",
49
49
  "simplify-code",
50
- "tdd",
51
50
  "track-work",
52
51
  "verify-work",
53
52
  "workflow",
@@ -87,7 +86,6 @@ type SessionCoreOverrides = Partial<Record<SessionCoreKey, boolean>>;
87
86
  const SESSION_OVERRIDES_ENTRY = "freeflow-session-overrides";
88
87
  const SESSION_CORE_KEYS = new Set<SessionCoreKey>(["enabled", "contextVirtualization", "conversationHistory"]);
89
88
 
90
- export const COGNITIVE_ROUTING_SWITCH_TOOL_NAME = "freeflow_switch_profile";
91
89
  export const FREEFLOW_RUNTIME_STATE_MESSAGE_TYPE = "freeflow-runtime-state";
92
90
  export const COGNITIVE_ROUTING_RUNTIME_STATE_MESSAGE_TYPE = "freeflow-cognitive-routing-runtime-state";
93
91
  export const WORKFLOW_BOOTSTRAP_MESSAGE_TYPE = "freeflow-workflow-bootstrap";
@@ -101,6 +99,33 @@ export function isPromptAvailable(value: unknown): value is string {
101
99
  return typeof value === "string" && value.trim().length > 0;
102
100
  }
103
101
 
102
+ // pi-subagents stamps child system prompts with this tag before binding extensions.
103
+ // Keeping detection at the prompt boundary avoids a process-global child flag.
104
+ export const SUBAGENT_AGENT_TAG = /<active_agent\s+name="[^"]+"\s*\/>/;
105
+ export const FREEFLOW_SUBAGENT_CAPABILITIES_DISABLED_MARKER = "<!-- freeflow-subagent-capabilities: disabled -->";
106
+ const SUBAGENT_OPTIONAL_CAPABILITIES_MESSAGE = "Optional Freeflow capabilities are disabled for subagents.";
107
+
108
+ export function isSubagentContext(context: unknown): boolean {
109
+ try {
110
+ const systemPrompt = (context as { getSystemPrompt?: () => unknown })?.getSystemPrompt?.();
111
+ return (
112
+ typeof systemPrompt === "string" &&
113
+ (SUBAGENT_AGENT_TAG.test(systemPrompt) || systemPrompt.includes(FREEFLOW_SUBAGENT_CAPABILITIES_DISABLED_MARKER))
114
+ );
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+
120
+ function disableSubagentCapability(capability: any): any {
121
+ return {
122
+ ...capability,
123
+ enabled: false,
124
+ effective: false,
125
+ blockingReason: { code: "disabled", message: SUBAGENT_OPTIONAL_CAPABILITIES_MESSAGE },
126
+ };
127
+ }
128
+
104
129
  export function hasUsableMandatoryPrompts(freeflowContext: any): boolean {
105
130
  return (
106
131
  isPromptAvailable(freeflowContext?.corePrompt) && isPromptAvailable(freeflowContext?.interactionContractPrompt)
@@ -432,7 +457,8 @@ export async function readCapabilityState(cwd, host = undefined, extensionHost =
432
457
  const enabled = layers.configured && effectiveCore.config.enabled;
433
458
  const contextVirtualizationConfigEnabled = effectiveCore.config.contextVirtualization;
434
459
  const conversationHistoryConfigEnabled = effectiveCore.config.conversationHistory;
435
- const hostSupportsCognitiveRouting = supportsCognitiveRoutingModelRegistry(host);
460
+ const subagentContext = isSubagentContext(host);
461
+ const hostSupportsCognitiveRouting = !subagentContext && supportsCognitiveRoutingModelRegistry(host);
436
462
  const configuredCognitiveRouting = await resolveCognitiveRoutingState(
437
463
  layers.repository.parsed,
438
464
  layers.local.parsed,
@@ -452,7 +478,7 @@ export async function readCapabilityState(cwd, host = undefined, extensionHost =
452
478
  effective: false,
453
479
  blockingReason: disabledReason,
454
480
  };
455
- return {
481
+ const capabilityState = {
456
482
  configured: layers.configured,
457
483
  repositoryConfigured: layers.repositoryConfigured,
458
484
  configExists: layers.repository.exists,
@@ -473,6 +499,14 @@ export async function readCapabilityState(cwd, host = undefined, extensionHost =
473
499
  hostSupportsCognitiveRouting,
474
500
  cognitiveRouting,
475
501
  };
502
+ if (!subagentContext) return capabilityState;
503
+
504
+ return {
505
+ ...capabilityState,
506
+ contextVirtualization: disableSubagentCapability(capabilityState.contextVirtualization),
507
+ conversationHistory: disableSubagentCapability(capabilityState.conversationHistory),
508
+ cognitiveRouting: disableSubagentCapability(capabilityState.cognitiveRouting),
509
+ };
476
510
  }
477
511
 
478
512
  export const readRuntimeState = readCapabilityState;
@@ -526,8 +560,8 @@ export function setFreeflowStatus(
526
560
  } else if (cognitiveRoutingActive) {
527
561
  const profile = cognitiveRoutingRuntime.activeProfile;
528
562
  const control =
529
- cognitiveRoutingRuntime.controlMode === "manual-standard" ||
530
- cognitiveRoutingRuntime.controlMode === "manual-reasoning"
563
+ cognitiveRoutingRuntime.controlMode === "manual-executor" ||
564
+ cognitiveRoutingRuntime.controlMode === "manual-coordinator"
531
565
  ? "manual hold"
532
566
  : "automatic";
533
567
  active.push(`${profile} · ${control}`);
@@ -542,8 +576,8 @@ export function setFreeflowStatus(
542
576
  ) {
543
577
  const startupProfile =
544
578
  cognitiveRouting.sessionStart?.control === "manual"
545
- ? (cognitiveRouting.sessionStart.profile ?? "reasoning")
546
- : "reasoning";
579
+ ? (cognitiveRouting.sessionStart.profile ?? "coordinator")
580
+ : "coordinator";
547
581
  active.push(`${startupProfile} · pending`);
548
582
  } else {
549
583
  const reason =
@@ -572,6 +606,8 @@ type CognitiveRoutingRuntimeSnapshot = {
572
606
  controlMode?: unknown;
573
607
  };
574
608
 
609
+ type CognitiveRoutingProjectionMode = "disabled" | "enabled" | "manual-bypass" | "blocked" | "unavailable" | "pending";
610
+
575
611
  type FreeflowContextMessage = {
576
612
  customType?: unknown;
577
613
  [key: string]: unknown;
@@ -579,13 +615,13 @@ type FreeflowContextMessage = {
579
615
 
580
616
  function publicCognitiveRoutingControl(controlMode) {
581
617
  if (controlMode === "automatic") return "automatic";
582
- if (controlMode === "manual-standard" || controlMode === "manual-reasoning") return "manual";
618
+ if (controlMode === "manual-executor" || controlMode === "manual-coordinator") return "manual";
583
619
  return "unavailable";
584
620
  }
585
621
 
586
622
  function publicCognitiveRoutingProfile(activeProfile, effective) {
587
623
  if (effective !== true) return "unavailable";
588
- return activeProfile === "standard" || activeProfile === "reasoning" ? activeProfile : "unavailable";
624
+ return activeProfile === "executor" || activeProfile === "coordinator" ? activeProfile : "unavailable";
589
625
  }
590
626
 
591
627
  function publicCapabilityStatus(capability) {
@@ -601,10 +637,27 @@ function publicCognitiveRoutingStatus(capability, runtime) {
601
637
  return publicCapabilityStatus(capability);
602
638
  }
603
639
 
640
+ function publicCognitiveRoutingProjectionMode(
641
+ capability: any,
642
+ runtime: CognitiveRoutingRuntimeSnapshot | undefined,
643
+ projectionFailure?: string,
644
+ ): CognitiveRoutingProjectionMode {
645
+ if (capability?.projection !== true) return "disabled";
646
+ if (capability?.effective !== true) return "unavailable";
647
+ if (runtime?.controlMode === "manual-executor" || runtime?.controlMode === "manual-coordinator") {
648
+ return "manual-bypass";
649
+ }
650
+ if (projectionFailure) return "blocked";
651
+ if (runtime?.runtimeStatus === "inactive" || runtime?.runtimeStatus === "blocked") return "unavailable";
652
+ if (runtime?.effective === true && runtime.controlMode === "automatic") return "enabled";
653
+ return "pending";
654
+ }
655
+
604
656
  export function freeflowRuntimeStateMessage(
605
657
  capabilityState,
606
658
  cognitiveRoutingRuntime: CognitiveRoutingRuntimeSnapshot | undefined = undefined,
607
659
  freeflowContext = undefined,
660
+ options: { projectionFailure?: string } = {},
608
661
  ) {
609
662
  const cognitiveRoutingEffective = capabilityState?.cognitiveRouting?.effective === true;
610
663
  const profile = publicCognitiveRoutingProfile(
@@ -613,6 +666,11 @@ export function freeflowRuntimeStateMessage(
613
666
  );
614
667
  const control =
615
668
  profile === "unavailable" ? "unavailable" : publicCognitiveRoutingControl(cognitiveRoutingRuntime?.controlMode);
669
+ const projectionMode = publicCognitiveRoutingProjectionMode(
670
+ capabilityState?.cognitiveRouting,
671
+ cognitiveRoutingRuntime,
672
+ options.projectionFailure,
673
+ );
616
674
  const mandatoryPromptAvailable = capabilityState?.enabled !== true || hasUsableMandatoryPrompts(freeflowContext);
617
675
  const freeflowStatus = capabilityState?.configured
618
676
  ? capabilityState.enabled
@@ -642,6 +700,7 @@ export function freeflowRuntimeStateMessage(
642
700
  "Cognitive Routing:",
643
701
  `- Control: \`${control}\``,
644
702
  `- Profile: \`${profile}\``,
703
+ `- Projection: \`${projectionMode}\``,
645
704
  ].join("\n"),
646
705
  display: false,
647
706
  details: { source: "provider-request-runtime-state" },
@@ -677,10 +736,12 @@ export function withFreeflowRuntimeState(
677
736
  capabilityState,
678
737
  cognitiveRoutingRuntime: CognitiveRoutingRuntimeSnapshot | undefined = undefined,
679
738
  freeflowContext = undefined,
680
- options: { force?: boolean } = {},
739
+ options: { force?: boolean; projectionFailure?: string } = {},
681
740
  ) {
682
741
  const source = Array.isArray(messages) ? messages : [];
683
- const runtimeState = freeflowRuntimeStateMessage(capabilityState, cognitiveRoutingRuntime, freeflowContext);
742
+ const runtimeState = freeflowRuntimeStateMessage(capabilityState, cognitiveRoutingRuntime, freeflowContext, {
743
+ projectionFailure: options.projectionFailure,
744
+ });
684
745
  const runtimeStateMessages = source.filter(
685
746
  (message) =>
686
747
  message?.customType === FREEFLOW_RUNTIME_STATE_MESSAGE_TYPE ||
@@ -0,0 +1,208 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { activeReadOnlySessionBranch, readOnlySessionSnapshot, ReadOnlySessionError } from "./read-only-session.js";
3
+ import {
4
+ ROUTING_ENTRY,
5
+ RoutingError,
6
+ canonical,
7
+ eventKey,
8
+ eventValue,
9
+ requireCondition as check,
10
+ type EventData,
11
+ type NativeEntry,
12
+ type RoutingEvent,
13
+ } from "../cognitive-routing-v2/types.js";
14
+ import { parseRoutingEvent, reduce, replay } from "../cognitive-routing-v2/state.js";
15
+
16
+ export interface SessionReader {
17
+ getSessionId(): string;
18
+ getSessionFile(): string | undefined;
19
+ getBranch(): readonly NativeEntry[];
20
+ getEntries(): readonly NativeEntry[];
21
+ getLeafId(): string | null;
22
+ }
23
+ export class EventStore {
24
+ private observed = new Map<string, { value: string; eventId: string; entryId: string }>();
25
+ private attempted = new Map<string, RoutingEvent>();
26
+ private fault?: string;
27
+ private ready = false;
28
+ private cachedEntries: readonly NativeEntry[] = [];
29
+ private cachedState = replay([]);
30
+ constructor(
31
+ private readonly pi: { appendEntry(type: string, data: unknown): void },
32
+ readonly reader: SessionReader,
33
+ ) {}
34
+ get blocked(): string | undefined {
35
+ return this.fault ?? (!this.ready ? "acknowledgment_unclassified" : undefined);
36
+ }
37
+ state() {
38
+ const entries = this.reader.getBranch();
39
+ const prefix =
40
+ this.cachedEntries.length <= entries.length && this.cachedEntries.every((entry, i) => entry === entries[i]);
41
+ if (!prefix) {
42
+ this.cachedState = replay(entries);
43
+ } else {
44
+ let state = this.cachedState;
45
+ for (const entry of entries.slice(this.cachedEntries.length))
46
+ if (entry.type === "custom" && entry.customType === ROUTING_ENTRY)
47
+ state = reduce(state, parseRoutingEvent(entry.data));
48
+ this.cachedState = state;
49
+ }
50
+ this.cachedEntries = [...entries];
51
+ return this.cachedState;
52
+ }
53
+ block(reason: string): void {
54
+ this.fault = reason;
55
+ this.ready = false;
56
+ }
57
+ make(data: EventData, operationId: string = randomUUID(), stepId: string = data.type): RoutingEvent {
58
+ return parseRoutingEvent({
59
+ version: 2,
60
+ eventId: randomUUID(),
61
+ operationId,
62
+ stepId,
63
+ recordedSessionId: this.reader.getSessionId(),
64
+ data,
65
+ });
66
+ }
67
+ async reconcile(): Promise<void> {
68
+ const acknowledged = this.ready && !this.fault;
69
+ this.ready = false;
70
+ const branch = this.reader.getBranch();
71
+ const live = branch.filter((e) => e.type === "custom" && e.customType === ROUTING_ENTRY);
72
+ try {
73
+ const ids = new Set<string>();
74
+ let parent: string | null = null;
75
+ for (const entry of branch) {
76
+ check(
77
+ typeof entry.id === "string" && !ids.has(entry.id) && entry.parentId === parent,
78
+ "invalid_native_ancestry",
79
+ );
80
+ ids.add(entry.id);
81
+ parent = entry.id;
82
+ }
83
+ check(parent === this.reader.getLeafId(), "invalid_native_ancestry");
84
+ if (
85
+ acknowledged &&
86
+ live.every((entry) => {
87
+ const event = parseRoutingEvent(entry.data);
88
+ const receipt = this.observed.get(eventKey(event));
89
+ return (
90
+ receipt?.entryId === entry.id && receipt.eventId === event.eventId && receipt.value === eventValue(event)
91
+ );
92
+ })
93
+ ) {
94
+ this.ready = true;
95
+ return;
96
+ }
97
+ if (!live.length && !this.attempted.size) {
98
+ this.observed.clear();
99
+ this.fault = undefined;
100
+ this.ready = true;
101
+ return;
102
+ }
103
+ const path = this.reader.getSessionFile();
104
+ check(path, "persisted_snapshot_unavailable", "Existing or uncertain routing state needs a persisted snapshot.");
105
+ const snapshot = await readOnlySessionSnapshot(path);
106
+ check(snapshot.sessionId === this.reader.getSessionId(), "session_identity_changed");
107
+ const persistedBranch = activeReadOnlySessionBranch(snapshot, this.reader.getLeafId());
108
+ check(
109
+ persistedBranch.length === branch.length && persistedBranch.every((e, i) => e.id === branch[i].id),
110
+ "snapshot_ancestry_mismatch",
111
+ "Live branch is not the complete persisted ancestry for its claimed leaf.",
112
+ );
113
+ replay(persistedBranch as NativeEntry[]);
114
+ const disk = new Map(persistedBranch.map((e) => [e.id, e]));
115
+ const known = new Map<string, { value: string; eventId: string; entryId: string }>();
116
+ // Compare the live selected ancestry, not whichever branch was last written on disk.
117
+ for (const entry of branch) {
118
+ const captured = disk.get(entry.id);
119
+ check(
120
+ captured && canonical(captured) === canonical(entry),
121
+ "snapshot_divergence",
122
+ "Live ancestry is not fully established by the fresh persisted snapshot; reconcile the session before retrying.",
123
+ );
124
+ if (entry.type === "custom" && entry.customType === ROUTING_ENTRY) {
125
+ const event = parseRoutingEvent(entry.data);
126
+ known.set(eventKey(event), { value: eventValue(event), eventId: event.eventId, entryId: entry.id });
127
+ }
128
+ }
129
+ for (const [key, event] of this.attempted)
130
+ check(
131
+ known.get(key)?.value === eventValue(event) && known.get(key)?.eventId === event.eventId,
132
+ "append_acknowledgment_uncertain",
133
+ "The attempted routing event is not established by persisted readback.",
134
+ );
135
+ this.observed = known;
136
+ this.attempted.clear();
137
+ this.fault = undefined;
138
+ this.ready = true;
139
+ this.cachedEntries = [];
140
+ this.cachedState = replay([]);
141
+ } catch (error) {
142
+ this.block(
143
+ error instanceof RoutingError
144
+ ? error.code
145
+ : error instanceof ReadOnlySessionError
146
+ ? `${error.code}: ${error.message}`
147
+ : "snapshot_unavailable",
148
+ );
149
+ throw error;
150
+ }
151
+ }
152
+ append(event: RoutingEvent): RoutingEvent {
153
+ const value = parseRoutingEvent(event),
154
+ key = eventKey(value),
155
+ body = eventValue(value);
156
+ if (this.blocked) throw new RoutingError("acknowledgment_uncertain", this.blocked);
157
+ const prior = this.state().events.get(key);
158
+ if (prior) {
159
+ check(eventValue(prior) === body, "operation_conflict");
160
+ const occurrence = this.reader
161
+ .getBranch()
162
+ .find(
163
+ (entry) =>
164
+ entry.type === "custom" &&
165
+ entry.customType === ROUTING_ENTRY &&
166
+ (entry.data as any)?.eventId === prior.eventId,
167
+ );
168
+ if (
169
+ this.observed.get(key)?.value !== body ||
170
+ this.observed.get(key)?.eventId !== prior.eventId ||
171
+ this.observed.get(key)?.entryId !== occurrence?.id
172
+ ) {
173
+ this.block("prior_acknowledgment_unclassified");
174
+ throw new RoutingError("acknowledgment_uncertain", this.blocked!);
175
+ }
176
+ return prior;
177
+ }
178
+ // Validate the whole state transition before mutating Pi's session memory.
179
+ const candidate = reduce(this.state(), value);
180
+ const baseline = this.cachedEntries;
181
+ this.attempted.set(key, value);
182
+ try {
183
+ this.pi.appendEntry(ROUTING_ENTRY, value);
184
+ const found = this.reader
185
+ .getBranch()
186
+ .find(
187
+ (e) => e.type === "custom" && e.customType === ROUTING_ENTRY && (e.data as any)?.eventId === value.eventId,
188
+ );
189
+ check(found && eventValue(parseRoutingEvent(found.data)) === body, "append_not_observed");
190
+ this.observed.set(key, { value: body, eventId: value.eventId, entryId: found.id });
191
+ this.attempted.delete(key);
192
+ const branch = this.reader.getBranch();
193
+ const delta = branch.slice(baseline.length).filter((e) => e.type === "custom" && e.customType === ROUTING_ENTRY);
194
+ if (baseline.every((e, i) => branch[i] === e) && delta.length === 1 && delta[0].id === found!.id) {
195
+ // Publish the already validated candidate only after acknowledged native append.
196
+ this.cachedState = candidate;
197
+ this.cachedEntries = [...branch];
198
+ }
199
+ return value;
200
+ } catch (error) {
201
+ this.block("append_acknowledgment_uncertain");
202
+ throw new RoutingError(
203
+ "acknowledgment_uncertain",
204
+ error instanceof Error ? error.message : "Pi append/readback did not acknowledge the event.",
205
+ );
206
+ }
207
+ }
208
+ }
@@ -0,0 +1,170 @@
1
+ import { createHash } from "node:crypto";
2
+ import { open, stat } from "node:fs/promises";
3
+ import { TextDecoder } from "node:util";
4
+
5
+ export interface ReadOnlySessionEntry {
6
+ type: string;
7
+ id: string;
8
+ parentId?: string | null;
9
+ [key: string]: unknown;
10
+ }
11
+ export interface ReadOnlySessionSnapshot {
12
+ path: string;
13
+ sessionId: string;
14
+ version: number;
15
+ hash: string;
16
+ entries: readonly ReadOnlySessionEntry[];
17
+ }
18
+ export class ReadOnlySessionError extends Error {
19
+ constructor(
20
+ readonly code: string,
21
+ message: string,
22
+ readonly line?: number,
23
+ ) {
24
+ super(message);
25
+ this.name = "ReadOnlySessionError";
26
+ }
27
+ }
28
+
29
+ export const SESSION_READ_LIMITS = {
30
+ maxBytes: 512 * 1024 * 1024,
31
+ maxEntryBytes: 128 * 1024 * 1024,
32
+ maxEntries: 250000,
33
+ };
34
+ function decodeEntry(text: string, line: number, seen: Set<string>): ReadOnlySessionEntry {
35
+ let value: any;
36
+ try {
37
+ value = JSON.parse(text);
38
+ } catch {
39
+ throw new ReadOnlySessionError("invalid_json", "Incomplete or invalid session JSON.", line);
40
+ }
41
+ if (!value || Array.isArray(value) || typeof value !== "object" || typeof value.type !== "string")
42
+ throw new ReadOnlySessionError("invalid_entry", "Invalid session entry.", line);
43
+ if (line === 1) {
44
+ if (
45
+ value.type !== "session" ||
46
+ typeof value.id !== "string" ||
47
+ !value.id ||
48
+ ![2, 3].includes(value.version) ||
49
+ typeof value.cwd !== "string"
50
+ )
51
+ throw new ReadOnlySessionError("invalid_header", "A supported native session header is required.", line);
52
+ } else {
53
+ if (value.type === "session" || typeof value.id !== "string" || !value.id || seen.has(value.id))
54
+ throw new ReadOnlySessionError("invalid_id", "Missing, duplicate, or misplaced session identity.", line);
55
+ if (value.parentId !== null && (typeof value.parentId !== "string" || !seen.has(value.parentId)))
56
+ throw new ReadOnlySessionError("invalid_parent", "Parent is absent or occurs after its child.", line);
57
+ seen.add(value.id);
58
+ }
59
+ return value;
60
+ }
61
+ export function parseReadOnlySessionText(text: string): readonly ReadOnlySessionEntry[] {
62
+ const lines = text.split("\n");
63
+ if (lines.at(-1) === "") lines.pop();
64
+ if (!lines.length || !lines[0]) throw new ReadOnlySessionError("empty", "Session snapshot is empty.");
65
+ const seen = new Set<string>();
66
+ return lines.map((line, i) => decodeEntry(line, i + 1, seen));
67
+ }
68
+ export async function readOnlySessionSnapshot(
69
+ path: string,
70
+ options: { maxBytes?: number; maxEntryBytes?: number; maxEntries?: number } = {},
71
+ ): Promise<ReadOnlySessionSnapshot> {
72
+ const limits = { ...SESSION_READ_LIMITS, ...options };
73
+ if (Object.values(limits).some((n) => !Number.isSafeInteger(n) || n <= 0))
74
+ throw new ReadOnlySessionError("invalid_limit", "Invalid snapshot limit.");
75
+ let file: Awaited<ReturnType<typeof open>> | undefined;
76
+ try {
77
+ file = await open(path, "r");
78
+ const before = await file.stat({ bigint: true });
79
+ if (!before.isFile()) throw new ReadOnlySessionError("read_limit", "Session is not a regular file.");
80
+ if (before.size > BigInt(limits.maxBytes))
81
+ throw new ReadOnlySessionError(
82
+ "read_limit",
83
+ `Session exceeds the supported ${limits.maxBytes} byte snapshot limit.`,
84
+ );
85
+ const hash = createHash("sha256"),
86
+ entries: ReadOnlySessionEntry[] = [],
87
+ seen = new Set<string>();
88
+ let total = 0,
89
+ lineBytes = 0,
90
+ parts: Buffer[] = [];
91
+ const append = (part: Buffer) => {
92
+ lineBytes += part.length;
93
+ if (lineBytes > limits.maxEntryBytes)
94
+ throw new ReadOnlySessionError(
95
+ "entry_limit",
96
+ `Session entry exceeds ${limits.maxEntryBytes} bytes.`,
97
+ entries.length + 1,
98
+ );
99
+ if (part.length) parts.push(part);
100
+ };
101
+ const decode = () => {
102
+ if (entries.length >= limits.maxEntries)
103
+ throw new ReadOnlySessionError("entry_count_limit", `Session exceeds ${limits.maxEntries} entries.`);
104
+ let text: string;
105
+ try {
106
+ text = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(parts, lineBytes));
107
+ } catch {
108
+ throw new ReadOnlySessionError("invalid_encoding", "Session is not complete UTF-8.", entries.length + 1);
109
+ }
110
+ entries.push(decodeEntry(text, entries.length + 1, seen));
111
+ parts = [];
112
+ lineBytes = 0;
113
+ };
114
+ for (;;) {
115
+ const buffer = Buffer.alloc(64 * 1024),
116
+ { bytesRead } = await file.read(buffer, 0, buffer.length, null);
117
+ if (!bytesRead) break;
118
+ total += bytesRead;
119
+ if (total > limits.maxBytes)
120
+ throw new ReadOnlySessionError("read_limit", "Session grew beyond the supported snapshot limit.");
121
+ const bytes = buffer.subarray(0, bytesRead);
122
+ hash.update(bytes);
123
+ let start = 0,
124
+ end: number;
125
+ while ((end = bytes.indexOf(10, start)) !== -1) {
126
+ append(bytes.subarray(start, end));
127
+ decode();
128
+ start = end + 1;
129
+ }
130
+ append(bytes.subarray(start));
131
+ }
132
+ if (lineBytes) decode();
133
+ if (!entries.length) throw new ReadOnlySessionError("empty", "Session snapshot is empty.");
134
+ const after = await file.stat({ bigint: true }),
135
+ current = await stat(path, { bigint: true });
136
+ const signature = (v: typeof before) => [v.dev, v.ino, v.size, v.mtimeNs, v.ctimeNs].join(":");
137
+ if (
138
+ signature(before) !== signature(after) ||
139
+ signature(after) !== signature(current) ||
140
+ after.size !== BigInt(total)
141
+ )
142
+ throw new ReadOnlySessionError("source_changed", "Session changed during snapshot capture.");
143
+ return { path, sessionId: entries[0].id, version: entries[0].version as number, hash: hash.digest("hex"), entries };
144
+ } catch (error) {
145
+ if (error instanceof ReadOnlySessionError) throw error;
146
+ throw new ReadOnlySessionError("read_failed", error instanceof Error ? error.message : String(error));
147
+ } finally {
148
+ await file?.close();
149
+ }
150
+ }
151
+
152
+ export function activeReadOnlySessionBranch(
153
+ snapshot: ReadOnlySessionSnapshot,
154
+ leafId: string | null,
155
+ ): readonly ReadOnlySessionEntry[] {
156
+ if (leafId === null) return [];
157
+ const byId = new Map(snapshot.entries.slice(1).map((entry) => [entry.id, entry]));
158
+ const path: ReadOnlySessionEntry[] = [];
159
+ const seen = new Set<string>();
160
+ let cursor: string | null = leafId;
161
+ while (cursor !== null) {
162
+ const entry = byId.get(cursor);
163
+ if (!entry || seen.has(cursor))
164
+ throw new ReadOnlySessionError("ancestry_unavailable", "Requested native ancestry is unavailable.");
165
+ seen.add(cursor);
166
+ path.push(entry);
167
+ cursor = entry.parentId as string | null;
168
+ }
169
+ return path.reverse();
170
+ }