@hassangameryt/freeflow 0.1.0 → 0.2.0

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 (89) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +8 -4
  3. package/package.json +15 -2
  4. package/plugins/freeflow/.claude-plugin/plugin.json +1 -1
  5. package/plugins/freeflow/.codex-plugin/plugin.json +1 -1
  6. package/plugins/freeflow/command-surface.json +2 -10
  7. package/plugins/freeflow/docs/README.md +3 -2
  8. package/plugins/freeflow/docs/adr/0002-evals-before-hooks.md +1 -1
  9. package/plugins/freeflow/docs/adr/0003-release-boundary.md +1 -1
  10. package/plugins/freeflow/docs/adr/0004-research-replaces-shallow-discovery-skills.md +26 -0
  11. package/plugins/freeflow/docs/adr/README.md +2 -1
  12. package/plugins/freeflow/docs/architecture.md +3 -3
  13. package/plugins/freeflow/docs/output-router.md +212 -0
  14. package/plugins/freeflow/docs/release-evidence.md +25 -5
  15. package/plugins/freeflow/docs/skills.md +6 -7
  16. package/plugins/freeflow/docs/workflow.md +1 -1
  17. package/plugins/freeflow/hooks/freeflow-runtime-context.mjs +23 -6
  18. package/plugins/freeflow/pi-extension/index.js +941 -15
  19. package/plugins/freeflow/router/dist/benchmark-harness.d.ts +32 -0
  20. package/plugins/freeflow/router/dist/benchmark-harness.js +92 -0
  21. package/plugins/freeflow/router/dist/benchmarks.d.ts +102 -0
  22. package/plugins/freeflow/router/dist/benchmarks.js +880 -0
  23. package/plugins/freeflow/router/dist/bounded-evidence.d.ts +40 -0
  24. package/plugins/freeflow/router/dist/bounded-evidence.js +310 -0
  25. package/plugins/freeflow/router/dist/codex-qa-benchmarks.d.ts +91 -0
  26. package/plugins/freeflow/router/dist/codex-qa-benchmarks.js +461 -0
  27. package/plugins/freeflow/router/dist/command-benchmarks.d.ts +128 -0
  28. package/plugins/freeflow/router/dist/command-benchmarks.js +652 -0
  29. package/plugins/freeflow/router/dist/config.d.ts +22 -0
  30. package/plugins/freeflow/router/dist/config.js +114 -0
  31. package/plugins/freeflow/router/dist/evidence-range-selector.d.ts +22 -0
  32. package/plugins/freeflow/router/dist/evidence-range-selector.js +180 -0
  33. package/plugins/freeflow/router/dist/evidence-search.d.ts +25 -0
  34. package/plugins/freeflow/router/dist/evidence-search.js +450 -0
  35. package/plugins/freeflow/router/dist/evidence.d.ts +38 -0
  36. package/plugins/freeflow/router/dist/evidence.js +196 -0
  37. package/plugins/freeflow/router/dist/experimental-local-index.d.ts +1 -0
  38. package/plugins/freeflow/router/dist/experimental-local-index.js +1 -0
  39. package/plugins/freeflow/router/dist/experiments/local-index.d.ts +58 -0
  40. package/plugins/freeflow/router/dist/experiments/local-index.js +562 -0
  41. package/plugins/freeflow/router/dist/index-benchmarks.d.ts +84 -0
  42. package/plugins/freeflow/router/dist/index-benchmarks.js +458 -0
  43. package/plugins/freeflow/router/dist/index.d.ts +12 -0
  44. package/plugins/freeflow/router/dist/index.js +12 -0
  45. package/plugins/freeflow/router/dist/line-ranges.d.ts +22 -0
  46. package/plugins/freeflow/router/dist/line-ranges.js +22 -0
  47. package/plugins/freeflow/router/dist/parsers.d.ts +16 -0
  48. package/plugins/freeflow/router/dist/parsers.js +473 -0
  49. package/plugins/freeflow/router/dist/repo-traversal.d.ts +18 -0
  50. package/plugins/freeflow/router/dist/repo-traversal.js +242 -0
  51. package/plugins/freeflow/router/dist/retrieve.d.ts +33 -0
  52. package/plugins/freeflow/router/dist/retrieve.js +855 -0
  53. package/plugins/freeflow/router/dist/router-contract.d.ts +10 -0
  54. package/plugins/freeflow/router/dist/router-contract.js +54 -0
  55. package/plugins/freeflow/router/dist/run.d.ts +26 -0
  56. package/plugins/freeflow/router/dist/run.js +356 -0
  57. package/plugins/freeflow/router/dist/schema.d.ts +18 -0
  58. package/plugins/freeflow/router/dist/schema.js +354 -0
  59. package/plugins/freeflow/router/dist/types.d.ts +234 -0
  60. package/plugins/freeflow/router/dist/types.js +9 -0
  61. package/plugins/freeflow/router/dist/vault.d.ts +80 -0
  62. package/plugins/freeflow/router/dist/vault.js +484 -0
  63. package/plugins/freeflow/skills/commit-work/SKILL.md +17 -16
  64. package/plugins/freeflow/skills/commit-work/references/staging-decisions.md +3 -3
  65. package/plugins/freeflow/skills/execute-plan/SKILL.md +70 -5
  66. package/plugins/freeflow/skills/execute-plan/references/execution-map.md +122 -0
  67. package/plugins/freeflow/skills/mode-contract/SKILL.md +1 -1
  68. package/plugins/freeflow/skills/output-router/SKILL.md +69 -0
  69. package/plugins/freeflow/skills/output-router/references/safety-policy.md +33 -0
  70. package/plugins/freeflow/skills/research/ARTIFACT-DESTINATIONS.md +58 -0
  71. package/plugins/freeflow/skills/research/CHECKPOINTS.md +74 -0
  72. package/plugins/freeflow/skills/research/LANGUAGE.md +61 -0
  73. package/plugins/freeflow/skills/research/SKILL.md +128 -0
  74. package/plugins/freeflow/skills/review-artifact/SKILL.md +34 -1
  75. package/plugins/freeflow/skills/review-artifact/references/reviewer-prompt.md +12 -1
  76. package/plugins/freeflow/skills/review-work/SKILL.md +25 -2
  77. package/plugins/freeflow/skills/review-work/references/reviewer-prompt.md +8 -0
  78. package/plugins/freeflow/skills/setup-freeflow/SKILL.md +14 -4
  79. package/plugins/freeflow/skills/setup-freeflow/references/activation-contract.md +8 -3
  80. package/plugins/freeflow/skills/setup-freeflow/references/host-setup.md +3 -1
  81. package/plugins/freeflow/skills/setup-freeflow/references/output-router-setup.md +52 -0
  82. package/plugins/freeflow/skills/workflow/SKILL.md +11 -3
  83. package/plugins/freeflow/skills/workflow/references/workflow-map.md +10 -11
  84. package/plugins/freeflow/skills/write-skill/SKILL.md +1 -1
  85. package/plugins/freeflow/skills/capture-decisions/SKILL.md +0 -95
  86. package/plugins/freeflow/skills/capture-decisions/references/destination-guide.md +0 -65
  87. package/plugins/freeflow/skills/grill-context/SKILL.md +0 -79
  88. package/plugins/freeflow/skills/grill-context/references/approach-framing.md +0 -52
  89. package/plugins/freeflow/skills/research-brief/SKILL.md +0 -69
@@ -1,11 +1,22 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { readFile } from "node:fs/promises";
2
3
  import { join } from "node:path";
3
4
 
5
+ import {
6
+ createVault,
7
+ findExactDuplicateTextOutput,
8
+ freeflowRetrieve,
9
+ freeflowRun,
10
+ isNativeSafetyNetEnabled,
11
+ normalizeRouterConfig,
12
+ storeTextOutput,
13
+ textOutputFingerprints,
14
+ } from "../router/dist/index.js";
15
+
4
16
  const VALID_MODES = new Set(["conversation", "workflow", "strict-workflow"]);
5
17
 
6
18
  const WORKFLOW_COMMANDS = [
7
- "grill-context",
8
- "research-brief",
19
+ "research",
9
20
  "write-spec",
10
21
  "review-artifact",
11
22
  "write-plan",
@@ -14,7 +25,6 @@ const WORKFLOW_COMMANDS = [
14
25
  "verify-work",
15
26
  "review-work",
16
27
  "commit-work",
17
- "capture-decisions",
18
28
  "handoff",
19
29
  "bypass",
20
30
  ];
@@ -27,18 +37,25 @@ const CONTRIBUTOR_COMMANDS = [
27
37
 
28
38
  const MODE_STATE_ENTRY = "freeflow-mode";
29
39
  const RESET_MODE_ARGS = new Set(["reset"]);
40
+ const SAFETY_NET_NATIVE_TOOLS = new Set(["read", "bash"]);
41
+ const SAFETY_NET_EXCERPT_LINES = 8;
42
+ const SAFETY_NET_HEURISTIC_MIN_BYTES = 16_384;
43
+ const SAFETY_NET_HEURISTIC_MIN_LINES = 200;
30
44
 
31
45
  let runtimeContextCache = null;
32
46
  let currentModeOverride = null;
47
+ let lastRouterConfigWarningKey = null;
33
48
 
34
49
  async function loadRuntimeContext() {
35
- const [workflowSkill, workflowMap, interviewGateSkill] = await Promise.all([
50
+ const [workflowSkill, workflowMap, interviewGateSkill, outputRouterSkill, outputRouterSafetyPolicy] = await Promise.all([
36
51
  readFile(new URL("../skills/workflow/SKILL.md", import.meta.url), "utf8"),
37
52
  readFile(new URL("../skills/workflow/references/workflow-map.md", import.meta.url), "utf8"),
38
53
  readFile(new URL("../skills/interview-gate/SKILL.md", import.meta.url), "utf8"),
54
+ readFile(new URL("../skills/output-router/SKILL.md", import.meta.url), "utf8"),
55
+ readFile(new URL("../skills/output-router/references/safety-policy.md", import.meta.url), "utf8"),
39
56
  ]);
40
57
 
41
- return { workflowSkill, workflowMap, interviewGateSkill };
58
+ return { workflowSkill, workflowMap, interviewGateSkill, outputRouterSkill, outputRouterSafetyPolicy };
42
59
  }
43
60
 
44
61
  async function refreshRuntimeContext() {
@@ -53,16 +70,40 @@ async function getRuntimeContext() {
53
70
  return refreshRuntimeContext();
54
71
  }
55
72
 
56
- async function readDefaultMode(cwd) {
73
+ async function readFreeflowConfig(cwd) {
57
74
  try {
58
75
  const raw = await readFile(join(cwd, ".freeflow/config.json"), "utf8");
59
76
  const parsed = JSON.parse(raw);
60
- return VALID_MODES.has(parsed.defaultMode) ? parsed.defaultMode : "workflow";
77
+ return parsed && typeof parsed === "object" ? parsed : {};
61
78
  } catch {
62
- return "workflow";
79
+ return {};
63
80
  }
64
81
  }
65
82
 
83
+ async function readDefaultMode(cwd) {
84
+ const parsed = await readFreeflowConfig(cwd);
85
+ return VALID_MODES.has(parsed.defaultMode) ? parsed.defaultMode : "workflow";
86
+ }
87
+
88
+ async function readOutputRouterConfig(cwd) {
89
+ const parsed = await readFreeflowConfig(cwd);
90
+ return normalizeRouterConfig(parsed.outputRouter);
91
+ }
92
+
93
+ function notifyRouterConfigWarnings(ctx, routerConfigResult) {
94
+ if (!routerConfigResult.warnings.length) {
95
+ return;
96
+ }
97
+
98
+ const key = routerConfigResult.warnings.join("\n");
99
+ if (key === lastRouterConfigWarningKey) {
100
+ return;
101
+ }
102
+
103
+ lastRouterConfigWarningKey = key;
104
+ ctx.ui.notify(`Freeflow outputRouter config warning: ${routerConfigResult.warnings.join(" ")}`, "warning");
105
+ }
106
+
66
107
  function restoreModeOverride(ctx) {
67
108
  currentModeOverride = null;
68
109
  const entries = ctx.sessionManager?.getEntries?.() ?? [];
@@ -111,7 +152,45 @@ function hasFreeflowActivation(systemPrompt) {
111
152
  );
112
153
  }
113
154
 
114
- function runtimeContext(modeState, freeflowContext, alreadyActivated) {
155
+ function hasOutputRouterActivation(systemPrompt) {
156
+ return (
157
+ systemPrompt.includes("## Loaded Output Router Skill") &&
158
+ systemPrompt.includes("## Loaded Output Router Safety Policy")
159
+ );
160
+ }
161
+
162
+ function outputRouterModeGuidance(mode) {
163
+ if (mode === "conversation") {
164
+ return "conversation mode: keep routed-tool guidance soft; answer questions directly.";
165
+ }
166
+ if (mode === "strict-workflow") {
167
+ return "strict-workflow mode: strongest guidance; prefer exact, recoverable routed evidence for risky work.";
168
+ }
169
+ return "workflow mode: prefer routed tools for exploration and likely-large command output.";
170
+ }
171
+
172
+ function outputRouterContext(modeState, freeflowContext, routerConfigResult) {
173
+ const safetyNetText =
174
+ routerConfigResult.config.postToolRouting === "off"
175
+ ? ""
176
+ : "\n\nOutput-router config note: large native read/bash outputs may be vaulted and replaced with labeled routed output. Use freeflow_retrieve with the output id to recover exact content.";
177
+
178
+ return `## Loaded Output Router Skill
179
+
180
+ Mode guidance: ${outputRouterModeGuidance(modeState.effectiveMode)}${safetyNetText}
181
+
182
+ \`\`\`md
183
+ ${freeflowContext.outputRouterSkill.trim()}
184
+ \`\`\`
185
+
186
+ ## Loaded Output Router Safety Policy
187
+
188
+ \`\`\`md
189
+ ${freeflowContext.outputRouterSafetyPolicy.trim()}
190
+ \`\`\``;
191
+ }
192
+
193
+ function runtimeContext(modeState, freeflowContext, routerConfigResult, alreadyActivated, routerAlreadyActivated) {
115
194
  const currentMode = modeState.currentMode ?? "none";
116
195
 
117
196
  if (alreadyActivated) {
@@ -121,7 +200,7 @@ Repo default mode from \`.freeflow/config.json\`: ${modeState.defaultMode}.
121
200
  Current session mode override: ${currentMode}.
122
201
  Effective Freeflow mode: ${modeState.effectiveMode}.
123
202
 
124
- Use the installed Freeflow skills when they match the task. This Pi extension loads context and routes commands only; it does not enforce policy, block tools, grant permissions, or create repo-local hooks.`;
203
+ Use the installed Freeflow skills when they match the task. This Pi extension loads context and routes commands only; it does not enforce policy, block tools, grant permissions, or create repo-local hooks.${routerAlreadyActivated ? "" : `\n\n${outputRouterContext(modeState, freeflowContext, routerConfigResult)}`}`;
125
204
  }
126
205
 
127
206
  return `# Freeflow Runtime Context
@@ -138,7 +217,7 @@ Treat the effective mode as the current mode for this agent turn.
138
217
  For mode changes or mode interpretation, use \`mode-contract\`.
139
218
  Do not announce the current mode on every reply. Mention it when the user asks, setup/config is discussed, or the mode changes the next action.
140
219
 
141
- ## Loaded Workflow Skill
220
+ ${routerAlreadyActivated ? "" : `${outputRouterContext(modeState, freeflowContext, routerConfigResult)}\n\n`}## Loaded Workflow Skill
142
221
 
143
222
  \`\`\`md
144
223
  ${freeflowContext.workflowSkill.trim()}
@@ -159,6 +238,829 @@ ${freeflowContext.workflowMap.trim()}
159
238
  This Pi extension loads context and routes commands only; it does not enforce policy, block tools, grant permissions, or create repo-local hooks.`;
160
239
  }
161
240
 
241
+ const STRING_SCHEMA = { type: "string" };
242
+ const PRESERVE_SCHEMA = { type: "string", enum: ["summary", "important", "full"] };
243
+ const RETRIEVE_ACTION_SCHEMA = {
244
+ type: "string",
245
+ enum: ["query", "locate", "retrieve", "expand", "explain"],
246
+ };
247
+ const EXPANSION_SCHEMA = { type: "string", enum: ["lines_30", "lines_80", "full"] };
248
+ const STREAM_SCHEMA = { type: "string", enum: ["stdout", "stderr", "combined", "raw"] };
249
+
250
+ const FREEFLOW_RETRIEVE_PARAMETERS = {
251
+ type: "object",
252
+ additionalProperties: false,
253
+ properties: {
254
+ action: { ...RETRIEVE_ACTION_SCHEMA, description: "Retrieval action to perform." },
255
+ source: {
256
+ type: "object",
257
+ additionalProperties: false,
258
+ properties: {
259
+ kind: { type: "string", enum: ["repo", "vault"] },
260
+ path: { ...STRING_SCHEMA, description: "Repo path for source.kind=repo." },
261
+ outputId: { ...STRING_SCHEMA, description: "Vault output id for source.kind=vault." },
262
+ stream: { ...STREAM_SCHEMA, description: "Vault stream to read." },
263
+ },
264
+ required: ["kind"],
265
+ description: "Source to retrieve from. Repo root and vault session are supplied by Freeflow/Pi.",
266
+ },
267
+ query: { ...STRING_SCHEMA, description: "Text query for query/locate actions." },
268
+ preserve: { ...PRESERVE_SCHEMA, description: "Fidelity mode. Default: important." },
269
+ evidence: {
270
+ type: "object",
271
+ additionalProperties: true,
272
+ description: "Evidence packet from a previous freeflow_retrieve result, used for expand.",
273
+ },
274
+ expansion: { ...EXPANSION_SCHEMA, description: "Expansion breadth for expand action." },
275
+ maxFullBytes: { type: "number", description: "Cap for preserve=full before exact chunks are returned." },
276
+ topK: { type: "number", description: "Number of ranked repo candidates for query/locate. Defaults: query=1, locate=5; max 10." },
277
+ lineRange: {
278
+ type: "object",
279
+ additionalProperties: false,
280
+ properties: {
281
+ start: { type: "number" },
282
+ end: { type: "number" },
283
+ },
284
+ required: ["start", "end"],
285
+ description: "Exact 1-based line range for vault retrieve.",
286
+ },
287
+ decision: {
288
+ type: "object",
289
+ additionalProperties: true,
290
+ description: "Prior routed result to explain.",
291
+ },
292
+ },
293
+ required: ["action", "source"],
294
+ };
295
+
296
+ const FREEFLOW_RUN_PARAMETERS = {
297
+ type: "object",
298
+ additionalProperties: false,
299
+ properties: {
300
+ command: { ...STRING_SCHEMA, description: "Shell command to run through Pi's approved command runner." },
301
+ cwd: { ...STRING_SCHEMA, description: "Working directory. Defaults to the current Pi cwd." },
302
+ timeoutMs: { type: "number", description: "Optional timeout in milliseconds." },
303
+ preserve: { ...PRESERVE_SCHEMA, description: "Fidelity mode. Default: important." },
304
+ goal: { ...STRING_SCHEMA, description: "Goal such as verification, test, build, or search." },
305
+ },
306
+ required: ["command"],
307
+ };
308
+
309
+ function stableHash(value) {
310
+ return createHash("sha256").update(value).digest("hex");
311
+ }
312
+
313
+ function getRouterSessionId(ctx) {
314
+ return ctx.sessionManager?.getSessionId?.() ?? `pi_${stableHash(ctx.cwd).slice(0, 16)}`;
315
+ }
316
+
317
+ function routedToolText(result) {
318
+ return JSON.stringify(result, null, 2);
319
+ }
320
+
321
+ function textComponent(text) {
322
+ return {
323
+ render(width = 120) {
324
+ const maxWidth = Number.isFinite(width) ? Math.max(1, width) : 120;
325
+ return String(text).split("\n").map((line) => truncateAnsiToWidth(line, maxWidth));
326
+ },
327
+ invalidate() {},
328
+ };
329
+ }
330
+
331
+ function truncateAnsiToWidth(input, width) {
332
+ const text = String(input);
333
+ let output = "";
334
+ let visible = 0;
335
+
336
+ for (let index = 0; index < text.length;) {
337
+ const ansi = readAnsiSequence(text, index);
338
+ if (ansi) {
339
+ output += ansi.sequence;
340
+ index = ansi.end;
341
+ continue;
342
+ }
343
+
344
+ if (visible >= width - 1) {
345
+ output += "…";
346
+ return output;
347
+ }
348
+
349
+ const codePoint = text.codePointAt(index);
350
+ const character = String.fromCodePoint(codePoint);
351
+ output += character;
352
+ visible += 1;
353
+ index += character.length;
354
+ }
355
+
356
+ return output;
357
+ }
358
+
359
+ function readAnsiSequence(text, index) {
360
+ if (text.charCodeAt(index) !== 0x1b) {
361
+ return null;
362
+ }
363
+
364
+ const next = text[index + 1];
365
+ if (next === "[") {
366
+ let end = index + 2;
367
+ while (end < text.length && !/[\x40-\x7e]/.test(text[end])) {
368
+ end += 1;
369
+ }
370
+ return { sequence: text.slice(index, Math.min(end + 1, text.length)), end: Math.min(end + 1, text.length) };
371
+ }
372
+
373
+ if (next === "]") {
374
+ const bellEnd = text.indexOf("\x07", index + 2);
375
+ const stEnd = text.indexOf("\x1b\\", index + 2);
376
+ const candidates = [bellEnd, stEnd === -1 ? -1 : stEnd + 1].filter((value) => value !== -1);
377
+ const end = candidates.length > 0 ? Math.min(...candidates) + 1 : text.length;
378
+ return { sequence: text.slice(index, end), end };
379
+ }
380
+
381
+ return { sequence: text.slice(index, Math.min(index + 2, text.length)), end: Math.min(index + 2, text.length) };
382
+ }
383
+
384
+ function themeFg(theme, color, text) {
385
+ return typeof theme?.fg === "function" ? theme.fg(color, text) : text;
386
+ }
387
+
388
+ function themeBold(theme, text) {
389
+ return typeof theme?.bold === "function" ? theme.bold(text) : text;
390
+ }
391
+
392
+ function formatStatus(theme, status) {
393
+ const text = String(status ?? "unknown");
394
+ if (text === "ok" || text === "success" || text === "routed" || text === "passed_through") {
395
+ return themeFg(theme, "success", text);
396
+ }
397
+ if (text === "error" || text === "failed") {
398
+ return themeFg(theme, "error", text);
399
+ }
400
+ return themeFg(theme, "warning", text);
401
+ }
402
+
403
+ function statusIcon(status) {
404
+ if (status === "success" || status === "ok") {
405
+ return "✓";
406
+ }
407
+ if (status === "failed" || status === "error") {
408
+ return "✗";
409
+ }
410
+ return "!";
411
+ }
412
+
413
+ function oneLine(value) {
414
+ return String(value ?? "").replace(/\s+/g, " ").trim();
415
+ }
416
+
417
+ function truncateText(value, maxLength = 120) {
418
+ const text = oneLine(value);
419
+ if (text.length <= maxLength) {
420
+ return text;
421
+ }
422
+ return `${text.slice(0, Math.max(0, maxLength - 1))}…`;
423
+ }
424
+
425
+ function shortenMiddle(value, maxLength = 80) {
426
+ const text = oneLine(value);
427
+ if (text.length <= maxLength) {
428
+ return text;
429
+ }
430
+ if (maxLength <= 5) {
431
+ return text.slice(0, maxLength);
432
+ }
433
+ const keep = maxLength - 1;
434
+ const head = Math.ceil(keep * 0.45);
435
+ const tail = Math.floor(keep * 0.55);
436
+ return `${text.slice(0, head)}…${text.slice(-tail)}`;
437
+ }
438
+
439
+ function commandLabel(command) {
440
+ if (Array.isArray(command)) {
441
+ return truncateText(command.join(" "), 120);
442
+ }
443
+ return truncateText(command ?? "...", 120);
444
+ }
445
+
446
+ function retrieveSourceLabel(source) {
447
+ if (!source || source.kind === "repo") {
448
+ return `repo ${shortenMiddle(source?.path ?? ".", 80)}`;
449
+ }
450
+ if (source.kind === "vault") {
451
+ const stream = source.stream ? `:${source.stream}` : "";
452
+ return `vault ${shortenMiddle(source.outputId ?? "...", 48)}${stream}`;
453
+ }
454
+ return oneLine(source.kind ?? "source");
455
+ }
456
+
457
+ function evidenceLabel(evidence) {
458
+ if (!evidence) {
459
+ return "no evidence";
460
+ }
461
+ const source = evidence.source;
462
+ const path = evidence.path ?? (source?.kind === "repo" ? source.path : source?.kind === "vault" ? source.outputId : "evidence");
463
+ const lines = evidence.lines ? `:${evidence.lines}` : "";
464
+ return `${shortenMiddle(path, 90)}${lines}`;
465
+ }
466
+
467
+ function excerptLines(theme, excerpt, indent = " ", maxLines = 8) {
468
+ const lines = splitLines(String(excerpt ?? ""));
469
+ const shown = lines.slice(0, maxLines).map((line) => `${indent}${themeFg(theme, "toolOutput", truncateText(line, 140))}`);
470
+ const omitted = lines.length - shown.length;
471
+ if (omitted > 0) {
472
+ shown.push(`${indent}${themeFg(theme, "dim", `… ${omitted} more line(s)`)}`);
473
+ }
474
+ return shown;
475
+ }
476
+
477
+ function routeSummaryLine(theme, result) {
478
+ const route = result?.routing?.status ?? "unknown";
479
+ const preserve = result?.preserve ? ` • preserve: ${result.preserve}` : "";
480
+ return `${themeFg(theme, "muted", "routing:")} ${formatStatus(theme, route)}${themeFg(theme, "dim", preserve)}`;
481
+ }
482
+
483
+ function routerResultFromToolResult(result) {
484
+ return result?.details?.result;
485
+ }
486
+
487
+ function fallbackResultText(result) {
488
+ const text = extractTextContent(result?.content);
489
+ return text ? truncateText(text, 200) : "No Freeflow result details available.";
490
+ }
491
+
492
+ function renderFreeflowRetrieveCall(args, theme) {
493
+ const title = themeFg(theme, "toolTitle", themeBold(theme, "freeflow_retrieve"));
494
+ const action = themeFg(theme, "muted", args?.action ?? "query");
495
+ const source = themeFg(theme, "accent", retrieveSourceLabel(args?.source));
496
+ const query = args?.query ? ` ${themeFg(theme, "dim", `\"${truncateText(args.query, 70)}\"`)}` : "";
497
+ return textComponent(`${title} ${action} ${source}${query}`);
498
+ }
499
+
500
+ function renderFreeflowRunCall(args, theme) {
501
+ const title = themeFg(theme, "toolTitle", themeBold(theme, "freeflow_run"));
502
+ const command = themeFg(theme, "accent", `$ ${commandLabel(args?.command)}`);
503
+ const extras = [];
504
+ if (args?.preserve) {
505
+ extras.push(`preserve=${args.preserve}`);
506
+ }
507
+ if (args?.timeoutMs !== undefined) {
508
+ extras.push(`timeout=${args.timeoutMs}ms`);
509
+ }
510
+ const suffix = extras.length > 0 ? ` ${themeFg(theme, "dim", `(${extras.join(", ")})`)}` : "";
511
+ return textComponent(`${title} ${command}${suffix}`);
512
+ }
513
+
514
+ function renderFreeflowRetrieveResult(result, { expanded } = {}, theme) {
515
+ const routed = routerResultFromToolResult(result);
516
+ if (!routed) {
517
+ return textComponent(fallbackResultText(result));
518
+ }
519
+
520
+ const evidence = Array.isArray(routed.evidence) ? routed.evidence : [];
521
+ const firstEvidence = evidence[0];
522
+ const icon = statusIcon(routed.toolStatus);
523
+ const lines = [
524
+ `${themeFg(theme, "success", icon)} ${themeFg(theme, "toolTitle", "freeflow_retrieve")} ${themeFg(theme, "muted", `${evidence.length} evidence packet(s)`)} • ${routeSummaryLine(theme, routed)}`,
525
+ ];
526
+
527
+ if (firstEvidence) {
528
+ lines.push(`${themeFg(theme, "accent", evidenceLabel(firstEvidence))}${firstEvidence.expandable ? themeFg(theme, "dim", " • expandable") : ""}`);
529
+ } else {
530
+ lines.push(themeFg(theme, "warning", truncateText(routed.routing?.reason ?? "No matching evidence returned.", 140)));
531
+ }
532
+
533
+ if (!expanded) {
534
+ lines.push(themeFg(theme, "dim", "ctrl+o to expand evidence and recovery details"));
535
+ return textComponent(lines.join("\n"));
536
+ }
537
+
538
+ lines.push("", themeFg(theme, "toolTitle", "Routing"));
539
+ lines.push(` ${themeFg(theme, "muted", "toolStatus:")} ${formatStatus(theme, routed.toolStatus)}`);
540
+ lines.push(` ${themeFg(theme, "muted", "routing.status:")} ${formatStatus(theme, routed.routing?.status)}`);
541
+ lines.push(` ${themeFg(theme, "muted", "route:")} ${routed.routing?.route ?? "retrieve"}`);
542
+ if (routed.routing?.reason) {
543
+ lines.push(` ${themeFg(theme, "muted", "reason:")} ${truncateText(routed.routing.reason, 160)}`);
544
+ }
545
+
546
+ lines.push("", themeFg(theme, "toolTitle", "Evidence"));
547
+ if (evidence.length === 0) {
548
+ lines.push(` ${themeFg(theme, "dim", "No evidence packets returned.")}`);
549
+ } else {
550
+ evidence.forEach((packet, index) => {
551
+ lines.push(` ${themeFg(theme, "accent", `#${index + 1} ${evidenceLabel(packet)}`)} ${themeFg(theme, "dim", `window=${packet.window}`)}`);
552
+ if (packet.why) {
553
+ lines.push(` ${themeFg(theme, "muted", "why:")} ${truncateText(packet.why, 160)}`);
554
+ }
555
+ lines.push(...excerptLines(theme, packet.excerpt, " ", 8));
556
+ });
557
+ }
558
+
559
+ if (routed.recovery?.how) {
560
+ lines.push("", themeFg(theme, "toolTitle", "Recovery"));
561
+ lines.push(` ${truncateText(routed.recovery.how, 180)}`);
562
+ if (routed.recovery.outputId) {
563
+ lines.push(` ${themeFg(theme, "muted", "outputId:")} ${themeFg(theme, "accent", routed.recovery.outputId)}`);
564
+ }
565
+ if (routed.recovery.evidenceId) {
566
+ lines.push(` ${themeFg(theme, "muted", "evidenceId:")} ${themeFg(theme, "accent", routed.recovery.evidenceId)}`);
567
+ }
568
+ }
569
+
570
+ return textComponent(lines.join("\n"));
571
+ }
572
+
573
+ function renderFreeflowRunResult(result, { expanded } = {}, theme, context = {}) {
574
+ const routed = routerResultFromToolResult(result);
575
+ if (!routed) {
576
+ return textComponent(fallbackResultText(result));
577
+ }
578
+
579
+ const executionStatus = routed.execution?.status ?? routed.toolStatus;
580
+ const outputId = routed.outputId || routed.recovery?.outputId;
581
+ const icon = statusIcon(executionStatus);
582
+ const importantLines = Array.isArray(routed.importantLines) ? routed.importantLines : [];
583
+ const lines = [
584
+ `${themeFg(theme, executionStatus === "failed" ? "error" : "success", icon)} ${themeFg(theme, "toolTitle", "freeflow_run")} ${themeFg(theme, "muted", "execution:")} ${formatStatus(theme, executionStatus)} • ${routeSummaryLine(theme, routed)}`,
585
+ ];
586
+
587
+ const statusParts = [];
588
+ if (routed.execution?.exitCode !== undefined && routed.execution?.exitCode !== null) {
589
+ statusParts.push(`exit ${routed.execution.exitCode}`);
590
+ }
591
+ if (routed.execution?.durationMs !== undefined) {
592
+ statusParts.push(`${routed.execution.durationMs}ms`);
593
+ }
594
+ if (outputId) {
595
+ statusParts.push(`outputId ${outputId}`);
596
+ }
597
+ if (routed.parser?.name) {
598
+ statusParts.push(`parser ${routed.parser.name} ${Number(routed.parser.confidence ?? 0).toFixed(2)}`);
599
+ }
600
+ if (statusParts.length > 0) {
601
+ lines.push(themeFg(theme, "accent", statusParts.join(" • ")));
602
+ }
603
+ if (routed.summary) {
604
+ lines.push(themeFg(theme, "muted", truncateText(routed.summary, 140)));
605
+ }
606
+ lines.push(themeFg(theme, "dim", `${importantLines.length} important span(s) • raw output recoverable from vault`));
607
+
608
+ if (!expanded) {
609
+ lines.push(themeFg(theme, "dim", "ctrl+o to expand status, evidence, and vault recovery"));
610
+ return textComponent(lines.join("\n"));
611
+ }
612
+
613
+ lines.push("", themeFg(theme, "toolTitle", "Command"));
614
+ lines.push(` ${themeFg(theme, "accent", `$ ${commandLabel(context.args?.command)}`)}`);
615
+
616
+ lines.push("", themeFg(theme, "toolTitle", "Status"));
617
+ lines.push(` ${themeFg(theme, "muted", "toolStatus:")} ${formatStatus(theme, routed.toolStatus)}`);
618
+ lines.push(` ${themeFg(theme, "muted", "execution.status:")} ${formatStatus(theme, routed.execution?.status)}`);
619
+ lines.push(` ${themeFg(theme, "muted", "routing.status:")} ${formatStatus(theme, routed.routing?.status)}`);
620
+ if (routed.execution?.exitCode !== undefined) {
621
+ lines.push(` ${themeFg(theme, "muted", "exitCode:")} ${routed.execution.exitCode}`);
622
+ }
623
+ if (routed.execution?.durationMs !== undefined) {
624
+ lines.push(` ${themeFg(theme, "muted", "durationMs:")} ${routed.execution.durationMs}`);
625
+ }
626
+
627
+ if (routed.routing?.reason || routed.summary) {
628
+ lines.push("", themeFg(theme, "toolTitle", "Routing"));
629
+ if (routed.summary) {
630
+ lines.push(` ${themeFg(theme, "muted", "summary:")} ${truncateText(routed.summary, 160)}`);
631
+ }
632
+ if (routed.routing?.reason) {
633
+ lines.push(` ${themeFg(theme, "muted", "reason:")} ${truncateText(routed.routing.reason, 180)}`);
634
+ }
635
+ }
636
+
637
+ if (routed.parser?.name) {
638
+ lines.push("", themeFg(theme, "toolTitle", "Parser"));
639
+ lines.push(` ${themeFg(theme, "muted", "name:")} ${routed.parser.name}`);
640
+ lines.push(` ${themeFg(theme, "muted", "confidence:")} ${Number(routed.parser.confidence ?? 0).toFixed(2)}`);
641
+ lines.push(` ${themeFg(theme, "muted", "fidelity:")} ${routed.parser.fidelity ?? "exact"}`);
642
+ lines.push(` ${themeFg(theme, "muted", "compressed:")} ${String(Boolean(routed.parser.compressed))}`);
643
+ if (routed.parser.counts && Object.keys(routed.parser.counts).length > 0) {
644
+ lines.push(` ${themeFg(theme, "muted", "counts:")} ${truncateText(JSON.stringify(routed.parser.counts), 160)}`);
645
+ }
646
+ if (Array.isArray(routed.parser.references) && routed.parser.references.length > 0) {
647
+ const refs = routed.parser.references.slice(0, 3).map((ref) => `${ref.path}${ref.line ? `:${ref.line}${ref.column ? `:${ref.column}` : ""}` : ""}${ref.code ? ` ${ref.code}` : ""}`);
648
+ lines.push(` ${themeFg(theme, "muted", "references:")} ${truncateText(refs.join(", "), 180)}`);
649
+ }
650
+ }
651
+
652
+ lines.push("", themeFg(theme, "toolTitle", "Evidence"));
653
+ if (importantLines.length === 0) {
654
+ lines.push(` ${themeFg(theme, "dim", "No important lines selected.")}`);
655
+ } else {
656
+ importantLines.forEach((line, index) => {
657
+ lines.push(` ${themeFg(theme, "accent", `#${index + 1} ${line.stream} lines ${line.lines}`)}`);
658
+ lines.push(...excerptLines(theme, line.excerpt, " ", 8));
659
+ });
660
+ }
661
+
662
+ if (routed.recovery?.how || outputId) {
663
+ lines.push("", themeFg(theme, "toolTitle", "Vault recovery"));
664
+ if (outputId) {
665
+ lines.push(` ${themeFg(theme, "muted", "outputId:")} ${themeFg(theme, "accent", outputId)}`);
666
+ }
667
+ if (routed.recovery?.how) {
668
+ lines.push(` ${truncateText(routed.recovery.how, 180)}`);
669
+ }
670
+ }
671
+
672
+ return textComponent(lines.join("\n"));
673
+ }
674
+
675
+ function byteLength(text) {
676
+ return Buffer.byteLength(text, "utf8");
677
+ }
678
+
679
+ function splitLines(text) {
680
+ if (text.length === 0) {
681
+ return [];
682
+ }
683
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
684
+ }
685
+
686
+ function extractTextContent(content) {
687
+ if (!Array.isArray(content) || content.length === 0) {
688
+ return null;
689
+ }
690
+
691
+ if (!content.every((part) => part?.type === "text" && typeof part.text === "string")) {
692
+ return null;
693
+ }
694
+
695
+ return content.map((part) => part.text).join("\n");
696
+ }
697
+
698
+ async function nativeResultText(event) {
699
+ const fullOutputPath = event.toolName === "bash" ? event.details?.fullOutputPath : undefined;
700
+ if (typeof fullOutputPath === "string" && fullOutputPath.length > 0) {
701
+ try {
702
+ return await readFile(fullOutputPath, "utf8");
703
+ } catch {
704
+ // Fall back to the exact text that would otherwise enter model context.
705
+ }
706
+ }
707
+
708
+ return extractTextContent(event.content);
709
+ }
710
+
711
+ function nativeOutputStats(text, truncation) {
712
+ const measuredLines = splitLines(text).length;
713
+ const measuredBytes = byteLength(text);
714
+ return {
715
+ lines: Number.isInteger(truncation?.totalLines) ? truncation.totalLines : measuredLines,
716
+ bytes: Number.isInteger(truncation?.totalBytes) ? truncation.totalBytes : measuredBytes,
717
+ capturedLines: measuredLines,
718
+ capturedBytes: measuredBytes,
719
+ };
720
+ }
721
+
722
+ function isSafetyNetEnabled(routerConfig) {
723
+ return isNativeSafetyNetEnabled(routerConfig.postToolRouting);
724
+ }
725
+
726
+ function shouldRouteNativeToolResult(event, routerConfig, text) {
727
+ if (!SAFETY_NET_NATIVE_TOOLS.has(event.toolName) || !isSafetyNetEnabled(routerConfig)) {
728
+ return { route: false };
729
+ }
730
+
731
+ const stats = nativeOutputStats(text, event.details?.truncation);
732
+ if (stats.bytes > routerConfig.thresholds.largeOutputBytes) {
733
+ return {
734
+ route: true,
735
+ stats,
736
+ reason: `native ${event.toolName} output exceeded largeOutputBytes (${stats.bytes} > ${routerConfig.thresholds.largeOutputBytes})`,
737
+ };
738
+ }
739
+
740
+ if (stats.lines > routerConfig.thresholds.largeOutputLines) {
741
+ return {
742
+ route: true,
743
+ stats,
744
+ reason: `native ${event.toolName} output exceeded largeOutputLines (${stats.lines} > ${routerConfig.thresholds.largeOutputLines})`,
745
+ };
746
+ }
747
+
748
+ if (matchesConfiguredNoiseHint(event, routerConfig) && exceedsHeuristicMinimum(stats, routerConfig)) {
749
+ return {
750
+ route: true,
751
+ stats,
752
+ reason: `native ${event.toolName} output matched configured noisy/generated-output hints and exceeded the conservative heuristic floor`,
753
+ };
754
+ }
755
+
756
+ return { route: false };
757
+ }
758
+
759
+ function exceedsHeuristicMinimum(stats, routerConfig) {
760
+ const byteFloor = Math.min(routerConfig.thresholds.largeOutputBytes, SAFETY_NET_HEURISTIC_MIN_BYTES);
761
+ const lineFloor = Math.min(routerConfig.thresholds.largeOutputLines, SAFETY_NET_HEURISTIC_MIN_LINES);
762
+ return stats.bytes > byteFloor || stats.lines > lineFloor;
763
+ }
764
+
765
+ function matchesConfiguredNoiseHint(event, routerConfig) {
766
+ if (event.toolName === "bash") {
767
+ const command = typeof event.input?.command === "string" ? event.input.command : "";
768
+ return matchesAnyHint(command, routerConfig.hints?.noisyCommandPatterns);
769
+ }
770
+
771
+ if (event.toolName === "read") {
772
+ const path = typeof event.input?.path === "string" ? event.input.path : "";
773
+ return matchesAnyPathHint(path, routerConfig.hints?.generatedPathGlobs);
774
+ }
775
+
776
+ return false;
777
+ }
778
+
779
+ function matchesAnyHint(value, hints) {
780
+ if (!value || !Array.isArray(hints)) {
781
+ return false;
782
+ }
783
+ return hints.some((hint) => typeof hint === "string" && hint.length > 0 && value.includes(hint));
784
+ }
785
+
786
+ function matchesAnyPathHint(path, hints) {
787
+ if (!path || !Array.isArray(hints)) {
788
+ return false;
789
+ }
790
+ return hints.some((hint) => matchesSimpleGlob(path, hint));
791
+ }
792
+
793
+ function matchesSimpleGlob(path, pattern) {
794
+ if (typeof pattern !== "string" || pattern.length === 0) {
795
+ return false;
796
+ }
797
+
798
+ if (pattern.endsWith("/**")) {
799
+ const prefix = pattern.slice(0, -3);
800
+ return path === prefix || path.startsWith(`${prefix}/`);
801
+ }
802
+
803
+ if (!pattern.includes("*")) {
804
+ return path === pattern || path.includes(pattern);
805
+ }
806
+
807
+ const escaped = pattern
808
+ .split("*")
809
+ .map((part) => part.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"))
810
+ .join(".*");
811
+ return new RegExp(`^${escaped}$`).test(path);
812
+ }
813
+
814
+ function nativeSafetyDecisionId(event, text) {
815
+ return `ffdec_native_${stableHash(`${event.toolName}:${event.toolCallId}:${text}`).slice(0, 24)}`;
816
+ }
817
+
818
+ function formatNativeSafetyNetResult({ event, record, duplicate, stats, reason, mode, text }) {
819
+ const lines = splitLines(text);
820
+ const shownLines = lines.slice(0, SAFETY_NET_EXCERPT_LINES);
821
+ const shownEnd = shownLines.length;
822
+ const omittedLines = Math.max(0, stats.capturedLines - shownLines.length);
823
+ const inputLabel = nativeInputLabel(event);
824
+ const duplicateLine = duplicate
825
+ ? `Duplicate: exact native output matches previous outputId=${duplicate.outputId}; current raw output was vaulted as outputId=${record.outputId}.`
826
+ : null;
827
+
828
+ return [
829
+ `Freeflow routed this native ${event.toolName} result (post-tool safety net).`,
830
+ `Reason: ${reason}; outputRouter.postToolRouting=${mode}.`,
831
+ `Captured exact native text in the Freeflow vault: outputId=${record.outputId}, stream=raw, lines=${stats.capturedLines}, bytes=${stats.capturedBytes}.`,
832
+ ...(duplicateLine ? [duplicateLine] : []),
833
+ `Original native result stats: lines=${stats.lines}, bytes=${stats.bytes}.${inputLabel ? ` Input: ${inputLabel}.` : ""}`,
834
+ `Recovery: use freeflow_retrieve with action=retrieve, source.kind=vault, outputId=${record.outputId}, stream=raw, and a lineRange to recover exact content.`,
835
+ `Showing exact captured lines 1-${shownEnd}${omittedLines > 0 ? ` (${omittedLines} captured lines omitted from context)` : ""}:`,
836
+ "```text",
837
+ shownLines.join("\n"),
838
+ "```",
839
+ ].join("\n");
840
+ }
841
+
842
+ function nativeInputLabel(event) {
843
+ if (event.toolName === "bash" && typeof event.input?.command === "string") {
844
+ return `command=${JSON.stringify(event.input.command)}`;
845
+ }
846
+ if (event.toolName === "read" && typeof event.input?.path === "string") {
847
+ return `path=${JSON.stringify(event.input.path)}`;
848
+ }
849
+ return "";
850
+ }
851
+
852
+ function appendSafetyNetWarning(event, message) {
853
+ return [
854
+ ...event.content,
855
+ {
856
+ type: "text",
857
+ text: `Freeflow safety-net warning: ${message} Native output was not shortened or removed.`,
858
+ },
859
+ ];
860
+ }
861
+
862
+ // Keep post-tool native transformations aligned with
863
+ // plugins/freeflow/skills/output-router/references/safety-policy.md.
864
+ async function handleNativeToolSafetyNet(event, ctx) {
865
+ const routerConfigResult = await readOutputRouterConfig(ctx.cwd);
866
+ notifyRouterConfigWarnings(ctx, routerConfigResult);
867
+ const routerConfig = routerConfigResult.config;
868
+
869
+ if (!SAFETY_NET_NATIVE_TOOLS.has(event.toolName) || !isSafetyNetEnabled(routerConfig)) {
870
+ return undefined;
871
+ }
872
+
873
+ const text = await nativeResultText(event);
874
+ if (text === null) {
875
+ return undefined;
876
+ }
877
+
878
+ const routeDecision = shouldRouteNativeToolResult(event, routerConfig, text);
879
+ if (!routeDecision.route) {
880
+ return undefined;
881
+ }
882
+
883
+ try {
884
+ const vault = createVault({ root: routerConfig.vault.root, retention: routerConfig.vault.retention });
885
+ const sessionId = getRouterSessionId(ctx);
886
+ const fingerprints = textOutputFingerprints({ raw: text });
887
+ const duplicate = await findExactDuplicateTextOutput(vault, { sessionId, fingerprints });
888
+ const decisionId = nativeSafetyDecisionId(event, text);
889
+ const record = await storeTextOutput(vault, {
890
+ sessionId,
891
+ raw: text,
892
+ sourceKind: "native",
893
+ decisionIds: [decisionId],
894
+ });
895
+ const routedText = formatNativeSafetyNetResult({
896
+ event,
897
+ record,
898
+ duplicate,
899
+ stats: routeDecision.stats,
900
+ reason: routeDecision.reason,
901
+ mode: routerConfig.postToolRouting,
902
+ text,
903
+ });
904
+
905
+ return {
906
+ content: [{ type: "text", text: routedText }],
907
+ details: {
908
+ ...(event.details && typeof event.details === "object" ? event.details : {}),
909
+ freeflowOutputRouter: {
910
+ route: "safety-net",
911
+ outputId: record.outputId,
912
+ decisionId,
913
+ reason: routeDecision.reason,
914
+ ...(duplicate ? { duplicateOfOutputId: duplicate.outputId } : {}),
915
+ },
916
+ },
917
+ };
918
+ } catch (error) {
919
+ const message = error instanceof Error ? error.message : String(error);
920
+ ctx.ui.notify(`Freeflow safety-net routing failed; passing native ${event.toolName} output through. ${message}`, "warning");
921
+ return {
922
+ content: appendSafetyNetWarning(event, message),
923
+ };
924
+ }
925
+ }
926
+
927
+ async function normalizeRetrieveParams(params, ctx) {
928
+ const routerConfigResult = await readOutputRouterConfig(ctx.cwd);
929
+ notifyRouterConfigWarnings(ctx, routerConfigResult);
930
+ const source = params.source ?? { kind: "repo" };
931
+
932
+ if (source.kind === "repo") {
933
+ return {
934
+ ...params,
935
+ generatedPathGlobs: routerConfigResult.config.hints?.generatedPathGlobs,
936
+ source: {
937
+ kind: "repo",
938
+ root: ctx.cwd,
939
+ ...(source.path ? { path: source.path } : {}),
940
+ },
941
+ };
942
+ }
943
+
944
+ if (source.kind === "vault") {
945
+ if (!source.outputId) {
946
+ throw new Error("freeflow_retrieve source.kind=vault requires source.outputId.");
947
+ }
948
+ return {
949
+ ...params,
950
+ source: {
951
+ kind: "vault",
952
+ root: routerConfigResult.config.vault.root,
953
+ sessionId: getRouterSessionId(ctx),
954
+ outputId: source.outputId,
955
+ ...(source.stream ? { stream: source.stream } : {}),
956
+ },
957
+ };
958
+ }
959
+
960
+ throw new Error(`Unsupported freeflow_retrieve source kind: ${source.kind}`);
961
+ }
962
+
963
+ function createPiCommandRunner(pi, signal) {
964
+ return {
965
+ async run(request) {
966
+ const startedAt = Date.now();
967
+ const command = Array.isArray(request.command) ? request.command.join(" ") : request.command;
968
+ const result = await pi.exec("bash", ["-lc", command], {
969
+ cwd: request.cwd,
970
+ timeout: request.timeoutMs,
971
+ signal,
972
+ });
973
+ const durationMs = Date.now() - startedAt;
974
+ const killed = Boolean(result.killed);
975
+ const code = typeof result.code === "number" ? result.code : null;
976
+ return {
977
+ stdout: result.stdout ?? "",
978
+ stderr: result.stderr ?? "",
979
+ executionStatus: signal?.aborted ? "cancelled" : killed ? "timed_out" : code === 0 ? "success" : "failed",
980
+ exitCode: code,
981
+ durationMs,
982
+ };
983
+ },
984
+ };
985
+ }
986
+
987
+ function registerRouterTools(pi) {
988
+ pi.registerTool({
989
+ name: "freeflow_retrieve",
990
+ label: "Freeflow Retrieve",
991
+ description:
992
+ "Retrieve targeted evidence from repo files or Freeflow-vaulted output. Returns labeled, recoverable routed evidence instead of broad raw output.",
993
+ promptSnippet: "Retrieve targeted repo/vault evidence with recoverable routed output.",
994
+ promptGuidelines: [
995
+ "Use freeflow_retrieve for targeted repo or vault evidence before reading whole files or dumping captured output.",
996
+ "Use freeflow_retrieve with source.kind=vault and an outputId to recover exact output from freeflow_run.",
997
+ ],
998
+ parameters: FREEFLOW_RETRIEVE_PARAMETERS,
999
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1000
+ const result = await freeflowRetrieve(await normalizeRetrieveParams(params, ctx));
1001
+ return {
1002
+ content: [{ type: "text", text: routedToolText(result) }],
1003
+ details: { result },
1004
+ };
1005
+ },
1006
+ renderCall(args, theme) {
1007
+ return renderFreeflowRetrieveCall(args, theme);
1008
+ },
1009
+ renderResult(result, options, theme) {
1010
+ return renderFreeflowRetrieveResult(result, options, theme);
1011
+ },
1012
+ });
1013
+
1014
+ pi.registerTool({
1015
+ name: "freeflow_run",
1016
+ label: "Freeflow Run",
1017
+ description:
1018
+ "Run a command through Pi's approved runner, vault exact stdout/stderr, and return compact routed evidence plus an outputId.",
1019
+ promptSnippet: "Run likely-large/noisy commands with vaulted raw output and compact evidence.",
1020
+ promptGuidelines: [
1021
+ "Use freeflow_run for commands likely to produce large or noisy output when routed evidence is enough.",
1022
+ "Use native bash when direct shell behavior or exact small raw output is intentionally needed.",
1023
+ ],
1024
+ parameters: FREEFLOW_RUN_PARAMETERS,
1025
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
1026
+ const routerConfigResult = await readOutputRouterConfig(ctx.cwd);
1027
+ notifyRouterConfigWarnings(ctx, routerConfigResult);
1028
+ const runner = createPiCommandRunner(pi, signal);
1029
+ const result = await freeflowRun(
1030
+ {
1031
+ command: params.command,
1032
+ cwd: params.cwd ?? ctx.cwd,
1033
+ timeoutMs: params.timeoutMs,
1034
+ preserve: params.preserve,
1035
+ goal: params.goal,
1036
+ sessionId: getRouterSessionId(ctx),
1037
+ vaultRoot: routerConfigResult.config.vault.root,
1038
+ vaultRetention: routerConfigResult.config.vault.retention,
1039
+ thresholds: routerConfigResult.config.thresholds,
1040
+ },
1041
+ {
1042
+ async run(request) {
1043
+ if (signal?.aborted) {
1044
+ return { stdout: "", stderr: "", executionStatus: "cancelled", exitCode: null };
1045
+ }
1046
+ return runner.run(request);
1047
+ },
1048
+ },
1049
+ );
1050
+ return {
1051
+ content: [{ type: "text", text: routedToolText(result) }],
1052
+ details: { result },
1053
+ };
1054
+ },
1055
+ renderCall(args, theme) {
1056
+ return renderFreeflowRunCall(args, theme);
1057
+ },
1058
+ renderResult(result, options, theme, context) {
1059
+ return renderFreeflowRunResult(result, options, theme, context);
1060
+ },
1061
+ });
1062
+ }
1063
+
162
1064
  async function handleWorkflowCommand(args, ctx, pi) {
163
1065
  const arg = args?.trim();
164
1066
 
@@ -192,31 +1094,55 @@ async function handleWorkflowCommand(args, ctx, pi) {
192
1094
  }
193
1095
 
194
1096
  export default function freeflow(pi) {
1097
+ registerRouterTools(pi);
1098
+
195
1099
  pi.on("session_start", async (_event, ctx) => {
196
1100
  restoreModeOverride(ctx);
197
- const [modeState] = await Promise.all([readModeState(ctx.cwd), refreshRuntimeContext()]);
1101
+ const [modeState, routerConfigResult] = await Promise.all([
1102
+ readModeState(ctx.cwd),
1103
+ readOutputRouterConfig(ctx.cwd),
1104
+ refreshRuntimeContext(),
1105
+ ]);
198
1106
  setModeStatus(ctx, modeState);
1107
+ notifyRouterConfigWarnings(ctx, routerConfigResult);
199
1108
  });
200
1109
 
201
1110
  pi.on("session_compact", async (_event, ctx) => {
202
- const [modeState] = await Promise.all([readModeState(ctx.cwd), refreshRuntimeContext()]);
1111
+ const [modeState, routerConfigResult] = await Promise.all([
1112
+ readModeState(ctx.cwd),
1113
+ readOutputRouterConfig(ctx.cwd),
1114
+ refreshRuntimeContext(),
1115
+ ]);
203
1116
  setModeStatus(ctx, modeState);
1117
+ notifyRouterConfigWarnings(ctx, routerConfigResult);
204
1118
  });
205
1119
 
206
1120
  pi.on("before_agent_start", async (event, ctx) => {
207
- const [modeState, freeflowContext] = await Promise.all([
1121
+ const [modeState, freeflowContext, routerConfigResult] = await Promise.all([
208
1122
  readModeState(ctx.cwd),
209
1123
  getRuntimeContext(),
1124
+ readOutputRouterConfig(ctx.cwd),
210
1125
  ]);
211
1126
  setModeStatus(ctx, modeState);
1127
+ notifyRouterConfigWarnings(ctx, routerConfigResult);
212
1128
  return {
213
1129
  systemPrompt:
214
1130
  event.systemPrompt +
215
1131
  "\n\n" +
216
- runtimeContext(modeState, freeflowContext, hasFreeflowActivation(event.systemPrompt)),
1132
+ runtimeContext(
1133
+ modeState,
1134
+ freeflowContext,
1135
+ routerConfigResult,
1136
+ hasFreeflowActivation(event.systemPrompt),
1137
+ hasOutputRouterActivation(event.systemPrompt)
1138
+ ),
217
1139
  };
218
1140
  });
219
1141
 
1142
+ pi.on("tool_result", async (event, ctx) => {
1143
+ return handleNativeToolSafetyNet(event, ctx);
1144
+ });
1145
+
220
1146
  for (const skill of WORKFLOW_COMMANDS) {
221
1147
  pi.registerCommand(skill, {
222
1148
  description: `Run Freeflow ${skill}`,