@hassangameryt/freeflow 0.7.0 → 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 (136) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/.cursor-plugin/plugin.json +25 -0
  5. package/README.md +57 -26
  6. package/capabilities/cognitive-routing/SKILL.md +94 -311
  7. package/com.github.copilot/hooks/hooks.json +11 -0
  8. package/command-surface.json +7 -1
  9. package/gemini-extension.json +5 -0
  10. package/hooks/adapters/claude-session-start.mjs +16 -0
  11. package/hooks/adapters/codex-session-start.mjs +16 -0
  12. package/hooks/adapters/copilot-session-start.mjs +13 -0
  13. package/hooks/adapters/cursor-session-start.mjs +13 -0
  14. package/hooks/adapters/gemini-session-start.mjs +15 -0
  15. package/hooks/claude/hooks.json +18 -0
  16. package/hooks/codex/hooks.json +18 -0
  17. package/hooks/cursor/hooks.json +9 -0
  18. package/hooks/freeflow-runtime-context.mjs +3 -215
  19. package/hooks/hooks.json +4 -5
  20. package/hooks/shared/runtime-context.mjs +210 -0
  21. package/hooks/tests/check-host-adapters.test.mjs +82 -0
  22. package/hooks/tests/check-runtime-context-hook.sh +34 -3
  23. package/package.json +20 -6
  24. package/pi-extension/dist/cognitive-routing-v2/budget.js +42 -0
  25. package/pi-extension/dist/cognitive-routing-v2/config.js +120 -0
  26. package/pi-extension/dist/cognitive-routing-v2/projection.js +321 -0
  27. package/pi-extension/dist/cognitive-routing-v2/render.js +231 -0
  28. package/pi-extension/dist/cognitive-routing-v2/runtime.js +1585 -0
  29. package/pi-extension/dist/cognitive-routing-v2/schemas.js +107 -0
  30. package/pi-extension/dist/cognitive-routing-v2/state.js +612 -0
  31. package/pi-extension/dist/cognitive-routing-v2/tools.js +91 -0
  32. package/pi-extension/dist/cognitive-routing-v2/types.js +44 -0
  33. package/pi-extension/dist/freeflow-context/tool.js +5 -0
  34. package/pi-extension/dist/index.js +279 -624
  35. package/pi-extension/dist/runtime/runtime-context.js +63 -13
  36. package/pi-extension/dist/session-sources/events.js +195 -0
  37. package/pi-extension/dist/session-sources/read-only-session.js +148 -0
  38. package/pi-extension/dist/session-sources/sources.js +233 -0
  39. package/pi-extension/dist/settings/settings-ui.js +44 -117
  40. package/pi-extension/src/cognitive-routing-v2/budget.ts +42 -0
  41. package/pi-extension/src/cognitive-routing-v2/config.ts +143 -0
  42. package/pi-extension/src/cognitive-routing-v2/projection.ts +365 -0
  43. package/pi-extension/src/cognitive-routing-v2/render.ts +256 -0
  44. package/pi-extension/src/cognitive-routing-v2/runtime.ts +1634 -0
  45. package/pi-extension/src/cognitive-routing-v2/schemas.ts +109 -0
  46. package/pi-extension/src/cognitive-routing-v2/state.ts +622 -0
  47. package/pi-extension/src/cognitive-routing-v2/tools.ts +93 -0
  48. package/pi-extension/src/cognitive-routing-v2/types.ts +222 -0
  49. package/pi-extension/src/freeflow-context/tool.ts +5 -0
  50. package/pi-extension/src/index.ts +285 -674
  51. package/pi-extension/src/runtime/runtime-context.ts +75 -14
  52. package/pi-extension/src/session-sources/events.ts +208 -0
  53. package/pi-extension/src/session-sources/read-only-session.ts +170 -0
  54. package/pi-extension/src/session-sources/sources.ts +263 -0
  55. package/pi-extension/src/settings/settings-ui.ts +54 -131
  56. package/pi-extension/tests/cognitive-routing-v2/communication.test.js +484 -0
  57. package/pi-extension/tests/cognitive-routing-v2/compaction-input.test.js +183 -0
  58. package/pi-extension/tests/cognitive-routing-v2/native.test.js +441 -0
  59. package/pi-extension/tests/cognitive-routing-v2/performance.test.js +87 -0
  60. package/pi-extension/tests/cognitive-routing-v2/persistence.test.js +245 -0
  61. package/pi-extension/tests/cognitive-routing-v2/projection.test.js +241 -0
  62. package/pi-extension/tests/cognitive-routing-v2/recovery.test.js +236 -0
  63. package/pi-extension/tests/cognitive-routing-v2/render.test.js +230 -0
  64. package/pi-extension/tests/cognitive-routing-v2/runtime.test.js +153 -0
  65. package/pi-extension/tests/cognitive-routing-v2/stabilization.test.js +521 -0
  66. package/pi-extension/tests/cognitive-routing-v2/state.test.js +275 -0
  67. package/pi-extension/tests/context-virtualization/extension.test.js +16 -0
  68. package/pi-extension/tests/fixtures/routing-native.js +188 -0
  69. package/pi-extension/tests/integration/mode-free-core.test.js +3 -2
  70. package/pi-extension/tests/integration/pi-extension.test.js +154 -37
  71. package/pi-extension/tests/integration/prompt-architecture.test.js +87 -6
  72. package/pi-extension/tests/integration/unaffected-regressions.test.js +77 -10
  73. package/plugin.json +33 -0
  74. package/runtime/prompts/cognitive-routing.md +4 -5
  75. package/runtime/prompts/core.md +51 -45
  76. package/runtime/prompts/interaction-contract.md +5 -5
  77. package/skills/action-selection/SKILL.md +57 -97
  78. package/skills/design-for-depth/SKILL.md +58 -193
  79. package/skills/design-for-depth/references/implementation-decisions.md +76 -0
  80. package/skills/diagnose-failure/SKILL.md +64 -196
  81. package/skills/discuss/SKILL.md +64 -123
  82. package/skills/discuss/references/discussion-continuity.md +7 -3
  83. package/skills/execute-work/SKILL.md +69 -88
  84. package/skills/execute-work/references/execute-work-edges.md +9 -3
  85. package/skills/handoff/SKILL.md +44 -54
  86. package/skills/handoff/references/handoff-templates.md +15 -6
  87. package/skills/review-artifact/SKILL.md +80 -127
  88. package/skills/review-artifact/references/independent-artifact-reviewer-contract.md +1 -1
  89. package/skills/review-work/SKILL.md +65 -115
  90. package/skills/review-work/references/independent-work-reviewer-contract.md +1 -1
  91. package/skills/simplify-code/SKILL.md +47 -100
  92. package/skills/track-work/SKILL.md +128 -119
  93. package/skills/track-work/references/working-record-format.md +14 -2
  94. package/skills/verify-work/SKILL.md +58 -62
  95. package/skills/verify-work/references/integration-evidence.md +4 -2
  96. package/skills/verify-work/references/test-design.md +89 -0
  97. package/skills/workflow/SKILL.md +73 -119
  98. package/skills/write-plan/SKILL.md +46 -77
  99. package/skills/write-plan/references/plan-shapes.md +5 -5
  100. package/skills/write-spec/SKILL.md +51 -63
  101. package/skills/write-spec/references/artifact-standards.md +2 -2
  102. package/skills/write-spec/references/decision-records.md +3 -2
  103. package/skills/write-spec/references/spec-shapes.md +15 -0
  104. package/pi-extension/dist/cognitive-routing/commands.js +0 -96
  105. package/pi-extension/dist/cognitive-routing/config.js +0 -232
  106. package/pi-extension/dist/cognitive-routing/controller.js +0 -1292
  107. package/pi-extension/dist/cognitive-routing/history.js +0 -451
  108. package/pi-extension/dist/cognitive-routing/host.js +0 -188
  109. package/pi-extension/dist/cognitive-routing/pi-routing-session.js +0 -414
  110. package/pi-extension/dist/cognitive-routing/pi-session-control.js +0 -320
  111. package/pi-extension/dist/cognitive-routing/runtime.js +0 -43
  112. package/pi-extension/dist/cognitive-routing/tool.js +0 -212
  113. package/pi-extension/dist/cognitive-routing/types.js +0 -6
  114. package/pi-extension/src/cognitive-routing/commands.ts +0 -129
  115. package/pi-extension/src/cognitive-routing/config.ts +0 -303
  116. package/pi-extension/src/cognitive-routing/controller.ts +0 -1628
  117. package/pi-extension/src/cognitive-routing/history.ts +0 -650
  118. package/pi-extension/src/cognitive-routing/host.ts +0 -266
  119. package/pi-extension/src/cognitive-routing/pi-routing-session.ts +0 -538
  120. package/pi-extension/src/cognitive-routing/pi-session-control.ts +0 -444
  121. package/pi-extension/src/cognitive-routing/runtime.ts +0 -57
  122. package/pi-extension/src/cognitive-routing/tool.ts +0 -273
  123. package/pi-extension/src/cognitive-routing/types.ts +0 -89
  124. package/pi-extension/tests/cognitive-routing/commands.test.js +0 -134
  125. package/pi-extension/tests/cognitive-routing/controller.test.js +0 -1128
  126. package/pi-extension/tests/cognitive-routing/extension-lifecycle.test.js +0 -1142
  127. package/pi-extension/tests/cognitive-routing/history.test.js +0 -274
  128. package/pi-extension/tests/cognitive-routing/host-identity.test.js +0 -63
  129. package/pi-extension/tests/cognitive-routing/pi-normal-lifecycle.test.js +0 -434
  130. package/pi-extension/tests/cognitive-routing/pi-session-control.test.js +0 -174
  131. package/pi-extension/tests/cognitive-routing/pi-session-history.test.js +0 -91
  132. package/pi-extension/tests/cognitive-routing/profile-resolution.test.js +0 -279
  133. package/pi-extension/tests/cognitive-routing/tool.test.js +0 -230
  134. package/skills/tdd/SKILL.md +0 -143
  135. package/skills/tdd/references/test-design.md +0 -96
  136. /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
+ }