@sangyaahub/pi-marg 1.0.3 → 1.0.5

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.
@@ -1,4 +1,5 @@
1
1
  export type AutoStage = "A" | "B" | "C" | "D";
2
+ export type FallbackLevel = "high" | "low";
2
3
  export type CandidateTier = "frontier" | "execution" | "general";
3
4
  export type RuntimeName = "OMP" | "Pi";
4
5
 
@@ -36,14 +37,27 @@ export interface ModelSelection extends ModelCandidate {
36
37
  }
37
38
 
38
39
  export interface ModelRouteState {
39
- version: 1;
40
+ version: 2;
40
41
  workType?: number;
41
42
  selections: Partial<Record<AutoStage, ModelSelection>>;
43
+ fallbacks: Partial<Record<FallbackLevel, ModelSelection>>;
44
+ activeStage?: AutoStage;
45
+ activeFallback?: FallbackLevel;
46
+ exhaustedSelectors?: string[];
47
+ handledFailureKeys?: string[];
48
+ lastFailover?: {
49
+ stage?: AutoStage;
50
+ failedSelector: string;
51
+ fallbackLevel: FallbackLevel;
52
+ activatedSelector: string;
53
+ switchedAt: string;
54
+ };
42
55
  }
43
56
 
44
57
  export interface ModelRouteParams {
45
- action: "catalog" | "select" | "activate" | "status";
58
+ action: "catalog" | "select" | "select_fallback" | "activate" | "status";
46
59
  stage?: AutoStage;
60
+ fallback?: FallbackLevel;
47
61
  target?: string;
48
62
  workType?: number;
49
63
  provider?: string;
@@ -63,8 +77,66 @@ export interface ModelRouteExecution {
63
77
  response: ReturnType<typeof toolResult>;
64
78
  }
65
79
 
80
+ export interface AutomaticFailoverExecution {
81
+ state: ModelRouteState;
82
+ switched?: {
83
+ level: FallbackLevel;
84
+ selection: ModelSelection;
85
+ };
86
+ exhausted: boolean;
87
+ stateChanged: boolean;
88
+ reusedCurrent?: boolean;
89
+ ignoredDuplicate?: boolean;
90
+ persistenceError?: string;
91
+ }
92
+
93
+ export interface TerminalUsageFailure {
94
+ role: "assistant";
95
+ stopReason: "error";
96
+ errorMessage: string;
97
+ provider?: string;
98
+ model?: string;
99
+ responseId?: string;
100
+ timestamp?: number;
101
+ }
102
+
103
+ export interface ToolUsageFailure {
104
+ selector: string;
105
+ key: string;
106
+ }
107
+
66
108
  export const MODEL_STATE_TYPE = "co.sangyaa.pi-marg.model-routing.v1";
67
109
  export const STAGES: AutoStage[] = ["A", "B", "C", "D"];
110
+ export const FALLBACK_LEVELS: FallbackLevel[] = ["high", "low"];
111
+
112
+ const USAGE_LIMIT_PATTERNS = [
113
+ /usage[\s_-]*limit/i,
114
+ /quota[^\n]{0,40}(?:exceeded|exhausted|reached|depleted|limit)/i,
115
+ /insufficient[_ -]?quota/i,
116
+ /monthly\s+usage\s+limit/i,
117
+ /weekly\s+(?:usage\s+)?limit/i,
118
+ /available\s+balance[^\n]{0,40}(?:\b0\b|depleted|exhausted|insufficient)/i,
119
+ /out\s+of\s+(?:budget|credits?)/i,
120
+ /credits?\s+(?:are\s+)?exhausted/i,
121
+ /agent\s+compute\s+units?[^\n]{0,40}(?:exhausted|depleted|limit|remaining\s*:?\s*0)/i,
122
+ /\bACUs?\b[^\n]*(?:exhausted|limit|remaining\s*:?\s*0)/i,
123
+ ];
124
+
125
+ const ERROR_TEXT_KEYS = ["error", "errorMessage", "message", "stderr", "output", "content"];
126
+ const ERROR_CONTAINER_KEYS = ["details", "result"];
127
+ const MAX_ERROR_TEXT_LENGTH = 32_000;
128
+ const MAX_ERROR_NODES = 200;
129
+ const MAX_HANDLED_FAILURE_KEYS = 32;
130
+ const EXACT_MODEL_EXECUTION_TOOLS = new Set([
131
+ "task",
132
+ "subagent",
133
+ "spawn_agent",
134
+ "run_agent",
135
+ "agent",
136
+ "workflowz",
137
+ "orchestrate",
138
+ "advisor",
139
+ ]);
68
140
 
69
141
  const FRONTIER_PATTERNS: Array<[ModelCandidate["choice"], RegExp]> = [
70
142
  ["claude-frontier", /(?:claude[^\n]*(?:fable|opus)|(?:fable|opus)[^\n]*claude)/i],
@@ -79,7 +151,87 @@ const EXECUTION_PATTERNS: Array<[ModelCandidate["choice"], RegExp]> = [
79
151
  ];
80
152
 
81
153
  export function emptyModelRouteState(): ModelRouteState {
82
- return { version: 1, selections: {} };
154
+ return { version: 2, selections: {}, fallbacks: {} };
155
+ }
156
+
157
+ export function isFallbackLevel(value: unknown): value is FallbackLevel {
158
+ return typeof value === "string" && FALLBACK_LEVELS.includes(value as FallbackLevel);
159
+ }
160
+
161
+ export function isUsageLimitErrorText(value: unknown): boolean {
162
+ if (typeof value !== "string" || value.trim().length === 0) return false;
163
+ return USAGE_LIMIT_PATTERNS.some((pattern) => pattern.test(value));
164
+ }
165
+
166
+ export function hasConfiguredFallback(state: ModelRouteState): boolean {
167
+ const high = state.fallbacks.high;
168
+ const low = state.fallbacks.low;
169
+ return Boolean(
170
+ high
171
+ && low
172
+ && high.access === "runtime-model"
173
+ && low.access === "runtime-model"
174
+ && high.identity !== low.identity,
175
+ );
176
+ }
177
+
178
+ export function findTerminalUsageLimitFailure(messages: readonly unknown[]): TerminalUsageFailure | undefined {
179
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
180
+ const message = asRecord(messages[index]);
181
+ if (message?.role !== "assistant") continue;
182
+ if (message.stopReason !== "error" || typeof message.errorMessage !== "string") return undefined;
183
+ if (!isUsageLimitErrorText(message.errorMessage)) return undefined;
184
+ return {
185
+ role: "assistant",
186
+ stopReason: "error",
187
+ errorMessage: message.errorMessage,
188
+ ...(typeof message.provider === "string" ? { provider: message.provider } : {}),
189
+ ...(typeof message.model === "string" ? { model: message.model } : {}),
190
+ ...(typeof message.responseId === "string" ? { responseId: message.responseId } : {}),
191
+ ...(typeof message.timestamp === "number" ? { timestamp: message.timestamp } : {}),
192
+ };
193
+ }
194
+ return undefined;
195
+ }
196
+
197
+ export function failedModelSelector(message: TerminalUsageFailure, current?: ModelLike): string {
198
+ if (message.provider && message.model) {
199
+ return `${message.provider}/${message.model}`;
200
+ }
201
+ return current?.provider && current?.id ? `${current.provider}/${current.id}` : "unknown-model";
202
+ }
203
+
204
+ export function modelFailureKey(message: TerminalUsageFailure): string {
205
+ if (message.responseId) return `model:${message.responseId}`;
206
+ return `model:${message.provider ?? "unknown"}/${message.model ?? "unknown"}:${message.timestamp ?? "no-time"}:${message.errorMessage}`;
207
+ }
208
+
209
+ export function modelToolUsageLimitFailure(event: unknown): ToolUsageFailure | undefined {
210
+ const record = asRecord(event);
211
+ const toolName = typeof record?.toolName === "string" ? record.toolName : "";
212
+ if (record?.isError !== true || !isModelExecutionTool(toolName)) return undefined;
213
+ if (!isUsageLimitErrorText(boundedErrorText(record.result))) return undefined;
214
+ return {
215
+ selector: isDevinExecutionTool(toolName) ? "devin/external-session" : `tool/${toolName}`,
216
+ key: `tool:${typeof record.toolCallId === "string" ? record.toolCallId : `${toolName}:${boundedErrorText(record.result)}`}`,
217
+ };
218
+ }
219
+
220
+ export function formatFailoverRecoveryMessage(
221
+ state: ModelRouteState,
222
+ failedSelector: string,
223
+ level: FallbackLevel,
224
+ target: string,
225
+ reusedCurrent = false,
226
+ ): string {
227
+ const stage = state.activeStage ? `Stage ${state.activeStage}` : "the active PiMarg stage";
228
+ return [
229
+ `[PiMarg auto-failover] ${failedSelector} reached its usage limit.`,
230
+ reusedCurrent
231
+ ? `Kept the active ${level} backup: ${target}.`
232
+ : `Switched to the ${level} backup: ${target}.`,
233
+ `Resume the current ${stage} work from the last safe point. Inspect existing files, tool results, and external state before repeating any side effect. Do not restart intake or the approved plan.`,
234
+ ].join("\n");
83
235
  }
84
236
 
85
237
  export function isStage(value: unknown): value is AutoStage {
@@ -211,6 +363,13 @@ export function buildModelCatalog(models: ModelLike[], hasExternalDevin: boolean
211
363
  } satisfies Record<AutoStage, ModelCandidate[]>;
212
364
  }
213
365
 
366
+ export function buildFallbackCatalog(catalog: Record<AutoStage, ModelCandidate[]>) {
367
+ return {
368
+ high: catalog.A.filter((candidate) => candidate.access === "runtime-model"),
369
+ low: catalog.B.filter((candidate) => candidate.access === "runtime-model"),
370
+ } satisfies Record<FallbackLevel, ModelCandidate[]>;
371
+ }
372
+
214
373
  export function numberedOptionLabel(index: number, text: string): string {
215
374
  return `${index + 1}. ${text}`;
216
375
  }
@@ -305,6 +464,7 @@ export function formatCatalogPresentation(
305
464
  for (const note of notes) lines.push(`- ${note}`);
306
465
  lines.push("- Present every provider as 1,2,… then every model for the chosen provider as 1,2,… Never omit a logged-in provider.");
307
466
  lines.push("- Select with action=select using the exact selector string.");
467
+ lines.push("- Also save two distinct runtime-only backups with action=select_fallback: high first, then low.");
308
468
 
309
469
  return {
310
470
  text: lines.join("\n"),
@@ -329,27 +489,152 @@ export function validateDistinctSelection(
329
489
  export function restoreModelRouteState(entries: readonly any[]): ModelRouteState {
330
490
  let restored = emptyModelRouteState();
331
491
  for (const entry of entries) {
332
- if (entry?.type === "custom" && entry.customType === MODEL_STATE_TYPE && entry.data?.version === 1) {
333
- restored = entry.data as ModelRouteState;
492
+ if (entry?.type !== "custom" || entry.customType !== MODEL_STATE_TYPE) continue;
493
+ if (entry.data?.version === 2) {
494
+ restored = {
495
+ ...entry.data,
496
+ version: 2,
497
+ selections: entry.data.selections ?? {},
498
+ fallbacks: entry.data.fallbacks ?? {},
499
+ } as ModelRouteState;
500
+ continue;
501
+ }
502
+ if (entry.data?.version === 1) {
503
+ restored = {
504
+ version: 2,
505
+ workType: entry.data.workType,
506
+ selections: entry.data.selections ?? {},
507
+ fallbacks: {},
508
+ };
334
509
  }
335
510
  }
336
511
  return restored;
337
512
  }
338
513
 
514
+ export async function executeAutomaticFailover(
515
+ currentState: ModelRouteState,
516
+ failedSelector: string,
517
+ port: ModelRoutePort,
518
+ failureKey?: string,
519
+ ): Promise<AutomaticFailoverExecution> {
520
+ const failed = failedSelector.trim() || "unknown-model";
521
+ const handledFailureKeys = currentState.handledFailureKeys ?? [];
522
+ if (failureKey && handledFailureKeys.includes(failureKey)) {
523
+ return {
524
+ state: currentState,
525
+ exhausted: false,
526
+ stateChanged: false,
527
+ ignoredDuplicate: true,
528
+ };
529
+ }
530
+ const nextHandledFailureKeys = failureKey
531
+ ? [...handledFailureKeys, failureKey].slice(-MAX_HANDLED_FAILURE_KEYS)
532
+ : handledFailureKeys;
533
+ const exhausted = new Set(currentState.exhaustedSelectors ?? []);
534
+ exhausted.add(failed);
535
+ const activeLevel = currentState.activeFallback;
536
+ const activeSelection = activeLevel ? currentState.fallbacks[activeLevel] : undefined;
537
+
538
+ if (activeLevel && activeSelection && activeSelection.selector !== failed) {
539
+ const nextState: ModelRouteState = {
540
+ ...currentState,
541
+ handledFailureKeys: nextHandledFailureKeys,
542
+ exhaustedSelectors: [...exhausted],
543
+ };
544
+ const persistenceError = persistFailoverState(port, nextState);
545
+ return {
546
+ state: nextState,
547
+ switched: { level: activeLevel, selection: activeSelection },
548
+ exhausted: false,
549
+ stateChanged: true,
550
+ reusedCurrent: true,
551
+ ...(persistenceError ? { persistenceError } : {}),
552
+ };
553
+ }
554
+
555
+ const startingIndex = activeLevel ? FALLBACK_LEVELS.indexOf(activeLevel) + 1 : 0;
556
+
557
+ for (const level of FALLBACK_LEVELS.slice(startingIndex)) {
558
+ const selection = currentState.fallbacks[level];
559
+ if (!selection || selection.access !== "runtime-model" || exhausted.has(selection.selector)) continue;
560
+ const model = port.models.find((item) => `${item.provider}/${item.id}` === selection.selector);
561
+ if (!model) {
562
+ exhausted.add(selection.selector);
563
+ continue;
564
+ }
565
+ let activated: boolean | void;
566
+ try {
567
+ activated = await port.activate(model);
568
+ } catch {
569
+ exhausted.add(selection.selector);
570
+ continue;
571
+ }
572
+ if (activated === false) {
573
+ exhausted.add(selection.selector);
574
+ continue;
575
+ }
576
+
577
+ const nextState: ModelRouteState = {
578
+ ...currentState,
579
+ version: 2,
580
+ activeFallback: level,
581
+ exhaustedSelectors: [...exhausted],
582
+ handledFailureKeys: nextHandledFailureKeys,
583
+ lastFailover: {
584
+ stage: currentState.activeStage,
585
+ failedSelector: failed,
586
+ fallbackLevel: level,
587
+ activatedSelector: selection.selector,
588
+ switchedAt: port.now?.() ?? new Date().toISOString(),
589
+ },
590
+ };
591
+ const persistenceError = persistFailoverState(port, nextState);
592
+ return {
593
+ state: nextState,
594
+ switched: { level, selection },
595
+ exhausted: false,
596
+ stateChanged: true,
597
+ ...(persistenceError ? { persistenceError } : {}),
598
+ };
599
+ }
600
+
601
+ const exhaustedSelectors = [...exhausted];
602
+ const priorExhausted = new Set(currentState.exhaustedSelectors ?? []);
603
+ const stateChanged = exhaustedSelectors.length !== priorExhausted.size
604
+ || exhaustedSelectors.some((selector) => !priorExhausted.has(selector))
605
+ || nextHandledFailureKeys.length !== handledFailureKeys.length;
606
+ if (!stateChanged) {
607
+ return { state: currentState, exhausted: true, stateChanged: false };
608
+ }
609
+ const nextState: ModelRouteState = {
610
+ ...currentState,
611
+ version: 2,
612
+ exhaustedSelectors,
613
+ handledFailureKeys: nextHandledFailureKeys,
614
+ };
615
+ const persistenceError = persistFailoverState(port, nextState);
616
+ return {
617
+ state: nextState,
618
+ exhausted: true,
619
+ stateChanged: true,
620
+ ...(persistenceError ? { persistenceError } : {}),
621
+ };
622
+ }
623
+
339
624
  export async function executeModelRoute(
340
625
  currentState: ModelRouteState,
341
626
  params: ModelRouteParams,
342
627
  port: ModelRoutePort,
343
628
  ): Promise<ModelRouteExecution> {
344
- const catalog = buildModelCatalog(port.models, port.hasExternalDevin);
345
-
346
629
  if (params.action === "catalog") {
630
+ const catalog = buildModelCatalog(port.models, port.hasExternalDevin);
631
+ const fallbackCatalog = buildFallbackCatalog(catalog);
347
632
  if (port.models.length === 0) {
348
633
  return {
349
634
  state: currentState,
350
635
  response: toolResult(
351
636
  emptyCatalogSetupMessage(port.runtimeName),
352
- { runtime: port.runtimeName, catalog, state: currentState },
637
+ { runtime: port.runtimeName, catalog, fallbackCatalog, state: currentState },
353
638
  true,
354
639
  ),
355
640
  };
@@ -377,6 +662,7 @@ export async function executeModelRoute(
377
662
  runtime: port.runtimeName,
378
663
  requiredStages: formatted.presentation.requiredStages,
379
664
  catalog,
665
+ fallbackCatalog,
380
666
  presentation: formatted.presentation,
381
667
  state: currentState,
382
668
  }),
@@ -387,12 +673,59 @@ export async function executeModelRoute(
387
673
  return { state: currentState, response: toolResult(JSON.stringify(currentState, null, 2), { state: currentState }) };
388
674
  }
389
675
 
676
+ if (params.action === "select_fallback") {
677
+ const catalog = buildModelCatalog(port.models, port.hasExternalDevin);
678
+ const fallbackCatalog = buildFallbackCatalog(catalog);
679
+ if (!isFallbackLevel(params.fallback)) {
680
+ return { state: currentState, response: toolResult("A fallback level of high or low is required.", { state: currentState }, true) };
681
+ }
682
+ if (typeof params.target !== "string") {
683
+ return { state: currentState, response: toolResult("Select an exact runtime selector from the current catalog.", { fallbackCatalog, state: currentState }, true) };
684
+ }
685
+ const level = params.fallback;
686
+ if (level === "low" && !currentState.fallbacks.high) {
687
+ return {
688
+ state: currentState,
689
+ response: toolResult("Select the high fallback before the low fallback so the recovery order is explicit.", { fallbackCatalog, state: currentState }, true),
690
+ };
691
+ }
692
+ const selected = fallbackCatalog[level].find((item) => item.selector === params.target);
693
+ if (!selected) {
694
+ return { state: currentState, response: toolResult(`Target ${params.target} is not an available ${level} fallback choice. Refresh the catalog.`, { fallbackCatalog, state: currentState }, true) };
695
+ }
696
+ const otherLevel: FallbackLevel = level === "high" ? "low" : "high";
697
+ const other = currentState.fallbacks[otherLevel];
698
+ if (other?.identity === selected.identity) {
699
+ return { state: currentState, response: toolResult(`The ${level} fallback must use a different model from the ${otherLevel} fallback.`, { fallbackCatalog, state: currentState }, true) };
700
+ }
701
+ const nextState: ModelRouteState = {
702
+ ...currentState,
703
+ version: 2,
704
+ fallbacks: {
705
+ ...currentState.fallbacks,
706
+ [level]: { ...selected, selectedAt: port.now?.() ?? new Date().toISOString() },
707
+ },
708
+ activeFallback: undefined,
709
+ exhaustedSelectors: [],
710
+ handledFailureKeys: [],
711
+ };
712
+ port.persist(nextState);
713
+ return {
714
+ state: nextState,
715
+ response: toolResult(
716
+ `Saved ${selected.label} (${selected.selector}) as the ${level} fallback. It will activate only after a usage-limit failure.`,
717
+ { state: nextState, selected, fallback: level },
718
+ ),
719
+ };
720
+ }
721
+
390
722
  if (!isStage(params.stage)) {
391
723
  return { state: currentState, response: toolResult("A valid stage A, B, C, or D is required.", { state: currentState }, true) };
392
724
  }
393
725
 
394
726
  const stage = params.stage;
395
727
  if (params.action === "select") {
728
+ const catalog = buildModelCatalog(port.models, port.hasExternalDevin);
396
729
  if (typeof params.target !== "string") {
397
730
  return { state: currentState, response: toolResult("Select an exact selector from the current catalog.", { catalog, state: currentState }, true) };
398
731
  }
@@ -412,12 +745,17 @@ export async function executeModelRoute(
412
745
 
413
746
  const workType = Number(params.workType);
414
747
  const nextState: ModelRouteState = {
415
- version: 1,
748
+ ...currentState,
749
+ version: 2,
416
750
  workType: Number.isInteger(workType) && workType >= 1 && workType <= 7 ? workType : currentState.workType,
417
751
  selections: {
418
752
  ...currentState.selections,
419
753
  [stage]: { ...selected, selectedAt: port.now?.() ?? new Date().toISOString() },
420
754
  },
755
+ activeStage: stage,
756
+ activeFallback: undefined,
757
+ exhaustedSelectors: [],
758
+ handledFailureKeys: [],
421
759
  };
422
760
  port.persist(nextState);
423
761
  return {
@@ -437,13 +775,113 @@ export async function executeModelRoute(
437
775
  }
438
776
  if (selected.access === "external-agent") {
439
777
  if (!port.hasExternalDevin) return { state: currentState, response: toolResult("Devin was selected but no external Devin tool is available.", { state: currentState }, true) };
440
- return { state: currentState, response: toolResult(`Stage ${stage} uses an external Devin session. Delegate only after post-web approval.`, { state: currentState, selected }) };
778
+ const nextState: ModelRouteState = {
779
+ ...currentState,
780
+ activeStage: stage,
781
+ activeFallback: undefined,
782
+ exhaustedSelectors: [],
783
+ handledFailureKeys: [],
784
+ };
785
+ port.persist(nextState);
786
+ return { state: nextState, response: toolResult(`Stage ${stage} uses an external Devin session. Delegate only after post-web approval.`, { state: nextState, selected }) };
441
787
  }
442
788
  const model = port.models.find((item) => `${item.provider}/${item.id}` === selected.selector);
443
789
  if (!model) return { state: currentState, response: toolResult(`Saved model ${selected.selector} is unavailable. Select a replacement.`, { state: currentState }, true) };
444
790
  const activated = await port.activate(model);
445
791
  if (activated === false) return { state: currentState, response: toolResult(`Could not activate ${selected.selector}; verify provider authentication.`, { state: currentState }, true) };
446
- return { state: currentState, response: toolResult(`Activated ${selected.label} (${selected.selector}) for Stage ${stage}.`, { state: currentState, selected }) };
792
+ const nextState: ModelRouteState = {
793
+ ...currentState,
794
+ activeStage: stage,
795
+ activeFallback: undefined,
796
+ exhaustedSelectors: [],
797
+ handledFailureKeys: [],
798
+ };
799
+ port.persist(nextState);
800
+ return { state: nextState, response: toolResult(`Activated ${selected.label} (${selected.selector}) for Stage ${stage}.`, { state: nextState, selected }) };
801
+ }
802
+
803
+ function boundedErrorText(value: unknown): string {
804
+ const parts: string[] = [];
805
+ const seen = new Set<object>();
806
+ let remaining = MAX_ERROR_TEXT_LENGTH;
807
+ let nodes = 0;
808
+
809
+ const append = (text: string) => {
810
+ if (remaining <= 0 || text.length === 0) return;
811
+ const fragment = text.slice(0, remaining);
812
+ parts.push(fragment);
813
+ remaining -= fragment.length;
814
+ };
815
+
816
+ const visit = (item: unknown, includePlainText = true) => {
817
+ if (remaining <= 0 || nodes >= MAX_ERROR_NODES || item == null) return;
818
+ nodes += 1;
819
+ if (typeof item === "string") {
820
+ if (includePlainText) append(item);
821
+ return;
822
+ }
823
+ if (typeof item !== "object") {
824
+ if (includePlainText) append(String(item));
825
+ return;
826
+ }
827
+ if (seen.has(item)) return;
828
+ seen.add(item);
829
+
830
+ if (item instanceof Error) {
831
+ append(item.message);
832
+ return;
833
+ }
834
+ if (Array.isArray(item)) {
835
+ for (const child of item) visit(child, includePlainText);
836
+ return;
837
+ }
838
+
839
+ const record = item as Record<string, unknown>;
840
+ if (includePlainText && record.type === "text" && typeof record.text === "string") append(record.text);
841
+ for (const key of ERROR_TEXT_KEYS) {
842
+ if (!(key in record)) continue;
843
+ try {
844
+ visit(record[key], true);
845
+ } catch {
846
+ // Ignore getters that throw while inspecting an external tool error.
847
+ }
848
+ }
849
+ for (const key of ERROR_CONTAINER_KEYS) {
850
+ if (!(key in record)) continue;
851
+ try {
852
+ visit(record[key], false);
853
+ } catch {
854
+ // Ignore getters that throw while inspecting an external tool error.
855
+ }
856
+ }
857
+ };
858
+
859
+ visit(value);
860
+ return parts.join("\n");
861
+ }
862
+
863
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
864
+ return typeof value === "object" && value !== null ? value as Record<string, unknown> : undefined;
865
+ }
866
+
867
+ function isDevinExecutionTool(toolName: string): boolean {
868
+ return /^(?:mcp__)?devin(?:__|_|$)/i.test(toolName);
869
+ }
870
+
871
+ function isModelExecutionTool(toolName: string): boolean {
872
+ const normalized = toolName.toLowerCase();
873
+ return EXACT_MODEL_EXECUTION_TOOLS.has(normalized)
874
+ || /^(?:spawn_agent|run_agent|subagent)(?:__|_|$)/.test(normalized)
875
+ || /^(?:mcp__)?(?:devin|workflowz)(?:__|_|$)/.test(normalized);
876
+ }
877
+
878
+ function persistFailoverState(port: ModelRoutePort, state: ModelRouteState): string | undefined {
879
+ try {
880
+ port.persist(state);
881
+ return undefined;
882
+ } catch (error) {
883
+ return error instanceof Error ? error.message : String(error);
884
+ }
447
885
  }
448
886
 
449
887
  function candidate(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sangyaahub/pi-marg",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "A governed, model-routed development path for Pi Agent and Oh My Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -71,4 +71,4 @@ if [[ "${runtime}" == "omp" || "${runtime}" == "both" ]]; then
71
71
  omp plugin install --scope user pi-marg@pi-marg-marketplace
72
72
  fi
73
73
 
74
- echo "PiMarg v1.0.3 installed for ${runtime}. Start a fresh runtime session, then run /pi-marg."
74
+ echo "PiMarg v1.0.5 installed for ${runtime}. Start a fresh runtime session, then run /pi-marg."
@@ -14,10 +14,38 @@ done
14
14
 
15
15
  bun -e '
16
16
  import { existsSync } from "node:fs";
17
+ const isMapping = (value) =>
18
+ value !== null && !Array.isArray(value) && typeof value === "object";
19
+
20
+ function assertNpmPublishWorkflow(workflow, path) {
21
+ const publish = workflow.jobs.publish;
22
+ const steps = publish?.steps;
23
+ const setupNode = steps?.find((step) => step?.uses === "actions/setup-node@v7");
24
+ const commands = steps?.map((step) => step?.run).filter(Boolean) ?? [];
25
+
26
+ if (
27
+ !isMapping(workflow.on) ||
28
+ !("workflow_dispatch" in workflow.on) ||
29
+ workflow.permissions?.contents !== "read" ||
30
+ workflow.permissions?.["id-token"] !== "write" ||
31
+ !isMapping(publish) ||
32
+ publish.if !== "github.ref == \u0027refs/heads/main\u0027" ||
33
+ publish.environment !== "npm-publish" ||
34
+ publish["runs-on"] !== "ubuntu-latest" ||
35
+ !Array.isArray(steps) ||
36
+ setupNode?.with?.["node-version"] !== 24 ||
37
+ setupNode?.with?.["registry-url"] !== "https://registry.npmjs.org" ||
38
+ setupNode?.with?.["package-manager-cache"] !== false ||
39
+ !commands.includes("npm publish --provenance")
40
+ ) {
41
+ throw new Error(`Unsafe npm publishing workflow: ${path}`);
42
+ }
43
+ }
44
+
17
45
  const packageJson = await Bun.file("package.json").json();
18
46
  const marketplace = await Bun.file(".omp-plugin/marketplace.json").json();
19
47
  if (packageJson.name !== "@sangyaahub/pi-marg") throw new Error("Unexpected package name");
20
- if (packageJson.version !== "1.0.3") throw new Error("package.json must be v1.0.3");
48
+ if (packageJson.version !== "1.0.5") throw new Error("package.json must be v1.0.5");
21
49
  if (packageJson.license !== "MIT") throw new Error("package.json must declare MIT");
22
50
  if (packageJson.author !== "Sangyaa") throw new Error("Unexpected package author");
23
51
  if (!packageJson.keywords?.includes("pi-package")) throw new Error("Pi catalog keyword is missing");
@@ -60,6 +88,21 @@ bun -e '
60
88
  throw new Error(`Invalid skill frontmatter: ${path}`);
61
89
  }
62
90
  }
91
+ for await (const path of new Bun.Glob(".github/workflows/*.{yml,yaml}").scan(".")) {
92
+ const text = await Bun.file(path).text();
93
+ const workflow = Bun.YAML.parse(text);
94
+ if (
95
+ !isMapping(workflow) ||
96
+ typeof workflow.name !== "string" ||
97
+ !("on" in workflow) ||
98
+ !isMapping(workflow.jobs)
99
+ ) {
100
+ throw new Error(`Invalid GitHub Actions workflow structure: ${path}`);
101
+ }
102
+ if (path === ".github/workflows/npm-deploy.yml") {
103
+ assertNpmPublishWorkflow(workflow, path);
104
+ }
105
+ }
63
106
  for await (const path of new Bun.Glob("**/*").scan({ cwd: ".", onlyFiles: true })) {
64
107
  if (path === "scripts/validate.sh") continue;
65
108
  if (/\.(?:png|jpe?g|gif|webp)$/i.test(path)) continue;
@@ -10,7 +10,7 @@ Turn a user request into one controlled workflow.
10
10
  1. Read [intake-and-state.md](references/intake-and-state.md) and complete the intake stages in order.
11
11
  2. After the user selects mode 1–4, read [routing-matrix.md](references/routing-matrix.md) and select exactly one primary skill spine.
12
12
  3. Read [session-ledger.md](references/session-ledger.md), create the neutral Markdown ledger, and identify the selected spine's authoritative planning artifact.
13
- 4. Read [models-and-tools.md](references/models-and-tools.md). Call `auto_model_route` with `action: catalog` and the confirmed work type, present the live choices, and save each required A/B/C/D selection. The runtime rejects A=C and B=D.
13
+ 4. Read [models-and-tools.md](references/models-and-tools.md). Call `auto_model_route` with `action: catalog` and the confirmed work type, present the live choices, and save each required A/B/C/D selection. The runtime rejects A=C and B=D. Then save two distinct runtime-only recovery models with `action: select_fallback`: high first, low second.
14
14
  5. Call `auto_runtime_status`. If it reports `OMP`, read [native-omp-runtime.md](references/native-omp-runtime.md); if it reports `Pi`, read [native-pi-runtime.md](references/native-pi-runtime.md). Record which advisor, task, review, checkpoint/rewind, code-intelligence, debugger, browser/desktop, security, and memory routes are actually available.
15
15
  6. For work types 2–6, load `codegraph-first` and pass its bootstrap gate before making repository-wide architecture or impact claims. CodeGraph owns those claims when available. LSP, AST, compiler, and repository-native tools may handle precise operations; missing CodeGraph must be recorded rather than concealed behind broad grep output.
16
16
  7. Load `auto-mode-addons` only for requested or configured Ponytail, Hindsight, language-specific, or cost-control support. An add-on never becomes a second workflow spine.
@@ -80,6 +80,8 @@ Ask only for stages required by the current work type. Work types 2–3 normally
80
80
 
81
81
  Use `action: select` for each answer with the **exact selector** string (for example `devin/swe-2`), not the display number alone. The active adapter saves exact selectors in session state and immediately activates the selected runtime model. Native `devin/swe-*` selectors behave like other models. An external Devin session requires `post-web` approval before creation.
82
82
 
83
+ After the stage models, choose two distinct authenticated runtime models from the same live catalog: a strong **high** backup and an economical **low** backup. Save them with `action: select_fallback`. Do not choose an external-agent route as a backup because automatic recovery requires `setModel` in the current session.
84
+
83
85
  ## Stage 5 — native runtime and supporting capabilities
84
86
 
85
87
  Call `auto_runtime_status` before making a recommendation. Distinguish `available`, `enabled`, and `used`; a tool installed but gated off did not run.
@@ -109,6 +111,7 @@ type: 1..7
109
111
  mode: 1..4
110
112
  spine: compound-engineering | superpowers | gsd | none
111
113
  models: A=<selector> B=<selector|n/a> C=<selector> D=<selector|n/a>
114
+ fallbacks: high=<runtime-selector> low=<different-runtime-selector> active=<none|high|low|exhausted>
112
115
  runtime: advisor=<off|available|on|unavailable> orchestration=<serial|task|workflowz> checkpoint=<off|available|used>
113
116
  addons: ponytail=<off|lite|full> memory=<off|local|hindsight-recall|hindsight-retain> language=<names|none> cost=<economy|balanced|quality>
114
117
  verification: tests=<planned|n/a> dap=<planned|n/a> ui=<browser|desktop|n/a> security=<planned|n/a>