@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
@@ -1,8 +1,8 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- import { resolveCognitiveRoutingState } from "../cognitive-routing/runtime.js";
5
- import { supportsCognitiveRoutingModelRegistry } from "../cognitive-routing/host.js";
4
+ import { resolveCognitiveRoutingState } from "../cognitive-routing-v2/config.js";
5
+ import { supportsCognitiveRoutingModelRegistry } from "../cognitive-routing-v2/config.js";
6
6
  export const WORKFLOW_COMMANDS = [
7
7
  { command: "discuss", skill: "discuss" },
8
8
  { command: "action-selection", skill: "action-selection" },
@@ -43,7 +43,6 @@ export const FREEFLOW_MODEL_SKILL_NAMES = [
43
43
  "setup-freeflow",
44
44
  "launch-work",
45
45
  "simplify-code",
46
- "tdd",
47
46
  "track-work",
48
47
  "verify-work",
49
48
  "workflow",
@@ -74,7 +73,6 @@ export function freeflowModelSkillPaths(capabilityState = undefined) {
74
73
  }
75
74
  const SESSION_OVERRIDES_ENTRY = "freeflow-session-overrides";
76
75
  const SESSION_CORE_KEYS = new Set(["enabled", "contextVirtualization", "conversationHistory"]);
77
- export const COGNITIVE_ROUTING_SWITCH_TOOL_NAME = "freeflow_switch_profile";
78
76
  export const FREEFLOW_RUNTIME_STATE_MESSAGE_TYPE = "freeflow-runtime-state";
79
77
  export const COGNITIVE_ROUTING_RUNTIME_STATE_MESSAGE_TYPE = "freeflow-cognitive-routing-runtime-state";
80
78
  export const WORKFLOW_BOOTSTRAP_MESSAGE_TYPE = "freeflow-workflow-bootstrap";
@@ -85,6 +83,30 @@ let currentSessionOverrides = {};
85
83
  export function isPromptAvailable(value) {
86
84
  return typeof value === "string" && value.trim().length > 0;
87
85
  }
86
+ // pi-subagents stamps child system prompts with this tag before binding extensions.
87
+ // Keeping detection at the prompt boundary avoids a process-global child flag.
88
+ export const SUBAGENT_AGENT_TAG = /<active_agent\s+name="[^"]+"\s*\/>/;
89
+ export const FREEFLOW_SUBAGENT_CAPABILITIES_DISABLED_MARKER = "<!-- freeflow-subagent-capabilities: disabled -->";
90
+ const SUBAGENT_OPTIONAL_CAPABILITIES_MESSAGE = "Optional Freeflow capabilities are disabled for subagents.";
91
+ export function isSubagentContext(context) {
92
+ try {
93
+ const systemPrompt = context?.getSystemPrompt?.();
94
+ return (
95
+ typeof systemPrompt === "string" &&
96
+ (SUBAGENT_AGENT_TAG.test(systemPrompt) || systemPrompt.includes(FREEFLOW_SUBAGENT_CAPABILITIES_DISABLED_MARKER))
97
+ );
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+ function disableSubagentCapability(capability) {
103
+ return {
104
+ ...capability,
105
+ enabled: false,
106
+ effective: false,
107
+ blockingReason: { code: "disabled", message: SUBAGENT_OPTIONAL_CAPABILITIES_MESSAGE },
108
+ };
109
+ }
88
110
  export function hasUsableMandatoryPrompts(freeflowContext) {
89
111
  return (
90
112
  isPromptAvailable(freeflowContext?.corePrompt) && isPromptAvailable(freeflowContext?.interactionContractPrompt)
@@ -380,7 +402,8 @@ export async function readCapabilityState(cwd, host = undefined, extensionHost =
380
402
  const enabled = layers.configured && effectiveCore.config.enabled;
381
403
  const contextVirtualizationConfigEnabled = effectiveCore.config.contextVirtualization;
382
404
  const conversationHistoryConfigEnabled = effectiveCore.config.conversationHistory;
383
- const hostSupportsCognitiveRouting = supportsCognitiveRoutingModelRegistry(host);
405
+ const subagentContext = isSubagentContext(host);
406
+ const hostSupportsCognitiveRouting = !subagentContext && supportsCognitiveRoutingModelRegistry(host);
384
407
  const configuredCognitiveRouting = await resolveCognitiveRoutingState(
385
408
  layers.repository.parsed,
386
409
  layers.local.parsed,
@@ -400,7 +423,7 @@ export async function readCapabilityState(cwd, host = undefined, extensionHost =
400
423
  effective: false,
401
424
  blockingReason: disabledReason,
402
425
  };
403
- return {
426
+ const capabilityState = {
404
427
  configured: layers.configured,
405
428
  repositoryConfigured: layers.repositoryConfigured,
406
429
  configExists: layers.repository.exists,
@@ -421,6 +444,13 @@ export async function readCapabilityState(cwd, host = undefined, extensionHost =
421
444
  hostSupportsCognitiveRouting,
422
445
  cognitiveRouting,
423
446
  };
447
+ if (!subagentContext) return capabilityState;
448
+ return {
449
+ ...capabilityState,
450
+ contextVirtualization: disableSubagentCapability(capabilityState.contextVirtualization),
451
+ conversationHistory: disableSubagentCapability(capabilityState.conversationHistory),
452
+ cognitiveRouting: disableSubagentCapability(capabilityState.cognitiveRouting),
453
+ };
424
454
  }
425
455
  export const readRuntimeState = readCapabilityState;
426
456
  export function restoreSessionOverrides(ctx) {
@@ -469,8 +499,8 @@ export function setFreeflowStatus(
469
499
  } else if (cognitiveRoutingActive) {
470
500
  const profile = cognitiveRoutingRuntime.activeProfile;
471
501
  const control =
472
- cognitiveRoutingRuntime.controlMode === "manual-standard" ||
473
- cognitiveRoutingRuntime.controlMode === "manual-reasoning"
502
+ cognitiveRoutingRuntime.controlMode === "manual-executor" ||
503
+ cognitiveRoutingRuntime.controlMode === "manual-coordinator"
474
504
  ? "manual hold"
475
505
  : "automatic";
476
506
  active.push(`${profile} · ${control}`);
@@ -485,8 +515,8 @@ export function setFreeflowStatus(
485
515
  ) {
486
516
  const startupProfile =
487
517
  cognitiveRouting.sessionStart?.control === "manual"
488
- ? (cognitiveRouting.sessionStart.profile ?? "reasoning")
489
- : "reasoning";
518
+ ? (cognitiveRouting.sessionStart.profile ?? "coordinator")
519
+ : "coordinator";
490
520
  active.push(`${startupProfile} · pending`);
491
521
  } else {
492
522
  const reason =
@@ -507,12 +537,12 @@ export function skillPrompt(skill, args) {
507
537
  }
508
538
  function publicCognitiveRoutingControl(controlMode) {
509
539
  if (controlMode === "automatic") return "automatic";
510
- if (controlMode === "manual-standard" || controlMode === "manual-reasoning") return "manual";
540
+ if (controlMode === "manual-executor" || controlMode === "manual-coordinator") return "manual";
511
541
  return "unavailable";
512
542
  }
513
543
  function publicCognitiveRoutingProfile(activeProfile, effective) {
514
544
  if (effective !== true) return "unavailable";
515
- return activeProfile === "standard" || activeProfile === "reasoning" ? activeProfile : "unavailable";
545
+ return activeProfile === "executor" || activeProfile === "coordinator" ? activeProfile : "unavailable";
516
546
  }
517
547
  function publicCapabilityStatus(capability) {
518
548
  if (capability?.effective === true) return "active";
@@ -525,10 +555,22 @@ function publicCognitiveRoutingStatus(capability, runtime) {
525
555
  if (runtime?.runtimeStatus === "inactive") return "inactive";
526
556
  return publicCapabilityStatus(capability);
527
557
  }
558
+ function publicCognitiveRoutingProjectionMode(capability, runtime, projectionFailure) {
559
+ if (capability?.projection !== true) return "disabled";
560
+ if (capability?.effective !== true) return "unavailable";
561
+ if (runtime?.controlMode === "manual-executor" || runtime?.controlMode === "manual-coordinator") {
562
+ return "manual-bypass";
563
+ }
564
+ if (projectionFailure) return "blocked";
565
+ if (runtime?.runtimeStatus === "inactive" || runtime?.runtimeStatus === "blocked") return "unavailable";
566
+ if (runtime?.effective === true && runtime.controlMode === "automatic") return "enabled";
567
+ return "pending";
568
+ }
528
569
  export function freeflowRuntimeStateMessage(
529
570
  capabilityState,
530
571
  cognitiveRoutingRuntime = undefined,
531
572
  freeflowContext = undefined,
573
+ options = {},
532
574
  ) {
533
575
  const cognitiveRoutingEffective = capabilityState?.cognitiveRouting?.effective === true;
534
576
  const profile = publicCognitiveRoutingProfile(
@@ -537,6 +579,11 @@ export function freeflowRuntimeStateMessage(
537
579
  );
538
580
  const control =
539
581
  profile === "unavailable" ? "unavailable" : publicCognitiveRoutingControl(cognitiveRoutingRuntime?.controlMode);
582
+ const projectionMode = publicCognitiveRoutingProjectionMode(
583
+ capabilityState?.cognitiveRouting,
584
+ cognitiveRoutingRuntime,
585
+ options.projectionFailure,
586
+ );
540
587
  const mandatoryPromptAvailable = capabilityState?.enabled !== true || hasUsableMandatoryPrompts(freeflowContext);
541
588
  const freeflowStatus = capabilityState?.configured
542
589
  ? capabilityState.enabled
@@ -565,6 +612,7 @@ export function freeflowRuntimeStateMessage(
565
612
  "Cognitive Routing:",
566
613
  `- Control: \`${control}\``,
567
614
  `- Profile: \`${profile}\``,
615
+ `- Projection: \`${projectionMode}\``,
568
616
  ].join("\n"),
569
617
  display: false,
570
618
  details: { source: "provider-request-runtime-state" },
@@ -596,7 +644,9 @@ export function withFreeflowRuntimeState(
596
644
  options = {},
597
645
  ) {
598
646
  const source = Array.isArray(messages) ? messages : [];
599
- const runtimeState = freeflowRuntimeStateMessage(capabilityState, cognitiveRoutingRuntime, freeflowContext);
647
+ const runtimeState = freeflowRuntimeStateMessage(capabilityState, cognitiveRoutingRuntime, freeflowContext, {
648
+ projectionFailure: options.projectionFailure,
649
+ });
600
650
  const runtimeStateMessages = source.filter(
601
651
  (message) =>
602
652
  message?.customType === FREEFLOW_RUNTIME_STATE_MESSAGE_TYPE ||
@@ -0,0 +1,195 @@
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
+ } from "../cognitive-routing-v2/types.js";
11
+ import { parseRoutingEvent, reduce, replay } from "../cognitive-routing-v2/state.js";
12
+ export class EventStore {
13
+ pi;
14
+ reader;
15
+ observed = new Map();
16
+ attempted = new Map();
17
+ fault;
18
+ ready = false;
19
+ cachedEntries = [];
20
+ cachedState = replay([]);
21
+ constructor(pi, reader) {
22
+ this.pi = pi;
23
+ this.reader = reader;
24
+ }
25
+ get blocked() {
26
+ return this.fault ?? (!this.ready ? "acknowledgment_unclassified" : undefined);
27
+ }
28
+ state() {
29
+ const entries = this.reader.getBranch();
30
+ const prefix =
31
+ this.cachedEntries.length <= entries.length && this.cachedEntries.every((entry, i) => entry === entries[i]);
32
+ if (!prefix) {
33
+ this.cachedState = replay(entries);
34
+ } else {
35
+ let state = this.cachedState;
36
+ for (const entry of entries.slice(this.cachedEntries.length))
37
+ if (entry.type === "custom" && entry.customType === ROUTING_ENTRY)
38
+ state = reduce(state, parseRoutingEvent(entry.data));
39
+ this.cachedState = state;
40
+ }
41
+ this.cachedEntries = [...entries];
42
+ return this.cachedState;
43
+ }
44
+ block(reason) {
45
+ this.fault = reason;
46
+ this.ready = false;
47
+ }
48
+ make(data, operationId = randomUUID(), stepId = data.type) {
49
+ return parseRoutingEvent({
50
+ version: 2,
51
+ eventId: randomUUID(),
52
+ operationId,
53
+ stepId,
54
+ recordedSessionId: this.reader.getSessionId(),
55
+ data,
56
+ });
57
+ }
58
+ async reconcile() {
59
+ const acknowledged = this.ready && !this.fault;
60
+ this.ready = false;
61
+ const branch = this.reader.getBranch();
62
+ const live = branch.filter((e) => e.type === "custom" && e.customType === ROUTING_ENTRY);
63
+ try {
64
+ const ids = new Set();
65
+ let parent = null;
66
+ for (const entry of branch) {
67
+ check(
68
+ typeof entry.id === "string" && !ids.has(entry.id) && entry.parentId === parent,
69
+ "invalid_native_ancestry",
70
+ );
71
+ ids.add(entry.id);
72
+ parent = entry.id;
73
+ }
74
+ check(parent === this.reader.getLeafId(), "invalid_native_ancestry");
75
+ if (
76
+ acknowledged &&
77
+ live.every((entry) => {
78
+ const event = parseRoutingEvent(entry.data);
79
+ const receipt = this.observed.get(eventKey(event));
80
+ return (
81
+ receipt?.entryId === entry.id && receipt.eventId === event.eventId && receipt.value === eventValue(event)
82
+ );
83
+ })
84
+ ) {
85
+ this.ready = true;
86
+ return;
87
+ }
88
+ if (!live.length && !this.attempted.size) {
89
+ this.observed.clear();
90
+ this.fault = undefined;
91
+ this.ready = true;
92
+ return;
93
+ }
94
+ const path = this.reader.getSessionFile();
95
+ check(path, "persisted_snapshot_unavailable", "Existing or uncertain routing state needs a persisted snapshot.");
96
+ const snapshot = await readOnlySessionSnapshot(path);
97
+ check(snapshot.sessionId === this.reader.getSessionId(), "session_identity_changed");
98
+ const persistedBranch = activeReadOnlySessionBranch(snapshot, this.reader.getLeafId());
99
+ check(
100
+ persistedBranch.length === branch.length && persistedBranch.every((e, i) => e.id === branch[i].id),
101
+ "snapshot_ancestry_mismatch",
102
+ "Live branch is not the complete persisted ancestry for its claimed leaf.",
103
+ );
104
+ replay(persistedBranch);
105
+ const disk = new Map(persistedBranch.map((e) => [e.id, e]));
106
+ const known = new Map();
107
+ // Compare the live selected ancestry, not whichever branch was last written on disk.
108
+ for (const entry of branch) {
109
+ const captured = disk.get(entry.id);
110
+ check(
111
+ captured && canonical(captured) === canonical(entry),
112
+ "snapshot_divergence",
113
+ "Live ancestry is not fully established by the fresh persisted snapshot; reconcile the session before retrying.",
114
+ );
115
+ if (entry.type === "custom" && entry.customType === ROUTING_ENTRY) {
116
+ const event = parseRoutingEvent(entry.data);
117
+ known.set(eventKey(event), { value: eventValue(event), eventId: event.eventId, entryId: entry.id });
118
+ }
119
+ }
120
+ for (const [key, event] of this.attempted)
121
+ check(
122
+ known.get(key)?.value === eventValue(event) && known.get(key)?.eventId === event.eventId,
123
+ "append_acknowledgment_uncertain",
124
+ "The attempted routing event is not established by persisted readback.",
125
+ );
126
+ this.observed = known;
127
+ this.attempted.clear();
128
+ this.fault = undefined;
129
+ this.ready = true;
130
+ this.cachedEntries = [];
131
+ this.cachedState = replay([]);
132
+ } catch (error) {
133
+ this.block(
134
+ error instanceof RoutingError
135
+ ? error.code
136
+ : error instanceof ReadOnlySessionError
137
+ ? `${error.code}: ${error.message}`
138
+ : "snapshot_unavailable",
139
+ );
140
+ throw error;
141
+ }
142
+ }
143
+ append(event) {
144
+ const value = parseRoutingEvent(event),
145
+ key = eventKey(value),
146
+ body = eventValue(value);
147
+ if (this.blocked) throw new RoutingError("acknowledgment_uncertain", this.blocked);
148
+ const prior = this.state().events.get(key);
149
+ if (prior) {
150
+ check(eventValue(prior) === body, "operation_conflict");
151
+ const occurrence = this.reader
152
+ .getBranch()
153
+ .find(
154
+ (entry) =>
155
+ entry.type === "custom" && entry.customType === ROUTING_ENTRY && entry.data?.eventId === prior.eventId,
156
+ );
157
+ if (
158
+ this.observed.get(key)?.value !== body ||
159
+ this.observed.get(key)?.eventId !== prior.eventId ||
160
+ this.observed.get(key)?.entryId !== occurrence?.id
161
+ ) {
162
+ this.block("prior_acknowledgment_unclassified");
163
+ throw new RoutingError("acknowledgment_uncertain", this.blocked);
164
+ }
165
+ return prior;
166
+ }
167
+ // Validate the whole state transition before mutating Pi's session memory.
168
+ const candidate = reduce(this.state(), value);
169
+ const baseline = this.cachedEntries;
170
+ this.attempted.set(key, value);
171
+ try {
172
+ this.pi.appendEntry(ROUTING_ENTRY, value);
173
+ const found = this.reader
174
+ .getBranch()
175
+ .find((e) => e.type === "custom" && e.customType === ROUTING_ENTRY && e.data?.eventId === value.eventId);
176
+ check(found && eventValue(parseRoutingEvent(found.data)) === body, "append_not_observed");
177
+ this.observed.set(key, { value: body, eventId: value.eventId, entryId: found.id });
178
+ this.attempted.delete(key);
179
+ const branch = this.reader.getBranch();
180
+ const delta = branch.slice(baseline.length).filter((e) => e.type === "custom" && e.customType === ROUTING_ENTRY);
181
+ if (baseline.every((e, i) => branch[i] === e) && delta.length === 1 && delta[0].id === found.id) {
182
+ // Publish the already validated candidate only after acknowledged native append.
183
+ this.cachedState = candidate;
184
+ this.cachedEntries = [...branch];
185
+ }
186
+ return value;
187
+ } catch (error) {
188
+ this.block("append_acknowledgment_uncertain");
189
+ throw new RoutingError(
190
+ "acknowledgment_uncertain",
191
+ error instanceof Error ? error.message : "Pi append/readback did not acknowledge the event.",
192
+ );
193
+ }
194
+ }
195
+ }
@@ -0,0 +1,148 @@
1
+ import { createHash } from "node:crypto";
2
+ import { open, stat } from "node:fs/promises";
3
+ import { TextDecoder } from "node:util";
4
+ export class ReadOnlySessionError extends Error {
5
+ code;
6
+ line;
7
+ constructor(code, message, line) {
8
+ super(message);
9
+ this.code = code;
10
+ this.line = line;
11
+ this.name = "ReadOnlySessionError";
12
+ }
13
+ }
14
+ export const SESSION_READ_LIMITS = {
15
+ maxBytes: 512 * 1024 * 1024,
16
+ maxEntryBytes: 128 * 1024 * 1024,
17
+ maxEntries: 250000,
18
+ };
19
+ function decodeEntry(text, line, seen) {
20
+ let value;
21
+ try {
22
+ value = JSON.parse(text);
23
+ } catch {
24
+ throw new ReadOnlySessionError("invalid_json", "Incomplete or invalid session JSON.", line);
25
+ }
26
+ if (!value || Array.isArray(value) || typeof value !== "object" || typeof value.type !== "string")
27
+ throw new ReadOnlySessionError("invalid_entry", "Invalid session entry.", line);
28
+ if (line === 1) {
29
+ if (
30
+ value.type !== "session" ||
31
+ typeof value.id !== "string" ||
32
+ !value.id ||
33
+ ![2, 3].includes(value.version) ||
34
+ typeof value.cwd !== "string"
35
+ )
36
+ throw new ReadOnlySessionError("invalid_header", "A supported native session header is required.", line);
37
+ } else {
38
+ if (value.type === "session" || typeof value.id !== "string" || !value.id || seen.has(value.id))
39
+ throw new ReadOnlySessionError("invalid_id", "Missing, duplicate, or misplaced session identity.", line);
40
+ if (value.parentId !== null && (typeof value.parentId !== "string" || !seen.has(value.parentId)))
41
+ throw new ReadOnlySessionError("invalid_parent", "Parent is absent or occurs after its child.", line);
42
+ seen.add(value.id);
43
+ }
44
+ return value;
45
+ }
46
+ export function parseReadOnlySessionText(text) {
47
+ const lines = text.split("\n");
48
+ if (lines.at(-1) === "") lines.pop();
49
+ if (!lines.length || !lines[0]) throw new ReadOnlySessionError("empty", "Session snapshot is empty.");
50
+ const seen = new Set();
51
+ return lines.map((line, i) => decodeEntry(line, i + 1, seen));
52
+ }
53
+ export async function readOnlySessionSnapshot(path, options = {}) {
54
+ const limits = { ...SESSION_READ_LIMITS, ...options };
55
+ if (Object.values(limits).some((n) => !Number.isSafeInteger(n) || n <= 0))
56
+ throw new ReadOnlySessionError("invalid_limit", "Invalid snapshot limit.");
57
+ let file;
58
+ try {
59
+ file = await open(path, "r");
60
+ const before = await file.stat({ bigint: true });
61
+ if (!before.isFile()) throw new ReadOnlySessionError("read_limit", "Session is not a regular file.");
62
+ if (before.size > BigInt(limits.maxBytes))
63
+ throw new ReadOnlySessionError(
64
+ "read_limit",
65
+ `Session exceeds the supported ${limits.maxBytes} byte snapshot limit.`,
66
+ );
67
+ const hash = createHash("sha256"),
68
+ entries = [],
69
+ seen = new Set();
70
+ let total = 0,
71
+ lineBytes = 0,
72
+ parts = [];
73
+ const append = (part) => {
74
+ lineBytes += part.length;
75
+ if (lineBytes > limits.maxEntryBytes)
76
+ throw new ReadOnlySessionError(
77
+ "entry_limit",
78
+ `Session entry exceeds ${limits.maxEntryBytes} bytes.`,
79
+ entries.length + 1,
80
+ );
81
+ if (part.length) parts.push(part);
82
+ };
83
+ const decode = () => {
84
+ if (entries.length >= limits.maxEntries)
85
+ throw new ReadOnlySessionError("entry_count_limit", `Session exceeds ${limits.maxEntries} entries.`);
86
+ let text;
87
+ try {
88
+ text = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(parts, lineBytes));
89
+ } catch {
90
+ throw new ReadOnlySessionError("invalid_encoding", "Session is not complete UTF-8.", entries.length + 1);
91
+ }
92
+ entries.push(decodeEntry(text, entries.length + 1, seen));
93
+ parts = [];
94
+ lineBytes = 0;
95
+ };
96
+ for (;;) {
97
+ const buffer = Buffer.alloc(64 * 1024),
98
+ { bytesRead } = await file.read(buffer, 0, buffer.length, null);
99
+ if (!bytesRead) break;
100
+ total += bytesRead;
101
+ if (total > limits.maxBytes)
102
+ throw new ReadOnlySessionError("read_limit", "Session grew beyond the supported snapshot limit.");
103
+ const bytes = buffer.subarray(0, bytesRead);
104
+ hash.update(bytes);
105
+ let start = 0,
106
+ end;
107
+ while ((end = bytes.indexOf(10, start)) !== -1) {
108
+ append(bytes.subarray(start, end));
109
+ decode();
110
+ start = end + 1;
111
+ }
112
+ append(bytes.subarray(start));
113
+ }
114
+ if (lineBytes) decode();
115
+ if (!entries.length) throw new ReadOnlySessionError("empty", "Session snapshot is empty.");
116
+ const after = await file.stat({ bigint: true }),
117
+ current = await stat(path, { bigint: true });
118
+ const signature = (v) => [v.dev, v.ino, v.size, v.mtimeNs, v.ctimeNs].join(":");
119
+ if (
120
+ signature(before) !== signature(after) ||
121
+ signature(after) !== signature(current) ||
122
+ after.size !== BigInt(total)
123
+ )
124
+ throw new ReadOnlySessionError("source_changed", "Session changed during snapshot capture.");
125
+ return { path, sessionId: entries[0].id, version: entries[0].version, hash: hash.digest("hex"), entries };
126
+ } catch (error) {
127
+ if (error instanceof ReadOnlySessionError) throw error;
128
+ throw new ReadOnlySessionError("read_failed", error instanceof Error ? error.message : String(error));
129
+ } finally {
130
+ await file?.close();
131
+ }
132
+ }
133
+ export function activeReadOnlySessionBranch(snapshot, leafId) {
134
+ if (leafId === null) return [];
135
+ const byId = new Map(snapshot.entries.slice(1).map((entry) => [entry.id, entry]));
136
+ const path = [];
137
+ const seen = new Set();
138
+ let cursor = leafId;
139
+ while (cursor !== null) {
140
+ const entry = byId.get(cursor);
141
+ if (!entry || seen.has(cursor))
142
+ throw new ReadOnlySessionError("ancestry_unavailable", "Requested native ancestry is unavailable.");
143
+ seen.add(cursor);
144
+ path.push(entry);
145
+ cursor = entry.parentId;
146
+ }
147
+ return path.reverse();
148
+ }