@zerwiz/ymir 0.1.16 → 0.1.18

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 (171) hide show
  1. package/.agents/config/app-repos.yaml +38 -0
  2. package/.claude/settings.json +31 -0
  3. package/.codex/hooks.json +43 -0
  4. package/.cursor/hooks.json +33 -0
  5. package/.pi/extensions/README.md +39 -0
  6. package/.pi/extensions/gna-pi-watch.ts +15 -0
  7. package/.pi/extensions/lib/ro-assistant-layout.ts +96 -0
  8. package/.pi/extensions/lib/ro-operational-user-layout.ts +145 -0
  9. package/.pi/extensions/lib/ro-visibility.ts +99 -0
  10. package/.pi/extensions/lib/ro-working-ship.ts +243 -0
  11. package/.pi/extensions/lib/rodd-operational-input.ts +62 -0
  12. package/.pi/extensions/lib/skuld-branch-dispatch.ts +252 -0
  13. package/.pi/extensions/lib/skuld-branch-model-picker.ts +77 -0
  14. package/.pi/extensions/lib/vordr-sessionstart-supervisor.mjs +48 -0
  15. package/.pi/extensions/ro.ts +11 -0
  16. package/.pi/extensions/skuld-branch-supervision.ts +11 -0
  17. package/.pi/extensions/syn-turnend-guard.ts +11 -0
  18. package/.pi/mcp.json +16 -0
  19. package/.pi/mcp.json.example +11 -0
  20. package/.pi/npm/.gitignore +2 -0
  21. package/.pi/settings.json +8 -0
  22. package/.pi/shared/extensions/gna-pi-watch.ts +750 -0
  23. package/.pi/shared/extensions/herdr-agent-state.ts +257 -0
  24. package/.pi/shared/extensions/open-editor.ts +509 -0
  25. package/.pi/shared/extensions/ro.ts +504 -0
  26. package/.pi/shared/extensions/skuld-branch-supervision.ts +1535 -0
  27. package/.pi/shared/extensions/syn-turnend-guard.ts +683 -0
  28. package/.pi/shared/extensions/todo.ts +133 -0
  29. package/.pi/shared/extensions/ymir-subagents.ts +287 -0
  30. package/.pi/shared/extensions/ymir-well.ts +130 -0
  31. package/CHANGELOG.md +23 -0
  32. package/assets/Ymir.md +4654 -0
  33. package/assets/healer.html +717 -0
  34. package/assets/icon-family/family-sheet.png +0 -0
  35. package/assets/icon-family/hlidskjalf.svg +1 -0
  36. package/assets/icon-family/sessrumnir.svg +1 -0
  37. package/assets/icon-family/smidja.svg +1 -0
  38. package/assets/icon-family/ymir.svg +1 -0
  39. package/assets/mimirswell.html +880 -0
  40. package/assets/mock/README.md +16 -0
  41. package/assets/mock/feeds.ts +175 -0
  42. package/assets/mock/mock.ts +408 -0
  43. package/assets/reference/docs/agent-control.md +122 -0
  44. package/assets/reference/docs/apodex/Appdex_as_Orchestrator_Planner_Reviewer_Assessment.md +176 -0
  45. package/assets/reference/docs/apodex/Final Report(1).md +198 -0
  46. package/assets/reference/docs/apodex/Final Report(2).md +54 -0
  47. package/assets/reference/docs/apodex/Final Report(3).md +40 -0
  48. package/assets/reference/docs/apodex/Final Report(4).md +234 -0
  49. package/assets/reference/docs/apodex/Final Report(5).md +13 -0
  50. package/assets/reference/docs/apodex/Final Report.md +82 -0
  51. package/assets/reference/docs/architecture.md +392 -0
  52. package/assets/reference/docs/arm-pretool-check.md +247 -0
  53. package/assets/reference/docs/calm-mode-feasibility.md +541 -0
  54. package/assets/reference/docs/calm.md +59 -0
  55. package/assets/reference/docs/captain-hold-lifecycle.md +96 -0
  56. package/assets/reference/docs/cd-guard.md +165 -0
  57. package/assets/reference/docs/cmux-backend.md +133 -0
  58. package/assets/reference/docs/codex-app-backend.md +57 -0
  59. package/assets/reference/docs/configuration.md +924 -0
  60. package/assets/reference/docs/documentation-audiences.json +468 -0
  61. package/assets/reference/docs/documentation-audiences.md +28 -0
  62. package/assets/reference/docs/examples/crew-dispatch.json +26 -0
  63. package/assets/reference/docs/examples/process-event-extension/file-signal.mjs +96 -0
  64. package/assets/reference/docs/examples/process-event-extension/firstmate-extension.json +15 -0
  65. package/assets/reference/docs/examples/watched-tools.json +24 -0
  66. package/assets/reference/docs/examples/wedge-alarm +22 -0
  67. package/assets/reference/docs/extension-bindings.md +237 -0
  68. package/assets/reference/docs/fm-test-isolation-proof.json +37 -0
  69. package/assets/reference/docs/fm-test-isolation-proof.md +149 -0
  70. package/assets/reference/docs/fm-test-portable-shards.md +118 -0
  71. package/assets/reference/docs/gitlab-merge-watch.md +275 -0
  72. package/assets/reference/docs/herdr-backend.md +346 -0
  73. package/assets/reference/docs/orca-backend.md +84 -0
  74. package/assets/reference/docs/pi-supervision-branch-poster.svg +125 -0
  75. package/assets/reference/docs/pi-supervision-branch.md +96 -0
  76. package/assets/reference/docs/plans/agentic-engineering-workflow.md +395 -0
  77. package/assets/reference/docs/plans/agentic-factory-implementation-plan.md +615 -0
  78. package/assets/reference/docs/plans/command-factory-enhancement-plan.md +420 -0
  79. package/assets/reference/docs/plans/phase-1-layer3-program-design.md +134 -0
  80. package/assets/reference/docs/plans/phase-2-vertical-slices.md +100 -0
  81. package/assets/reference/docs/plans/phase-3-measurable-goals.md +106 -0
  82. package/assets/reference/docs/plans/phase-4-context-budget.md +106 -0
  83. package/assets/reference/docs/plans/phase-5-pre-mortem-confidence.md +100 -0
  84. package/assets/reference/docs/plans/phase-6-incident-to-agent.md +109 -0
  85. package/assets/reference/docs/remote-secondmates.md +268 -0
  86. package/assets/reference/docs/scripts.md +146 -0
  87. package/assets/reference/docs/sessionstart-nudge.md +115 -0
  88. package/assets/reference/docs/subagent-guard.md +383 -0
  89. package/assets/reference/docs/supervision-protocols/claude.md +27 -0
  90. package/assets/reference/docs/supervision-protocols/codex.md +16 -0
  91. package/assets/reference/docs/supervision-protocols/cursor.md +31 -0
  92. package/assets/reference/docs/supervision-protocols/grok.md +39 -0
  93. package/assets/reference/docs/supervision-protocols/opencode.md +17 -0
  94. package/assets/reference/docs/supervision-protocols/pi.md +33 -0
  95. package/assets/reference/docs/supervision-protocols/unknown.md +13 -0
  96. package/assets/reference/docs/tmux-backend.md +115 -0
  97. package/assets/reference/docs/trace-context.md +117 -0
  98. package/assets/reference/docs/turnend-guard.md +170 -0
  99. package/assets/reference/docs/verification/dispatch-auth.md +204 -0
  100. package/assets/reference/docs/verification/muse.md +221 -0
  101. package/assets/reference/docs/verification/process-event-sources.md +208 -0
  102. package/assets/reference/docs/verification/public-followup.md +119 -0
  103. package/assets/reference/docs/verification/runtime-backends.md +1022 -0
  104. package/assets/reference/docs/verification/stow-memory.md +53 -0
  105. package/assets/reference/docs/verification/supervision.md +534 -0
  106. package/assets/reference/docs/verification/trace-context.md +31 -0
  107. package/assets/reference/docs/voice-relay.md +295 -0
  108. package/assets/reference/docs/watcher-continuity.md +119 -0
  109. package/assets/reference/docs/wedge-alarm.md +39 -0
  110. package/assets/reference/docs/zellij-backend.md +114 -0
  111. package/assets/reference/index.html +1172 -0
  112. package/assets/reference/skills/afk/SKILL.md +241 -0
  113. package/assets/reference/skills/ahoy/SKILL.md +56 -0
  114. package/assets/reference/skills/ask-user-authority/SKILL.md +56 -0
  115. package/assets/reference/skills/bearings/SKILL.md +161 -0
  116. package/assets/reference/skills/bearings/assets/board-template.html +731 -0
  117. package/assets/reference/skills/bootstrap-diagnostics/SKILL.md +68 -0
  118. package/assets/reference/skills/captain-hold-lifecycle/SKILL.md +54 -0
  119. package/assets/reference/skills/decision-hold-lifecycle/SKILL.md +15 -0
  120. package/assets/reference/skills/diagnostic-reasoning/SKILL.md +53 -0
  121. package/assets/reference/skills/firstmate-codexapp/SKILL.md +110 -0
  122. package/assets/reference/skills/firstmate-codexapp/agents/openai.yaml +4 -0
  123. package/assets/reference/skills/firstmate-coding-guidelines/SKILL.md +127 -0
  124. package/assets/reference/skills/firstmate-orca/SKILL.md +92 -0
  125. package/assets/reference/skills/fmx-respond/SKILL.md +283 -0
  126. package/assets/reference/skills/harness-adapters/SKILL.md +95 -0
  127. package/assets/reference/skills/harness-adapters/references/common/control-and-recovery.md +37 -0
  128. package/assets/reference/skills/harness-adapters/references/common/dispatch.md +32 -0
  129. package/assets/reference/skills/harness-adapters/references/common/model-and-effort.md +42 -0
  130. package/assets/reference/skills/harness-adapters/references/common/primary-hooks.md +40 -0
  131. package/assets/reference/skills/harness-adapters/references/harness/claude.md +55 -0
  132. package/assets/reference/skills/harness-adapters/references/harness/codex.md +43 -0
  133. package/assets/reference/skills/harness-adapters/references/harness/cursor.md +75 -0
  134. package/assets/reference/skills/harness-adapters/references/harness/grok.md +69 -0
  135. package/assets/reference/skills/harness-adapters/references/harness/kimi.md +51 -0
  136. package/assets/reference/skills/harness-adapters/references/harness/muse.md +70 -0
  137. package/assets/reference/skills/harness-adapters/references/harness/opencode.md +42 -0
  138. package/assets/reference/skills/harness-adapters/references/harness/pi.md +56 -0
  139. package/assets/reference/skills/process-event-sources/SKILL.md +137 -0
  140. package/assets/reference/skills/project-management/SKILL.md +92 -0
  141. package/assets/reference/skills/quota-array-dispatch/SKILL.md +131 -0
  142. package/assets/reference/skills/secondmate-provisioning/SKILL.md +254 -0
  143. package/assets/reference/skills/stow/SKILL.md +309 -0
  144. package/assets/reference/skills/stuck-crewmate-recovery/SKILL.md +54 -0
  145. package/assets/reference/skills/updatefirstmate/SKILL.md +64 -0
  146. package/assets/skills/README.md +5 -0
  147. package/assets/yggdrasil.html +649 -0
  148. package/assets/ymir-banner-01.png +0 -0
  149. package/assets/ymir-banner-02.png +0 -0
  150. package/assets/ymir-banner-03.png +0 -0
  151. package/assets/ymir-banner-04.png +0 -0
  152. package/assets/ymir-banner-05.png +0 -0
  153. package/assets/ymir-banner-06.png +0 -0
  154. package/assets/ymir-emblem-darksteel.svg +63 -0
  155. package/assets/ymir-emblem-forged.svg +55 -0
  156. package/assets/ymir-emblem-slate.svg +55 -0
  157. package/assets/ymir-mark-algiz-anvil.svg +9 -0
  158. package/assets/ymir-stave.svg +53 -0
  159. package/assets/ymir.html +563 -0
  160. package/bin/ymir-install.sh +6 -1
  161. package/data/learnings.md +33 -0
  162. package/data/local-models.md +34 -0
  163. package/data/operator.md +15 -0
  164. package/data/projects.md +28 -0
  165. package/data/realm.md +1 -0
  166. package/deploy/Containerfile +27 -0
  167. package/deploy/README.md +104 -0
  168. package/deploy/compose/compose.yaml +45 -0
  169. package/deploy/env.example +30 -0
  170. package/deploy/quadlet/ymir.container +57 -0
  171. package/package.json +10 -8
@@ -0,0 +1,243 @@
1
+ // Brokk's Ró-only animated working presentation.
2
+ //
3
+ // Ró replaces Pi's stock working row with a tiny SSHHIP-derived boat while one
4
+ // logical agent run is active. This module owns only the sprite geometry, the bounce
5
+ // track, the two animation cadences, the session-scoped freeze/resume state, and the
6
+ // temporary TUI widget; `.pi/extensions/ro.ts` owns when the presentation is
7
+ // installed and removed, and stays the sole caller of setWorkingVisible().
8
+ // docs/ro.md owns the Allfather-facing contract.
9
+ //
10
+ // Cadence: one scheduler drives two logically independent clocks. Every tick advances
11
+ // the water phase, and only every CALM_WORKING_SHIP_TICKS_PER_MOVE-th tick moves the
12
+ // boat, so the water visibly ripples several times between boat steps and the boat
13
+ // itself reads as ro. Both clocks stop together when the widget is disposed. Ticks,
14
+ // not wall-clock timestamps, drive every state change, so tests can seek time exactly.
15
+ //
16
+ // Continuity: one extension-owned animation instance survives hide/show within the same
17
+ // Pi process and Ró extension lifetime. Disposing the widget freezes column,
18
+ // direction, water phase, and tick cadence without advancing them for hidden wall
19
+ // time. The next working period resumes from that exact logical state. A fresh session
20
+ // or new extension lifetime calls reset() and starts at the normal initial position.
21
+ // State is never a module-level or process-global singleton.
22
+ //
23
+ // Verified against Pi 0.81.1 declarations and the Pi 0.82.0 CLI, which expose
24
+ // ExtensionUIContext.setWidget() with a component smidja, per-widget dispose(), and
25
+ // TUI.requestRender(). Pi renders a widget through Component.render(width), so this
26
+ // module recomputes its track from that width on every frame instead of caching a
27
+ // terminal size that a resize would invalidate. A resize while the boat is hidden is
28
+ // applied on the first resumed frame through the same clamp path.
29
+ import type { Component, TUI } from "@earendil-works/pi-tui";
30
+
31
+ // The hull is symmetric and replaces waves on its row rather than adding a third row.
32
+ const HULL = "\\__/";
33
+ // A mainsail extends aft of the mast, so it trails behind the bow relative to travel.
34
+ const SAIL_RIGHT = "<|";
35
+ const SAIL_LEFT = "|>";
36
+ // Centers the two-cell sail over the four-cell hull.
37
+ const SAIL_OFFSET = 1;
38
+ const HULL_WIDTH = HULL.length;
39
+ const SAIL_WIDTH = SAIL_RIGHT.length;
40
+
41
+ // Bounded deterministic fixed-cell water phases. Every entry is exactly one column, so
42
+ // advancing the phase ripples the surface without changing visible width or row count.
43
+ const WAVE_CYCLE = ["~", "~", "-", "~"] as const;
44
+
45
+ // Standard ANSI foreground codes only: no theme lookup, bright variant, or 256/RGB.
46
+ const BLUE = "\u001b[34m";
47
+ const YELLOW = "\u001b[33m";
48
+ // Restores the default foreground so color never bleeds into padding or later frames.
49
+ const RESET = "\u001b[39m";
50
+
51
+ export const RO_WORKING_SHIP_WIDGET_KEY = "brokk-ro-working-ship";
52
+ /** Scheduler period. One tick advances the water by one phase. */
53
+ export const CALM_WORKING_SHIP_TICK_MS = 220;
54
+ /** Boat moves one column every Nth tick, so it travels at 220 * 4 = 880ms per column. */
55
+ export const CALM_WORKING_SHIP_TICKS_PER_MOVE = 4;
56
+
57
+ export type CalmWorkingShipAnimation = {
58
+ /** Render one frame that exactly fits `width`, clamping the track to it first. */
59
+ render(width: number): string[];
60
+ /** Advance one scheduler tick: water every tick, boat on its slower cadence. */
61
+ tick(): void;
62
+ restoreLastRendered(): void;
63
+ /** Restore the normal initial column, direction, water phase, and cadence. */
64
+ reset(): void;
65
+ /**
66
+ * Clamp the frozen column and direction to `width` without advancing time.
67
+ * Used when a terminal resize lands while the working presentation is hidden.
68
+ */
69
+ clampToWidth(width: number): void;
70
+ /** Current hull column, exposed for deterministic motion assertions. */
71
+ position(): number;
72
+ /** Current travel direction: 1 travelling right, -1 travelling left. */
73
+ direction(): number;
74
+ /** Current water phase, exposed for deterministic ripple assertions. */
75
+ waterPhase(): number;
76
+ };
77
+
78
+ /** Longest hull start column that still fits the sprite in `width` usable cells. */
79
+ function trackSpan(width: number): number {
80
+ if (width >= HULL_WIDTH) return width - HULL_WIDTH;
81
+ if (width >= SAIL_WIDTH) return width - SAIL_WIDTH;
82
+ return 0;
83
+ }
84
+
85
+ export function createCalmWorkingShipAnimation(): CalmWorkingShipAnimation {
86
+ let position = 0;
87
+ let direction = 1;
88
+ let span = 0;
89
+ let phase = 0;
90
+ let ticks = 0;
91
+ let renderedPosition = position;
92
+ let renderedDirection = direction;
93
+ let renderedSpan = span;
94
+ let renderedPhase = phase;
95
+ let renderedTicks = ticks;
96
+
97
+ // Reversing the moment the boat lands on an endpoint means the endpoint frame itself
98
+ // already shows the new heading, so no frame at or after a bounce shows the old sail.
99
+ const settleDirectionAtEdges = (): void => {
100
+ if (span <= 0) return;
101
+ if (position >= span) direction = -1;
102
+ else if (position <= 0) direction = 1;
103
+ };
104
+
105
+ const applyWidth = (width: number): void => {
106
+ if (width <= 0) {
107
+ span = 0;
108
+ position = 0;
109
+ return;
110
+ }
111
+ span = trackSpan(width);
112
+ position = Math.min(position, span);
113
+ settleDirectionAtEdges();
114
+ };
115
+
116
+ const commitRenderedState = (): void => {
117
+ renderedPosition = position;
118
+ renderedDirection = direction;
119
+ renderedSpan = span;
120
+ renderedPhase = phase;
121
+ renderedTicks = ticks;
122
+ };
123
+
124
+ const restoreLastRenderedState = (): void => {
125
+ position = renderedPosition;
126
+ direction = renderedDirection;
127
+ span = renderedSpan;
128
+ phase = renderedPhase;
129
+ ticks = renderedTicks;
130
+ };
131
+
132
+ /** One colored run of water covering absolute columns [from, from + count). */
133
+ const water = (from: number, count: number): string => {
134
+ if (count <= 0) return "";
135
+ let cells = "";
136
+ for (let column = from; column < from + count; column += 1) {
137
+ cells += WAVE_CYCLE[(column + phase) % WAVE_CYCLE.length];
138
+ }
139
+ return `${BLUE}${cells}${RESET}`;
140
+ };
141
+
142
+ const boat = (text: string): string => `${YELLOW}${text}${RESET}`;
143
+
144
+ return {
145
+ position: () => position,
146
+ direction: () => direction,
147
+ waterPhase: () => phase,
148
+
149
+ restoreLastRendered: restoreLastRenderedState,
150
+
151
+ reset(): void {
152
+ position = 0;
153
+ direction = 1;
154
+ span = 0;
155
+ phase = 0;
156
+ ticks = 0;
157
+ commitRenderedState();
158
+ },
159
+
160
+ clampToWidth(width: number): void {
161
+ applyWidth(width);
162
+ },
163
+
164
+ tick(): void {
165
+ ticks += 1;
166
+ phase = (phase + 1) % WAVE_CYCLE.length;
167
+ if (ticks % CALM_WORKING_SHIP_TICKS_PER_MOVE !== 0) return;
168
+ if (span <= 0) {
169
+ position = 0;
170
+ return;
171
+ }
172
+ position = Math.min(span, Math.max(0, position + direction));
173
+ settleDirectionAtEdges();
174
+ },
175
+
176
+ render(width: number): string[] {
177
+ if (width <= 0) return [];
178
+
179
+ // A resize lands here before the next frame, so recompute and clamp the track
180
+ // immediately rather than trusting a position measured against the old width.
181
+ applyWidth(width);
182
+
183
+ const sail = direction >= 0 ? SAIL_RIGHT : SAIL_LEFT;
184
+
185
+ let frame: string[];
186
+ if (width < SAIL_WIDTH) {
187
+ // Too narrow for even the sail: a deterministic single row of water.
188
+ frame = [water(0, width)];
189
+ } else if (width < HULL_WIDTH) {
190
+ // Too narrow for the hull: the sail alone rides the water row.
191
+ frame = [
192
+ water(0, position) +
193
+ boat(sail) +
194
+ water(position + SAIL_WIDTH, width - position - SAIL_WIDTH),
195
+ ];
196
+ } else {
197
+ frame = [
198
+ " ".repeat(position + SAIL_OFFSET) + boat(sail),
199
+ water(0, position) +
200
+ boat(HULL) +
201
+ water(position + HULL_WIDTH, width - position - HULL_WIDTH),
202
+ ];
203
+ }
204
+
205
+ commitRenderedState();
206
+ return frame;
207
+ },
208
+ };
209
+ }
210
+
211
+ /**
212
+ * Build the temporary Ró working widget bound to one caller-owned animation.
213
+ * Pi disposes the previous component before installing a replacement under the same
214
+ * key and when it clears extension widgets, so the single scheduler driving both
215
+ * cadences cannot outlive the widget or duplicate. Disposing freezes the shared
216
+ * animation in place; the next widget bound to the same animation resumes without
217
+ * applying hidden wall time.
218
+ */
219
+ export function createCalmWorkingShipWidget(
220
+ tui: TUI,
221
+ animation: CalmWorkingShipAnimation = createCalmWorkingShipAnimation(),
222
+ ): Component & { dispose(): void } {
223
+ let disposed = false;
224
+ const timer = setInterval(() => {
225
+ if (disposed) return;
226
+ animation.tick();
227
+ tui.requestRender();
228
+ }, CALM_WORKING_SHIP_TICK_MS);
229
+ // The animation must never keep Pi's process alive on its own.
230
+ timer.unref?.();
231
+
232
+ return {
233
+ render: (width) => (disposed ? [] : animation.render(width)),
234
+ // Every frame is rebuilt from fixed standard ANSI codes, so there is no cache.
235
+ invalidate: () => {},
236
+ dispose: () => {
237
+ if (disposed) return;
238
+ disposed = true;
239
+ clearInterval(timer);
240
+ animation.restoreLastRendered();
241
+ },
242
+ };
243
+ }
@@ -0,0 +1,62 @@
1
+ // Rödd operational-input bridge.
2
+ //
3
+ // Rödd ("voice") is the structured message wire between the Brokk primary and
4
+ // Ymir's workers. This is the TypeScript half of the protocol; the shell CLI
5
+ // (bin/rodd-operational-input.sh) is the single owner of construction/parsing.
6
+ // Ported from the upstream agent-distro reference.
7
+ import { spawnSync } from "node:child_process";
8
+ import { dirname, resolve } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ const operationalInputScript =
12
+ process.env.RODD_OPERATIONAL_INPUT_SCRIPT ||
13
+ resolve(dirname(fileURLToPath(import.meta.url)), "../../../bin/rodd-operational-input.sh");
14
+
15
+ export const RODD_CURRENT_OPERATIONAL_KINDS = [
16
+ "session-start",
17
+ "watcher",
18
+ "turn-end-guard",
19
+ "away-supervisor",
20
+ "from-brokk",
21
+ "launch-brief",
22
+ "branch-outcome",
23
+ ] as const;
24
+
25
+ export type RoddCurrentOperationalKind =
26
+ (typeof RODD_CURRENT_OPERATIONAL_KINDS)[number];
27
+
28
+ function runOperationalInputCommand(
29
+ command: "encode" | "classify" | "kind",
30
+ content: string,
31
+ kind?: RoddCurrentOperationalKind,
32
+ ): string | undefined {
33
+ const args = command === "encode" ? [command, kind ?? ""] : [command];
34
+ const result = spawnSync(operationalInputScript, args, {
35
+ encoding: "utf8",
36
+ input: content,
37
+ maxBuffer: 1024 * 1024,
38
+ });
39
+ if (result.status !== 0) return undefined;
40
+ return command === "classify" ? result.stdout.replace(/\n$/, "") : result.stdout;
41
+ }
42
+
43
+ export function encodeRoddOperationalInput(
44
+ kind: RoddCurrentOperationalKind,
45
+ content: string,
46
+ ): string {
47
+ const encoded = runOperationalInputCommand("encode", content, kind);
48
+ if (encoded === undefined) {
49
+ throw new Error(`could not encode Rödd operational input kind ${kind}`);
50
+ }
51
+ return encoded;
52
+ }
53
+
54
+ export function classifyRoddOperationalText(content: string): string | undefined {
55
+ return runOperationalInputCommand("classify", content);
56
+ }
57
+
58
+ export function classifyRoddCurrentOperationalText(
59
+ content: string,
60
+ ): string | undefined {
61
+ return runOperationalInputCommand("kind", content);
62
+ }
@@ -0,0 +1,252 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readdirSync, readFileSync } from "node:fs";
3
+
4
+ // Shared wake-dispatch handshake between the Pi watcher extension (the
5
+ // dispatcher) and the supervision-branch extension (the handler), carried over
6
+ // pi.events so neither extension imports the other.
7
+ //
8
+ // Contract: the watcher builds one offer per actionable wake and emits it on
9
+ // SKULD_BRANCH_DISPATCH_EVENT. A live, enabled branch extension calls accept()
10
+ // SYNCHRONOUSLY inside its handler (the event bus invokes handlers
11
+ // synchronously up to their first await), so after emit returns the watcher
12
+ // reads `accepted`: true means the branch now owns delivering and handling the
13
+ // wake (including its own fallback back to main on a later failure); false
14
+ // means no branch took it and the watcher delivers to main exactly as it did
15
+ // before the branch existed. Watcher-failure alarms are never offered - only
16
+ // main can repair the watcher cycle (brokk_watch_arm_pi lives on main).
17
+
18
+ export const SKULD_BRANCH_DISPATCH_EVENT = "skuld-branch-supervision:dispatch";
19
+
20
+ export type UnreadWakeScopeStatus = "safe" | "empty" | "unsafe";
21
+
22
+ export interface UnreadWakeScope {
23
+ status: UnreadWakeScopeStatus;
24
+ eligible: boolean;
25
+ /** Exact project values touched by the currently eligible rows (context only). */
26
+ projects: string[];
27
+ /**
28
+ * The exact durable-queue sequence numbers this scan proved safe for the
29
+ * branch to drain and acknowledge right now (docs/watcher-continuity.md
30
+ * "Per-actor acknowledgement" - the single owner of the consume contract
31
+ * bin/saga-wake-drain.sh implements against this list). Empty whenever
32
+ * `eligible` is false.
33
+ */
34
+ eligibleSeqs: string[];
35
+ /**
36
+ * True only when this scan itself is untrustworthy: the queue or its
37
+ * metadata could not be read, a line fails the structural tab-field check,
38
+ * or an unresolvable signal/stale row was found. False whenever the scan
39
+ * completed cleanly and simply found nothing (or nothing further) eligible
40
+ * for the branch right now: status "unsafe" with corrupted false is the
41
+ * ordinary "ordinary main-only content, nothing here for the branch" case,
42
+ * not a fault, and callers should treat it as ordinary absence rather than
43
+ * escalating. A main-owned check row is never a source of corruption in
44
+ * either mode.
45
+ */
46
+ corrupted: boolean;
47
+ }
48
+
49
+ const EMPTY_SCOPE: UnreadWakeScope = { status: "empty", eligible: false, projects: [], eligibleSeqs: [], corrupted: false };
50
+ const UNSAFE_SCOPE: UnreadWakeScope = { status: "unsafe", eligible: false, projects: [], eligibleSeqs: [], corrupted: true };
51
+
52
+ // scopeForSkuldWake is the single owner of branch-eligibility classification
53
+ // (docs/pi-supervision-branch.md "Autonomy"; docs/watcher-continuity.md
54
+ // "Per-actor acknowledgement"). bin/saga-wake-drain.sh never reclassifies a row
55
+ // itself - it only consumes the exact sequence-number snapshot this function
56
+ // (via writeEligibleRowsSnapshot) hands it.
57
+ //
58
+ // A check-kind row - merge-confirmation polls, Relay mentions, credential/auth
59
+ // failures, and every other legitimately main-only class - never vetoes a scan
60
+ // in either mode. It is simply excluded from eligibleSeqs and left queued for
61
+ // main, which is woken for it on that check's own watcher cycle
62
+ // (brokk-primary-pi-watch.ts forces every check-kind TRIGGER to main), so nothing
63
+ // starves by being left behind.
64
+ //
65
+ // That applies to a heartbeat review too, and it is the whole point: a
66
+ // heartbeat used to be deferred to main merely because some unrelated check
67
+ // row happened to be sitting unread, which put a routine fleet review in the
68
+ // Allfather's chat for a reason that had nothing to do with the fleet. A
69
+ // permanently main-owned row is not fleet context the branch is missing, so it
70
+ // no longer rides the heartbeat into main (docs/pi-supervision-branch.md
71
+ // "Heartbeat routing").
72
+ //
73
+ // The heartbeat's all-or-nothing contract is unchanged in what it actually
74
+ // guarantees: a heartbeat review takes EVERY branch-ownable unread row or none
75
+ // of them. An unresolvable signal/stale row (unmapped project) still vetoes the
76
+ // whole scan in both modes, because that is a data/metadata problem this
77
+ // function cannot safely reason past, not an ordinary main-only event. A row
78
+ // this repo's brokk_wake_append could never have produced (an unknown kind, or a
79
+ // line that fails the structural tab-field check) also still vetoes the whole
80
+ // scan - that is queue corruption, not an everyday mixed queue.
81
+ export function scopeForSkuldWake(state: string, heartbeat: boolean): UnreadWakeScope {
82
+ let queue = "";
83
+ try {
84
+ queue = readFileSync(`${state}/.wake-queue`, "utf8");
85
+ } catch {
86
+ return UNSAFE_SCOPE;
87
+ }
88
+
89
+ const rows = queue.split(/\r?\n/).filter((line) => line.length > 0);
90
+ if (rows.length === 0) return EMPTY_SCOPE;
91
+
92
+ const projects = new Set<string>();
93
+ const metadata = new Map<string, string>();
94
+ try {
95
+ for (const name of readdirSync(state)) {
96
+ if (!name.endsWith(".meta")) continue;
97
+ const task = name.slice(0, -5);
98
+ const fields = readFileSync(`${state}/${name}`, "utf8").split(/\r?\n/);
99
+ const project = fields.find((line) => line.startsWith("project="))?.slice(8) ?? "";
100
+ const window = fields.find((line) => line.startsWith("window="))?.slice(7) ?? "";
101
+ if (project) {
102
+ metadata.set(task, project);
103
+ if (window) metadata.set(window, project);
104
+ }
105
+ }
106
+ } catch {
107
+ return UNSAFE_SCOPE;
108
+ }
109
+
110
+ const eligibleSeqs: string[] = [];
111
+ for (const line of rows) {
112
+ const fields = line.split("\t");
113
+ if (fields.length < 5 || !/^[0-9]+$/.test(fields[1])) return UNSAFE_SCOPE;
114
+ const seq = fields[1];
115
+ const kind = fields[2];
116
+ const key = fields[3];
117
+ if (kind === "heartbeat") {
118
+ if (heartbeat) eligibleSeqs.push(seq);
119
+ continue;
120
+ }
121
+ if (kind === "check") {
122
+ // Always main-owned, in every mode: excluded from what the branch may
123
+ // claim, never a reason to reject the rest of the queue and never a
124
+ // reason to send an otherwise-eligible heartbeat review to main.
125
+ continue;
126
+ }
127
+ let project = "";
128
+ if (kind === "signal") {
129
+ const task = key.replace(/\.(?:status|turn-ended)$/, "");
130
+ project = metadata.get(task) ?? "";
131
+ } else if (kind === "stale") {
132
+ project = metadata.get(key) ?? metadata.get(key.replace(/^brokk-/, "")) ?? "";
133
+ } else {
134
+ // A kind brokk_wake_append never emits: structural corruption, not an
135
+ // ordinary main-only row.
136
+ return UNSAFE_SCOPE;
137
+ }
138
+ if (!project) return UNSAFE_SCOPE;
139
+ projects.add(project);
140
+ eligibleSeqs.push(seq);
141
+ }
142
+ const eligible = eligibleSeqs.length > 0;
143
+ // Reached only after every row passed classification without a veto. A scan
144
+ // that ends up ineligible simply found nothing the branch may claim - a
145
+ // queue of purely main-only content, not a fault. (Before check rows stopped
146
+ // vetoing a heartbeat, this point was unreachable for a heartbeat with an
147
+ // empty eligible set, so reading eligibility off the claim set rather than
148
+ // off the heartbeat flag changes no pre-existing outcome and keeps a
149
+ // heartbeat from being offered with nothing to hand over.)
150
+ return { status: eligible ? "safe" : "unsafe", eligible, projects: [...projects], eligibleSeqs, corrupted: false };
151
+ }
152
+
153
+ // The exact state-relative filename bin/saga-wake-drain.sh reads for a
154
+ // SKULD_ACTOR=branch drain or ack (its header is the single owner of
155
+ // the consume-side contract). Written atomically, immediately before every
156
+ // branch prompt, by writeEligibleRowsSnapshot below.
157
+ export const BRANCH_ELIGIBLE_ROWS_FILE = ".branch-eligible-rows";
158
+
159
+ // Atomically publish the exact row set a branch turn may drain and
160
+ // acknowledge. One sequence number per line - an opaque handoff, never
161
+ // reclassified by the consumer. A main-owned result means the competing main
162
+ // turn won the queue-lock claim and already owns presentation; error means no
163
+ // actor acquired the requested rows.
164
+ export type EligibleRowsSnapshotResult = "published" | "main-owned" | "error";
165
+
166
+ function runGrantScript(state: string, grantScript: string, args: readonly string[]): number | null {
167
+ try {
168
+ const result = spawnSync("bash", [grantScript, ...args], {
169
+ encoding: "utf8",
170
+ env: {
171
+ ...process.env,
172
+ BROKK_STATE_OVERRIDE: state,
173
+ BROKK_WAKE_QUEUE: `${state}/.wake-queue`,
174
+ BROKK_WAKE_QUEUE_LOCK: `${state}/.wake-queue.lock`,
175
+ },
176
+ });
177
+ return result.status;
178
+ } catch {
179
+ return null;
180
+ }
181
+ }
182
+
183
+ export function activateEligibleRowsOwner(
184
+ state: string,
185
+ grantScript: string,
186
+ ownerPid: number,
187
+ generation: string,
188
+ ): boolean {
189
+ return runGrantScript(state, grantScript, ["activate", String(ownerPid), generation]) === 0;
190
+ }
191
+
192
+ export function writeEligibleRowsSnapshot(
193
+ state: string,
194
+ seqs: readonly string[],
195
+ grantScript: string,
196
+ generation: string,
197
+ ): EligibleRowsSnapshotResult {
198
+ if (seqs.length === 0 || seqs.some((seq) => !/^[0-9]+$/.test(seq))) return "error";
199
+ const status = runGrantScript(state, grantScript, ["publish", generation, ...seqs]);
200
+ if (status === 0) return "published";
201
+ if (status === 3) return "main-owned";
202
+ return "error";
203
+ }
204
+
205
+ export function releaseEligibleRowsSnapshot(state: string, grantScript: string, generation: string): boolean {
206
+ return runGrantScript(state, grantScript, ["release", generation]) === 0;
207
+ }
208
+
209
+ export function deactivateEligibleRowsOwner(
210
+ state: string,
211
+ grantScript: string,
212
+ ownerPid: number,
213
+ generation: string,
214
+ ): boolean {
215
+ return runGrantScript(state, grantScript, ["deactivate", String(ownerPid), generation]) === 0;
216
+ }
217
+
218
+ export interface SkuldDispatchOffer {
219
+ /** The watcher's actionable close message (the wake reason line(s)). */
220
+ message: string;
221
+ /**
222
+ * Exact project values from the unread task metadata this wake will drain.
223
+ * Empty means the wake is fleet-wide or could not be scoped safely.
224
+ */
225
+ projects: readonly string[];
226
+ /** True when the watcher classified this wake as a fleet-wide heartbeat scan. */
227
+ heartbeat: boolean;
228
+ /** True only when at least one currently unread row is safe for branch handling. */
229
+ eligible: boolean;
230
+ /** Set by accept(); read by the watcher after emit returns. */
231
+ accepted: boolean;
232
+ accept(): void;
233
+ }
234
+
235
+ export function createSkuldDispatchOffer(
236
+ message: string,
237
+ projects: readonly string[] = [],
238
+ heartbeat = false,
239
+ eligible = false,
240
+ ): SkuldDispatchOffer {
241
+ const offer: SkuldDispatchOffer = {
242
+ message,
243
+ projects: [...projects],
244
+ heartbeat,
245
+ eligible,
246
+ accepted: false,
247
+ accept() {
248
+ offer.accepted = true;
249
+ },
250
+ };
251
+ return offer;
252
+ }
@@ -0,0 +1,77 @@
1
+ // Ordering and filtering for /skuld-model's bounded, searchable model
2
+ // picker. docs/configuration.md owns its operator-facing behavior.
3
+ //
4
+ // This file holds only the choices Brokk owns - which entries exist, in
5
+ // which order, and which survive a search query - so they stay testable
6
+ // without a terminal. The picker's rendering, scrolling, key handling, and
7
+ // branch-only component-choice rationale live beside pickBranchModel in
8
+ // skuld-branch-supervision.ts.
9
+
10
+ /** One row of the supervision-branch picker. */
11
+ export interface BranchPickerItem {
12
+ /** Stable identity of the choice, used to resolve the Allfather's pick. */
13
+ value: string;
14
+ /** What the row shows, and what a search query is matched against. */
15
+ label: string;
16
+ /** Optional trailing note, such as marking the current choice. */
17
+ description?: string;
18
+ }
19
+
20
+ /** Signature of Pi's own `fuzzyFilter`, injected so this file stays UI-free. */
21
+ export type BranchPickerFuzzyFilter = <T>(items: T[], query: string, getText: (item: T) => string) => T[];
22
+
23
+ /**
24
+ * Rows the picker shows at once. Pi's own model selector shows ten, and the
25
+ * bound is what keeps a long catalog scrolling inside the dialog instead of
26
+ * overflowing the terminal.
27
+ */
28
+ export const BRANCH_PICKER_MAX_VISIBLE = 10;
29
+
30
+ /** The stable identity of the "follow main" row, which is always first. */
31
+ export const FOLLOW_MAIN_VALUE = "\0follow-main";
32
+
33
+ /**
34
+ * Builds the picker's rows: "follow main" first, then the eligible models in
35
+ * the order the caller resolved them. The current choice is marked so the
36
+ * Allfather can see what is pinned without leaving the dialog.
37
+ */
38
+ export function buildBranchModelItems(
39
+ followMainLabel: string,
40
+ modelLabels: readonly string[],
41
+ currentPin: string | null,
42
+ ): BranchPickerItem[] {
43
+ const followMain: BranchPickerItem = {
44
+ value: FOLLOW_MAIN_VALUE,
45
+ label: followMainLabel,
46
+ ...(currentPin === null ? { description: "current" } : {}),
47
+ };
48
+ return [
49
+ followMain,
50
+ ...modelLabels.map((label) => ({
51
+ value: label,
52
+ label,
53
+ ...(currentPin !== null && label === currentPin ? { description: "current" } : {}),
54
+ })),
55
+ ];
56
+ }
57
+
58
+ /**
59
+ * Applies a search query while keeping "follow main" first. Pi's fuzzy filter
60
+ * ranks by match quality, which would otherwise be free to sort the "follow
61
+ * main" row below a model, so it is filtered separately and prepended
62
+ * whenever it still matches. An empty query keeps the built order.
63
+ */
64
+ export function filterBranchPickerItems(
65
+ items: readonly BranchPickerItem[],
66
+ query: string,
67
+ fuzzy: BranchPickerFuzzyFilter,
68
+ ): BranchPickerItem[] {
69
+ const trimmed = query.trim();
70
+ if (trimmed === "") return [...items];
71
+ const followMain = items.find((item) => item.value === FOLLOW_MAIN_VALUE);
72
+ const rest = items.filter((item) => item.value !== FOLLOW_MAIN_VALUE);
73
+ const matched = fuzzy([...rest], trimmed, (item) => item.label);
74
+ if (!followMain) return matched;
75
+ const followMainMatches = fuzzy([followMain], trimmed, (item) => item.label).length > 0;
76
+ return followMainMatches ? [followMain, ...matched] : matched;
77
+ }
@@ -0,0 +1,48 @@
1
+ // Vörðr — session-start digest supervisor.
2
+ //
3
+ // Vörðr ("warden/guardian") is the thin process supervisor that runs the Sága
4
+ // session-start digest as a detached child so Pi can stream and cap its output
5
+ // without blocking the session open. Ported from the upstream agent-distro reference.
6
+ import { spawn } from "node:child_process";
7
+
8
+ const [runner, ...args] = process.argv.slice(2);
9
+ let runnerCode;
10
+ let outputBytes = 0;
11
+ let pendingWrites = 0;
12
+ let resultSent = false;
13
+
14
+ const sendResult = () => {
15
+ if (resultSent || runnerCode === undefined || pendingWrites !== 0) return;
16
+ resultSent = true;
17
+ process.send?.({ type: "result", code: runnerCode, bytes: outputBytes });
18
+ };
19
+
20
+ process.on("SIGTERM", () => {});
21
+ process.on("disconnect", () => {
22
+ try {
23
+ process.kill(-process.pid, "SIGKILL");
24
+ } catch {
25
+ process.exit(1);
26
+ }
27
+ });
28
+
29
+ const child = spawn(runner, args, {
30
+ env: { ...process.env, VORDR_SESSIONSTART_SUPERVISOR_PID: String(process.pid) },
31
+ stdio: ["ignore", "pipe", "ignore"],
32
+ });
33
+ child.stdout.on("data", (chunk) => {
34
+ outputBytes += chunk.length;
35
+ pendingWrites += 1;
36
+ process.stdout.write(chunk, () => {
37
+ pendingWrites -= 1;
38
+ sendResult();
39
+ });
40
+ });
41
+ child.on("error", () => {
42
+ runnerCode = null;
43
+ sendResult();
44
+ });
45
+ child.on("close", (code) => {
46
+ runnerCode = code;
47
+ sendResult();
48
+ });