zob-harness 0.6.0 → 0.7.1

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.
@@ -1030,7 +1030,7 @@
1030
1030
  "README.md",
1031
1031
  "scripts/README.md"
1032
1032
  ],
1033
- "noShipNotes": "Bounded repo-local context discovery only; prefer ColGREP when installed/ready, otherwise use grep/find/read fallback. Never auto-install ColGREP, run installer/network/package-manager commands, read forbidden/secret/session/vendor/build paths, persist raw secret/session bodies, inject stale/global/unbounded prompt context, or treat broad search hits as exact proof without grep/read/file-ref verification. Oracle/no-ship review must check freshness, citation coverage, and forbidden-source violations."
1033
+ "noShipNotes": "Bounded repo-local context discovery only; for exploratory/natural-language repo discovery start with zob_context_search/ColGREP when installed/ready; if the native tool is unavailable but bash is available, use compact npm run --silent zob:context:query before rg/grep; otherwise use grep/find/read fallback. Never auto-install ColGREP, run installer/network/package-manager commands, broad-grep .pi without pruning .pi/sessions and .pi/agent-sessions, read forbidden/secret/session/vendor/build paths, persist raw secret/session bodies, inject stale/global/unbounded prompt context, or treat broad search hits as exact proof without grep/read/file-ref verification. Oracle/no-ship review must check freshness, citation coverage, and forbidden-source violations."
1034
1034
  },
1035
1035
  {
1036
1036
  "name": "zob_context_readiness",
@@ -25,8 +25,8 @@
25
25
  "build"
26
26
  ],
27
27
  "limits": {
28
- "maxResults": 20,
29
- "maxContextLines": 2,
28
+ "maxResults": 6,
29
+ "maxContextLines": 1,
30
30
  "maxFileBytes": 1048576
31
31
  },
32
32
  "promptInjection": {
@@ -56,7 +56,7 @@ export const ZOB_ZCOMMIT_TOOLS = ["zob_zcommit_run"] as const;
56
56
  export const ZOB_DELEGATION_READ_TOOLS = ["zob_delegation_catalog", "get_delegation_run", "await_delegation_run"] as const;
57
57
  export const ZOB_MISSION_CONTROL_READ_TOOLS = ["zob_coms_readiness", "zob_mission_control_snapshot"] as const;
58
58
  export const ZOB_MISSION_CONTROL_PROPOSAL_TOOLS = ["zob_mission_control_propose_command"] as const;
59
- export const ZOB_CONTEXT_READ_TOOLS = ["zob_context_readiness", "zob_context_validate_scope"] as const;
59
+ export const ZOB_CONTEXT_READ_TOOLS = ["zob_context_search", "zob_context_readiness", "zob_context_validate_scope"] as const;
60
60
  export const ZOB_CONTEXT_PROPOSAL_TOOLS = ["zob_context_writeback_proposal"] as const;
61
61
  export const ZOB_COMPUTE_READ_TOOLS = ["zob_compute_preview", "zob_compute_resolve_profile", "zob_compute_plan_workflow", "zob_compute_validate_profile"] as const;
62
62
  export const ZOB_COMPUTE_REPORT_TOOLS = ["zob_compute_write_profile_reports"] as const;
@@ -1,6 +1,6 @@
1
- import { spawnSync } from "node:child_process";
1
+ import { spawn, spawnSync } from "node:child_process";
2
2
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
3
- import { basename, extname, isAbsolute, join, normalize, sep } from "node:path";
3
+ import { basename, extname, isAbsolute, join, normalize, relative, sep } from "node:path";
4
4
 
5
5
  export type ContextSearchMode = "auto" | "semantic" | "hybrid" | "regex" | "files";
6
6
 
@@ -48,7 +48,7 @@ const DEFAULT_CONFIG: ContextDiscoveryConfig = {
48
48
  fallbackProvider: "grep",
49
49
  includePaths: [".pi/extensions", ".pi/skills", ".pi/capabilities", "scripts", "docs", "README.md", "AGENTS.md"],
50
50
  excludePaths: [".env", "**/.env", ".env.*", "**/*secret*", "**/*key*", "*.pem", ".pi/sessions", ".pi/agent-sessions", "node_modules", "dist", "build"],
51
- limits: { maxResults: 20, maxContextLines: 2, maxFileBytes: 1024 * 1024 },
51
+ limits: { maxResults: 6, maxContextLines: 1, maxFileBytes: 1024 * 1024 },
52
52
  promptInjection: { enabled: true, includeInstallHint: true },
53
53
  loadedFrom: "defaults",
54
54
  };
@@ -78,6 +78,14 @@ export function normalizeRepoPath(raw: unknown): string | undefined {
78
78
  return normalized.split(sep).join("/");
79
79
  }
80
80
 
81
+ function normalizeBackendPath(repoRoot: string, raw: unknown): string | undefined {
82
+ if (typeof raw !== "string" || raw.trim().length === 0 || raw.includes("\0")) return undefined;
83
+ if (!isAbsolute(raw)) return normalizeRepoPath(raw);
84
+ const relPath = relative(repoRoot, raw);
85
+ if (!relPath || relPath === ".." || relPath.startsWith(`..${sep}`) || isAbsolute(relPath)) return undefined;
86
+ return normalizeRepoPath(relPath);
87
+ }
88
+
81
89
  function pathIsExcluded(relPath: string, excludePaths: string[]): boolean {
82
90
  const normalized = normalizeRepoPath(relPath);
83
91
  if (!normalized) return true;
@@ -136,10 +144,10 @@ export function buildActiveSearchBackendPromptSnippet(repoRoot: string): string
136
144
  const detection = detectColgrep(repoRoot);
137
145
  const scope = `${config.loadedFrom}; roots=${config.includePaths.slice(0, 6).join(",")}; excludes=${config.excludePaths.length}`;
138
146
  if (detection.ready) {
139
- return `\n\nZOB ACTIVE SEARCH BACKEND\n- active search backend: colgrep\n- prompt injection: enabled by ${scope}; bounded per turn from current repo config, not a global/stale context pack.\n- Prefer zob_context_search for codebase discovery and broad/semantic search; use grep/read for exact proof and final citations.\n- Search output must stay bounded and avoid forbidden paths/secrets.`;
147
+ return `\n\nZOB ACTIVE SEARCH BACKEND\n- active search backend: colgrep\n- prompt injection: enabled by ${scope}; bounded per turn from current repo config, not a global/stale context pack.\n- For exploratory, natural-language, or "where is this mechanism?" repo discovery, start with zob_context_search/ColGREP before grep/find.\n- If zob_context_search is not listed in your available tools but bash is available, run npm run --silent zob:context:query -- --query "<natural language query>" --max-results 6 --max-context-lines 1 before rg/grep; the wrapper returns compact refs by default.\n- Do not conclude the native tool is unavailable and immediately use broad rg/grep; use the wrapper above as the ColGREP path.\n- Run one exploratory context search, then read the returned refs; retry only if results=0 or clearly irrelevant.\n- Use grep/read after semantic discovery for exact proof, known identifiers, final citations, and line refs.\n- Never run broad grep/find over .pi unless .pi/sessions and .pi/agent-sessions are explicitly excluded/pruned.\n- Search output must stay bounded and avoid forbidden paths/secrets.`;
140
148
  }
141
149
  const installHint = config.promptInjection.includeInstallHint ? `\n- Optional ColGREP setup hint: ${detection.guidance}` : "";
142
- return `\n\nZOB ACTIVE SEARCH BACKEND\n- active search backend: grep fallback\n- prompt injection: enabled by ${scope}; bounded per turn from current repo config, not a global/stale context pack.\n- Prefer zob_context_search, grep, find, and read for bounded repo-local discovery and exact proof.${installHint}\n- Missing ColGREP is not a blocker; do not auto-install it.`;
150
+ return `\n\nZOB ACTIVE SEARCH BACKEND\n- active search backend: grep fallback\n- prompt injection: enabled by ${scope}; bounded per turn from current repo config, not a global/stale context pack.\n- Prefer zob_context_search when listed; if not listed and bash is available, npm run --silent zob:context:query -- --query "<query>" still gives the same bounded compact fallback path.${installHint}\n- Missing ColGREP is not a blocker; do not auto-install it.\n- Never run broad grep/find over .pi unless .pi/sessions and .pi/agent-sessions are explicitly excluded/pruned.`;
143
151
  }
144
152
 
145
153
  function safeSearchRoots(repoRoot: string, config: ContextDiscoveryConfig, requestedPaths?: string[]): { roots: string[]; rejected: string[] } {
@@ -180,12 +188,13 @@ function collectFiles(repoRoot: string, relPath: string, config: ContextDiscover
180
188
  }
181
189
 
182
190
  function normalizeLineResult(repoRoot: string, item: Record<string, unknown>, config: ContextDiscoveryConfig): NormalizedContextResult | undefined {
183
- const rawPath = item.path ?? item.file ?? item.filename ?? item.source_path;
184
- const path = normalizeRepoPath(rawPath);
191
+ const unit = typeof item.unit === "object" && item.unit !== null ? item.unit as Record<string, unknown> : undefined;
192
+ const rawPath = item.path ?? item.file ?? item.filename ?? item.source_path ?? unit?.path ?? unit?.file ?? unit?.filename ?? unit?.source_path;
193
+ const path = normalizeBackendPath(repoRoot, rawPath);
185
194
  if (!path || pathIsExcluded(path, config.excludePaths) || !existsSync(join(repoRoot, path))) return undefined;
186
- const lineValue = item.line ?? item.lineNumber ?? item.line_number ?? item.start_line;
195
+ const lineValue = item.line ?? item.lineNumber ?? item.line_number ?? item.start_line ?? unit?.line ?? unit?.lineNumber ?? unit?.line_number ?? unit?.start_line;
187
196
  const line = typeof lineValue === "number" ? Math.max(1, Math.floor(lineValue)) : undefined;
188
- const rawPreview = item.preview ?? item.text ?? item.lineText ?? item.content ?? item.match ?? "";
197
+ const rawPreview = item.preview ?? item.text ?? item.lineText ?? item.content ?? item.match ?? unit?.preview ?? unit?.text ?? unit?.lineText ?? unit?.content ?? unit?.code ?? unit?.docstring ?? unit?.signature ?? unit?.qualified_name ?? unit?.name ?? "";
189
198
  const preview = String(rawPreview).replace(/\s+/gu, " ").trim().slice(0, 240);
190
199
  const score = typeof item.score === "number" ? item.score : undefined;
191
200
  return { path, line, ref: line ? `${path}:${line}` : path, preview, score };
@@ -202,12 +211,47 @@ function extractJsonResults(repoRoot: string, stdout: string, config: ContextDis
202
211
  }
203
212
  }
204
213
 
205
- function runColgrep(repoRoot: string, query: string, roots: string[], config: ContextDiscoveryConfig, maxResults: number, maxContextLines: number): { ok: boolean; results: NormalizedContextResult[]; error?: string } {
214
+ const COLGREP_TIMEOUT_MS = 30_000;
215
+ const COLGREP_MAX_OUTPUT_BYTES = 1024 * 1024;
216
+
217
+ type ColgrepRunResult = { ok: boolean; results: NormalizedContextResult[]; error?: string };
218
+
219
+ async function runColgrep(repoRoot: string, query: string, roots: string[], config: ContextDiscoveryConfig, maxResults: number, maxContextLines: number): Promise<ColgrepRunResult> {
206
220
  const args = ["--json", "-k", String(maxResults), "-n", String(maxContextLines), query, ...roots];
207
- const result = spawnSync("colgrep", args, { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10_000, maxBuffer: 1024 * 1024 });
208
- if (result.status !== 0) return { ok: false, results: [], error: result.stderr.trim().slice(0, 240) || `colgrep exited ${String(result.status)}` };
209
- const results = extractJsonResults(repoRoot, result.stdout, config, maxResults);
210
- return { ok: true, results };
221
+ return await new Promise<ColgrepRunResult>((resolve) => {
222
+ let stdout = "";
223
+ let stderr = "";
224
+ let settled = false;
225
+ const child = spawn("colgrep", args, { cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"] });
226
+ const finish = (value: ColgrepRunResult): void => {
227
+ if (settled) return;
228
+ settled = true;
229
+ clearTimeout(timer);
230
+ resolve(value);
231
+ };
232
+ const timer = setTimeout(() => {
233
+ child.kill("SIGTERM");
234
+ finish({ ok: false, results: [], error: `colgrep timed out after ${String(COLGREP_TIMEOUT_MS)}ms; retry with narrower paths or max_results.` });
235
+ }, COLGREP_TIMEOUT_MS);
236
+ child.stdout.setEncoding("utf8");
237
+ child.stderr.setEncoding("utf8");
238
+ child.stdout.on("data", (chunk: string) => {
239
+ stdout += chunk;
240
+ if (stdout.length > COLGREP_MAX_OUTPUT_BYTES) {
241
+ child.kill("SIGTERM");
242
+ finish({ ok: false, results: [], error: "colgrep output exceeded compact result budget; retry with lower max_results." });
243
+ }
244
+ });
245
+ child.stderr.on("data", (chunk: string) => { stderr += chunk; });
246
+ child.on("error", (error) => finish({ ok: false, results: [], error: error.message.slice(0, 240) }));
247
+ child.on("close", (code) => {
248
+ if (code !== 0) {
249
+ finish({ ok: false, results: [], error: stderr.trim().slice(0, 240) || `colgrep exited ${String(code)}` });
250
+ return;
251
+ }
252
+ finish({ ok: true, results: extractJsonResults(repoRoot, stdout, config, maxResults) });
253
+ });
254
+ });
211
255
  }
212
256
 
213
257
  function fallbackSearch(repoRoot: string, params: Required<Pick<ContextSearchParams, "query" | "mode">> & Pick<ContextSearchParams, "pattern" | "paths">, config: ContextDiscoveryConfig, maxResults: number, maxContextLines: number): { results: NormalizedContextResult[]; rejectedPaths: string[]; roots: string[] } {
@@ -246,7 +290,7 @@ function fallbackSearch(repoRoot: string, params: Required<Pick<ContextSearchPar
246
290
  return { results, rejectedPaths: rejected, roots };
247
291
  }
248
292
 
249
- export function runContextSearch(repoRoot: string, input: ContextSearchParams): Record<string, unknown> {
293
+ export async function runContextSearch(repoRoot: string, input: ContextSearchParams): Promise<Record<string, unknown>> {
250
294
  const config = loadContextDiscoveryConfig(repoRoot);
251
295
  const query = String(input.query ?? "").trim();
252
296
  const mode = input.mode ?? "auto";
@@ -259,7 +303,7 @@ export function runContextSearch(repoRoot: string, input: ContextSearchParams):
259
303
  let fallbackReason = detection.ready ? undefined : detection.guidance;
260
304
  let results: NormalizedContextResult[] = [];
261
305
  if (detection.ready && mode !== "regex" && mode !== "files") {
262
- const colgrep = runColgrep(repoRoot, query, roots, config, maxResults, maxContextLines);
306
+ const colgrep = await runColgrep(repoRoot, query, roots, config, maxResults, maxContextLines);
263
307
  if (colgrep.ok) results = colgrep.results;
264
308
  else {
265
309
  provider = "grep-fallback";
@@ -292,7 +336,7 @@ export function runContextSearch(repoRoot: string, input: ContextSearchParams):
292
336
  rejectedPaths: rejected,
293
337
  limits: { maxResults, maxContextLines },
294
338
  recommendedVerification: refs.length > 0
295
- ? [`grep -n ${shellQuote(query)} ${shellQuote(results[0]?.path ?? roots[0] ?? ".")}`, `read ${results[0]?.path ?? roots[0] ?? "."}`]
339
+ ? [`read ${results[0]?.path ?? roots[0] ?? "."}`, "After reading, grep exact identifiers/strings found in returned refs for final proof."]
296
340
  : [`grep -R -n ${shellQuote(query)} ${roots.map(shellQuote).join(" ") || "."}`, "find relevant safe paths, then read exact files"],
297
341
  safety: { repoRelativeOnly: true, forbiddenPathsExcluded: config.excludePaths, rawPromptOrConversationPersisted: false, autoInstall: false },
298
342
  };
@@ -26,6 +26,7 @@ import { showDelegationOverlay } from "./delegation-overlay.js";
26
26
  import { finishDelegationRun } from "./delegation-monitor.js";
27
27
  import { showGoalTodoOverlay } from "./goal-todo-overlay.js";
28
28
  import type { HarnessRuntimeState } from "./state.js";
29
+ import { registerZobIntroCommand } from "./zob-intro.js";
29
30
  import {
30
31
  asInteractiveAutonomyMode,
31
32
  formatInteractiveAutonomyStatus,
@@ -986,6 +987,8 @@ export function registerHarnessCommands(pi: ExtensionAPI, state: HarnessRuntimeS
986
987
  refreshIntentClassifierModelCache(ctx);
987
988
  });
988
989
 
990
+ registerZobIntroCommand(pi);
991
+
989
992
  // Exact `/new` is handled by Pi before extension input/command hooks. Soft carryover
990
993
  // is therefore written from the `session_shutdown` reason="new" hook in events.ts.
991
994
  // Keep this registration only for `/new hard`, where users need an explicit clean reset.
@@ -30,16 +30,19 @@ export function registerContextTools(pi: ExtensionAPI): void {
30
30
  name: "zob_context_search",
31
31
  label: "ZOB Context Search",
32
32
  description: "Search bounded repo-local context through the active context backend. Prefers ColGREP when installed and ready, falls back to safe grep/find-style search, never auto-installs ColGREP, and excludes forbidden/session/vendor/build paths.",
33
- promptSnippet: "Use zob_context_search for bounded repo-local discovery before broad grep/read; verify exact proof with grep/read on returned refs.",
33
+ promptSnippet: "For exploratory/natural-language repo discovery, call zob_context_search before broad grep/find; if this tool is not exposed but bash is available, use compact npm run --silent zob:context:query before rg/grep; verify exact proof with grep/read on returned refs.",
34
34
  promptGuidelines: [
35
- "Call zob_context_search for codebase/context discovery when semantic or broad search is useful.",
35
+ "Call zob_context_search first for exploratory/natural-language codebase discovery when semantic or broad search is useful.",
36
36
  "zob_context_search prefers ColGREP when ready and falls back to bounded grep when ColGREP is missing, not ready, or a query fails.",
37
+ "If zob_context_search is not available in the current toolset, use the compact local wrapper: npm run --silent zob:context:query -- --query \"<query>\" --max-results 6 --max-context-lines 1 before broad rg/grep.",
37
38
  "Never install ColGREP from this tool path; missing ColGREP is not a blocker.",
39
+ "Run one exploratory context search, then read returned refs; retry only when results are empty or clearly irrelevant.",
40
+ "Never run broad grep/find over .pi unless .pi/sessions and .pi/agent-sessions are explicitly excluded/pruned.",
38
41
  "Use returned refs as leads and verify final claims with exact read/grep evidence.",
39
42
  ],
40
43
  parameters: ContextSearchParams,
41
44
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
42
- const result = runContextSearch(ctx.cwd, params);
45
+ const result = await runContextSearch(ctx.cwd, params);
43
46
  return { content: [{ type: "text", text: formatContextSearchResult(result) }], details: result };
44
47
  },
45
48
  });
@@ -0,0 +1,662 @@
1
+ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+
4
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import type { AutocompleteItem, Component, TUI } from "@earendil-works/pi-tui";
6
+ import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
7
+
8
+ import { sha256 } from "../core/utils/hashing.js";
9
+
10
+ type LogicalFrame = readonly string[];
11
+ type ZobIntroStyleName = "accent" | "plain";
12
+
13
+ type ZobIntroOptions = {
14
+ blockWidth: number;
15
+ blockHeight: number;
16
+ tickMs: number;
17
+ repeat: number | null;
18
+ style: ZobIntroStyleName;
19
+ };
20
+
21
+ type ZobIntroParseResult =
22
+ | { ok: true; help: false; options: ZobIntroOptions }
23
+ | { ok: true; help: true; options: ZobIntroOptions }
24
+ | { ok: false; errors: string[]; options: ZobIntroOptions };
25
+
26
+ type ZobIntroStyles = {
27
+ accent: (text: string) => string;
28
+ block: (text: string) => string;
29
+ bold: (text: string) => string;
30
+ dim: (text: string) => string;
31
+ exhaustHot: (text: string) => string;
32
+ exhaustDim: (text: string) => string;
33
+ warning: (text: string) => string;
34
+ };
35
+
36
+ const WELCOME_TITLE = "Welcome to the ZOB Harness";
37
+ const START_PROMPT = "Press Enter/Space to continue";
38
+ const FIRST_RUN_MARKER_RELATIVE_PATH = ".pi/tmp/zob-intro-first-run.json";
39
+ const FIRST_RUN_ARGS = "once fast";
40
+
41
+ const DEFAULT_ZOB_INTRO_OPTIONS: ZobIntroOptions = {
42
+ blockWidth: 6,
43
+ blockHeight: 3,
44
+ tickMs: 180,
45
+ repeat: 3,
46
+ style: "accent",
47
+ };
48
+
49
+ const ZOB_INTRO_MIN_CANVAS_COLS = 8;
50
+ const ZOB_INTRO_EXIT_FRAME_COUNT = 10;
51
+ const ZOB_INTRO_WRAP_PAUSE_FRAME_COUNT = 2;
52
+ const ZOB_INTRO_WRAP_PASS_FRAME_COUNT = 8;
53
+ const BUILD_FRAME_TICK_HOLD = 2;
54
+ const WELCOME_TEXT_SHIFT_LEFT = -3;
55
+
56
+ const ZOB_INTRO_TARGET_FRAME = [
57
+ "###.....",
58
+ "#.#####.",
59
+ "###....#",
60
+ "#.#####.",
61
+ "###.....",
62
+ ] as const satisfies LogicalFrame;
63
+
64
+ const ZOB_INTRO_MORPH_FRAMES = [
65
+ ["###.", "#.#.", "##.#", "#..#"],
66
+ ["###.", "#.#.", "##+#", "#.+#"],
67
+ ["###.", "#.#.", "###.", "#.#."],
68
+ ["###.", "#.#.", "###.", "#.#.", "+..."],
69
+ ["###.", "#.#.", "###.", "#.#.", "#..."],
70
+ ["###.", "#.#.", "###.", "#.#.", "#+.."],
71
+ ["###.", "#.#.", "###.", "#.#.", "##.."],
72
+ ["###.....", "#.#.....", "###.....", "#.#.....", "##+....."],
73
+ ["###.....", "#.#.....", "###.....", "#.#.....", "###....."],
74
+ ["###.....", "#.#+....", "###.+...", "#.#+....", "###....."],
75
+ ["###.....", "#.##....", "###.#...", "#.##....", "###....."],
76
+ ["###.....", "#.##+...", "###..+..", "#.##+...", "###....."],
77
+ ["###.....", "#.###...", "###..#..", "#.###...", "###....."],
78
+ ["###.....", "#.###+..", "###...+.", "#.###+..", "###....."],
79
+ ["###.....", "#.####..", "###...#.", "#.####..", "###....."],
80
+ ["###.....", "#.####+.", "###....+", "#.####+.", "###....."],
81
+ ZOB_INTRO_TARGET_FRAME,
82
+ ] as const satisfies readonly LogicalFrame[];
83
+
84
+ function frameWidth(frame: LogicalFrame): number {
85
+ return Math.max(...frame.map((row) => row.length));
86
+ }
87
+
88
+ function centerOffset(frame: LogicalFrame, width: number): number {
89
+ return Math.max(0, Math.floor((width - frameWidth(frame)) / 2));
90
+ }
91
+
92
+ function placeFrame(frame: LogicalFrame, offsetX: number, width: number): string[] {
93
+ return frame.map((row) => {
94
+ const cells = Array.from({ length: width }, () => ".");
95
+ for (let sourceX = 0; sourceX < row.length; sourceX += 1) {
96
+ const cell = row[sourceX];
97
+ if (!cell || cell === ".") continue;
98
+ const targetX = offsetX + sourceX;
99
+ if (targetX >= 0 && targetX < width) cells[targetX] = cell;
100
+ }
101
+ return cells.join("");
102
+ });
103
+ }
104
+
105
+ function writeCells(row: string[], startX: number, pattern: string): void {
106
+ for (let index = 0; index < pattern.length; index += 1) {
107
+ const targetX = startX + index;
108
+ if (targetX < 0 || targetX >= row.length) continue;
109
+ if (row[targetX] !== ".") continue;
110
+ row[targetX] = pattern[index] ?? ".";
111
+ }
112
+ }
113
+
114
+ function buildLaserPattern(length: number, pulse: number, lane: number): string {
115
+ return Array.from({ length }, (_unused, index) => {
116
+ const phase = (index + pulse + lane * 2) % 7;
117
+ if (phase <= 1) return "=";
118
+ if (phase === 2 || phase === 5) return "-";
119
+ if (phase === 3) return "~";
120
+ return ".";
121
+ }).join("");
122
+ }
123
+
124
+ function withLaserRays(frame: LogicalFrame, rayStartX: number, pulse: number, width: number, boosted = false): string[] {
125
+ const rows = frame.map((row) => row.padEnd(width, ".").slice(0, width).split(""));
126
+ const start = Math.max(0, rayStartX);
127
+ const length = Math.max(0, width - start);
128
+ if (boosted && rows[1]) writeCells(rows[1], start, buildLaserPattern(Math.max(0, length - 2), pulse, 1));
129
+ if (rows[2]) writeCells(rows[2], start, buildLaserPattern(length, pulse + 1, 0));
130
+ if (boosted && rows[3]) writeCells(rows[3], start, buildLaserPattern(Math.max(0, length - 1), pulse + 2, 2));
131
+ return rows.map((row) => row.join(""));
132
+ }
133
+
134
+ function emptyIntroFrame(width: number): LogicalFrame {
135
+ return Array.from({ length: LOGICAL_ROWS }, () => ".".repeat(width));
136
+ }
137
+
138
+ function buildExitFrame(exitIndex: number, width: number, startOffset: number, endOffset: number, pulseOffset = 0, boosted = false): LogicalFrame {
139
+ const targetWidth = frameWidth(ZOB_INTRO_TARGET_FRAME);
140
+ const denominator = Math.max(1, ZOB_INTRO_EXIT_FRAME_COUNT - 1);
141
+ const rawT = Math.min(1, exitIndex / denominator);
142
+ const t = rawT * rawT;
143
+ const offsetX = Math.round(startOffset + (endOffset - startOffset) * t);
144
+ const shiftedWholeLogo = placeFrame(ZOB_INTRO_TARGET_FRAME, offsetX, width);
145
+ return withLaserRays(shiftedWholeLogo, offsetX + targetWidth, exitIndex + pulseOffset, width, boosted);
146
+ }
147
+
148
+ function buildWrapPassFrame(passIndex: number, width: number): LogicalFrame {
149
+ const targetWidth = frameWidth(ZOB_INTRO_TARGET_FRAME);
150
+ const startOffset = width + 1;
151
+ const endOffset = -targetWidth - 1;
152
+ const denominator = Math.max(1, ZOB_INTRO_WRAP_PASS_FRAME_COUNT - 1);
153
+ const t = Math.min(1, passIndex / denominator);
154
+ const offsetX = Math.round(startOffset + (endOffset - startOffset) * t);
155
+ const shiftedWholeLogo = placeFrame(ZOB_INTRO_TARGET_FRAME, offsetX, width);
156
+ return withLaserRays(shiftedWholeLogo, offsetX + targetWidth, passIndex + ZOB_INTRO_EXIT_FRAME_COUNT, width, true);
157
+ }
158
+
159
+ function buildIntroFrame(frameIndex: number, width: number): LogicalFrame {
160
+ if (frameIndex < ZOB_INTRO_MORPH_FRAMES.length) {
161
+ const frame = ZOB_INTRO_MORPH_FRAMES[frameIndex] ?? ZOB_INTRO_MORPH_FRAMES[0];
162
+ return placeFrame(frame, centerOffset(frame, width), width);
163
+ }
164
+
165
+ let phaseIndex = Math.max(0, frameIndex - ZOB_INTRO_MORPH_FRAMES.length);
166
+ const targetWidth = frameWidth(ZOB_INTRO_TARGET_FRAME);
167
+ if (phaseIndex < ZOB_INTRO_EXIT_FRAME_COUNT) {
168
+ return buildExitFrame(phaseIndex, width, centerOffset(ZOB_INTRO_TARGET_FRAME, width), -targetWidth - 1);
169
+ }
170
+ phaseIndex -= ZOB_INTRO_EXIT_FRAME_COUNT;
171
+ if (phaseIndex < ZOB_INTRO_WRAP_PAUSE_FRAME_COUNT) return emptyIntroFrame(width);
172
+ phaseIndex -= ZOB_INTRO_WRAP_PAUSE_FRAME_COUNT;
173
+ return buildWrapPassFrame(phaseIndex, width);
174
+ }
175
+
176
+ const ZOB_INTRO_FRAME_COUNT = ZOB_INTRO_MORPH_FRAMES.length + ZOB_INTRO_EXIT_FRAME_COUNT + ZOB_INTRO_WRAP_PAUSE_FRAME_COUNT + ZOB_INTRO_WRAP_PASS_FRAME_COUNT;
177
+ const LOGICAL_ROWS = Math.max(...ZOB_INTRO_MORPH_FRAMES.map((frame) => frame.length));
178
+
179
+ function cloneDefaultOptions(): ZobIntroOptions {
180
+ return { ...DEFAULT_ZOB_INTRO_OPTIONS };
181
+ }
182
+
183
+ function parsePositiveInt(value: string, label: string, min: number, max: number, errors: string[]): number | undefined {
184
+ const parsed = Number.parseInt(value, 10);
185
+ if (!Number.isFinite(parsed) || parsed < min || parsed > max) {
186
+ errors.push(`${label} must be an integer between ${min} and ${max}`);
187
+ return undefined;
188
+ }
189
+ return parsed;
190
+ }
191
+
192
+ function parseZobIntroArgs(args: string): ZobIntroParseResult {
193
+ const options = cloneDefaultOptions();
194
+ const parts = args.trim().split(/\s+/).filter(Boolean);
195
+ const errors: string[] = [];
196
+ let help = false;
197
+
198
+ for (let index = 0; index < parts.length; index += 1) {
199
+ const part = parts[index]?.toLowerCase() ?? "";
200
+ if (part === "help" || part === "--help" || part === "-h") {
201
+ help = true;
202
+ continue;
203
+ }
204
+ if (part === "loop") {
205
+ options.repeat = null;
206
+ continue;
207
+ }
208
+ if (part === "once") {
209
+ options.repeat = 1;
210
+ continue;
211
+ }
212
+ if (part === "fast") {
213
+ options.tickMs = 90;
214
+ continue;
215
+ }
216
+ if (part === "slow") {
217
+ options.tickMs = 300;
218
+ continue;
219
+ }
220
+ if (part === "normal") {
221
+ options.tickMs = DEFAULT_ZOB_INTRO_OPTIONS.tickMs;
222
+ continue;
223
+ }
224
+ if (part === "plain" || part === "mono") {
225
+ options.style = "plain";
226
+ continue;
227
+ }
228
+ if (part === "accent" || part === "neon") {
229
+ options.style = "accent";
230
+ continue;
231
+ }
232
+ if (part === "--repeat") {
233
+ const next = parts[index + 1];
234
+ if (!next) {
235
+ errors.push("--repeat requires a positive integer or 'loop'");
236
+ continue;
237
+ }
238
+ index += 1;
239
+ if (next.toLowerCase() === "loop") options.repeat = null;
240
+ else {
241
+ const repeat = parsePositiveInt(next, "repeat", 1, 99, errors);
242
+ if (repeat !== undefined) options.repeat = repeat;
243
+ }
244
+ continue;
245
+ }
246
+ if (part === "--speed" || part === "--tick-ms") {
247
+ const next = parts[index + 1];
248
+ if (!next) {
249
+ errors.push(`${part} requires a millisecond value`);
250
+ continue;
251
+ }
252
+ index += 1;
253
+ const tickMs = parsePositiveInt(next, "tickMs", 30, 2_000, errors);
254
+ if (tickMs !== undefined) options.tickMs = tickMs;
255
+ continue;
256
+ }
257
+ const blockMatch = /^(\d+)x(\d+)$/.exec(part);
258
+ if (blockMatch) {
259
+ const blockWidth = parsePositiveInt(blockMatch[1] ?? "", "blockWidth", 1, 20, errors);
260
+ const blockHeight = parsePositiveInt(blockMatch[2] ?? "", "blockHeight", 1, 12, errors);
261
+ if (blockWidth !== undefined) options.blockWidth = blockWidth;
262
+ if (blockHeight !== undefined) options.blockHeight = blockHeight;
263
+ continue;
264
+ }
265
+ errors.push(`unknown option '${part}'`);
266
+ }
267
+
268
+ if (errors.length > 0) return { ok: false, errors, options };
269
+ return { ok: true, help, options };
270
+ }
271
+
272
+ function zobIntroHelpTemplate(): string {
273
+ return [
274
+ "# ZOB intro animation",
275
+ "",
276
+ "Usage:",
277
+ "/zob-intro # play the morph + whole-logo laser exit, default 6x3 terminal pixels",
278
+ "/zob-intro once # play once then close",
279
+ "/zob-intro loop # loop until ESC/Q",
280
+ "/zob-intro fast 6x3 neon # faster and wider terminal pixels",
281
+ "/zob-intro slow 5x3 plain # slower uncolored blocks",
282
+ "/zob-intro reset-first-run # show the startup intro again on next Pi/ZOB launch",
283
+ "",
284
+ "Options:",
285
+ "- once | loop | --repeat N",
286
+ "- fast | normal | slow | --speed MS",
287
+ "- 3x3 | 5x3 | 6x3 | any WxH up to 20x12",
288
+ "- accent/neon | plain/mono",
289
+ "",
290
+ "Controls:",
291
+ "- Enter or Space: start from welcome screen",
292
+ "- ESC or Q: close",
293
+ "- Space after start: pause/resume",
294
+ ].join("\n");
295
+ }
296
+
297
+ function zobIntroArgumentCompletions(prefix: string): AutocompleteItem[] | null {
298
+ const query = prefix.trim().toLowerCase();
299
+ const items: AutocompleteItem[] = [
300
+ { value: "once", label: "once", description: "play one time then close" },
301
+ { value: "loop", label: "loop", description: "loop until ESC/Q" },
302
+ { value: "fast", label: "fast", description: "90ms per frame" },
303
+ { value: "normal", label: "normal", description: "180ms per frame" },
304
+ { value: "slow", label: "slow", description: "300ms per frame" },
305
+ { value: "5x3", label: "5x3", description: "narrower terminal-pixel ratio" },
306
+ { value: "6x3", label: "6x3", description: "default terminal-pixel ratio" },
307
+ { value: "plain", label: "plain", description: "uncolored terminal foreground" },
308
+ { value: "neon", label: "neon", description: "theme accent blocks" },
309
+ { value: "reset-first-run", label: "reset-first-run", description: "show startup intro again on next Pi/ZOB launch" },
310
+ { value: "help", label: "help", description: "insert usage help" },
311
+ ];
312
+ const filtered = query
313
+ ? items.filter((item) => item.value.toLowerCase().startsWith(query) || item.label.toLowerCase().includes(query) || item.description?.toLowerCase().includes(query))
314
+ : items;
315
+ return filtered.length > 0 ? filtered.slice(0, 20) : null;
316
+ }
317
+
318
+ function stableFrameHash(): string {
319
+ return sha256(JSON.stringify({ morphFrames: ZOB_INTRO_MORPH_FRAMES, targetFrame: ZOB_INTRO_TARGET_FRAME, exitFrameCount: ZOB_INTRO_EXIT_FRAME_COUNT, wrapPauseFrameCount: ZOB_INTRO_WRAP_PAUSE_FRAME_COUNT, wrapPassFrameCount: ZOB_INTRO_WRAP_PASS_FRAME_COUNT }));
320
+ }
321
+
322
+ function appendZobIntroLedger(pi: ExtensionAPI, options: ZobIntroOptions, status: "played" | "blocked" | "help" | "reset" | "auto_skipped", errors: string[] = []): void {
323
+ pi.appendEntry("zob-intro-command", {
324
+ schema: "zob.intro-command.v1",
325
+ status,
326
+ frameCount: ZOB_INTRO_FRAME_COUNT,
327
+ logicalCols: "dynamic",
328
+ logicalRows: LOGICAL_ROWS,
329
+ blockWidth: options.blockWidth,
330
+ blockHeight: options.blockHeight,
331
+ tickMs: options.tickMs,
332
+ repeat: options.repeat ?? "loop",
333
+ style: options.style,
334
+ frameHash: stableFrameHash(),
335
+ errorHashes: errors.map((error) => sha256(error)),
336
+ rawFramesStored: false,
337
+ bodyStored: false,
338
+ generatedAt: new Date().toISOString(),
339
+ });
340
+ }
341
+
342
+ function firstRunMarkerPath(cwd: string): string {
343
+ return join(cwd, FIRST_RUN_MARKER_RELATIVE_PATH);
344
+ }
345
+
346
+ async function hasSeenFirstRunIntro(cwd: string): Promise<boolean> {
347
+ try {
348
+ await readFile(firstRunMarkerPath(cwd), "utf8");
349
+ return true;
350
+ } catch (error) {
351
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return false;
352
+ return true;
353
+ }
354
+ }
355
+
356
+ async function markFirstRunIntroSeen(cwd: string): Promise<void> {
357
+ const markerPath = firstRunMarkerPath(cwd);
358
+ await mkdir(dirname(markerPath), { recursive: true });
359
+ await writeFile(markerPath, `${JSON.stringify({ schema: "zob.intro-first-run-marker.v1", seenAt: new Date().toISOString(), frameHash: stableFrameHash(), bodyStored: false }, null, 2)}\n`, "utf8");
360
+ }
361
+
362
+ async function resetFirstRunIntro(cwd: string): Promise<void> {
363
+ try {
364
+ await unlink(firstRunMarkerPath(cwd));
365
+ } catch (error) {
366
+ if (!(error && typeof error === "object" && "code" in error && error.code === "ENOENT")) throw error;
367
+ }
368
+ }
369
+
370
+ class ZobIntroComponent implements Component {
371
+ private frameIndex = 0;
372
+ private completedCycles = 0;
373
+ private paused = false;
374
+ private interval: ReturnType<typeof setInterval> | undefined;
375
+ private welcomeInterval: ReturnType<typeof setInterval> | undefined;
376
+ private cachedWidth = 0;
377
+ private cachedHeight = 0;
378
+ private cachedVersion = -1;
379
+ private cachedLines: string[] = [];
380
+ private version = 0;
381
+ private closed = false;
382
+ private waitingForStart = true;
383
+ private welcomeTick = 0;
384
+ private buildHoldTick = 0;
385
+
386
+ constructor(
387
+ private readonly tui: Pick<TUI, "requestRender" | "terminal">,
388
+ private readonly styles: ZobIntroStyles,
389
+ private readonly options: ZobIntroOptions,
390
+ private readonly onDone: () => void,
391
+ ) {
392
+ this.tui.terminal.clearScreen();
393
+ this.tui.terminal.hideCursor();
394
+ this.startWelcomeReveal();
395
+ }
396
+
397
+ handleInput(data: string): void {
398
+ if (matchesKey(data, "escape") || data === "q" || data === "Q") {
399
+ this.close();
400
+ return;
401
+ }
402
+ if (this.waitingForStart) {
403
+ if (matchesKey(data, "enter") || matchesKey(data, "space") || data === "\r" || data === "\n" || data === " ") {
404
+ this.waitingForStart = false;
405
+ this.stopWelcomeReveal();
406
+ this.start();
407
+ this.version += 1;
408
+ this.tui.requestRender();
409
+ }
410
+ return;
411
+ }
412
+ if (matchesKey(data, "space") || data === " ") {
413
+ this.paused = !this.paused;
414
+ this.version += 1;
415
+ this.tui.requestRender();
416
+ }
417
+ }
418
+
419
+ invalidate(): void {
420
+ this.cachedWidth = 0;
421
+ this.cachedHeight = 0;
422
+ this.cachedVersion = -1;
423
+ }
424
+
425
+ render(width: number): string[] {
426
+ const height = Math.max(1, this.tui.terminal.rows);
427
+ if (this.cachedWidth === width && this.cachedHeight === height && this.cachedVersion === this.version) return this.cachedLines;
428
+
429
+ const welcomeLines = this.waitingForStart ? this.renderWelcomeLines(width) : [];
430
+ const artLines = this.renderArt(this.options.blockWidth, this.options.blockHeight, this.logicalColsForWidth(width));
431
+ const emptyLine = " ".repeat(Math.max(0, width));
432
+ const contentHeight = artLines.length + welcomeLines.length;
433
+ const topPad = Math.max(0, Math.floor((height - contentHeight) / 2));
434
+ const lines: string[] = [];
435
+
436
+ for (let index = 0; index < topPad; index += 1) lines.push(emptyLine);
437
+ for (const artLine of artLines) lines.push(this.centerLine(artLine, width));
438
+ for (const welcomeLine of welcomeLines) lines.push(this.centerLine(welcomeLine, width, WELCOME_TEXT_SHIFT_LEFT));
439
+ while (lines.length < height) lines.push(emptyLine);
440
+
441
+ this.cachedLines = lines.slice(0, height);
442
+ this.cachedWidth = width;
443
+ this.cachedHeight = height;
444
+ this.cachedVersion = this.version;
445
+ return this.cachedLines;
446
+ }
447
+
448
+ dispose(): void {
449
+ if (this.interval) {
450
+ clearInterval(this.interval);
451
+ this.interval = undefined;
452
+ }
453
+ this.stopWelcomeReveal();
454
+ this.tui.terminal.showCursor();
455
+ }
456
+
457
+ private startWelcomeReveal(): void {
458
+ this.welcomeInterval = setInterval(() => {
459
+ if (!this.waitingForStart) return;
460
+ this.welcomeTick += 1;
461
+ this.version += 1;
462
+ this.tui.requestRender();
463
+ }, 55);
464
+ this.welcomeInterval.unref?.();
465
+ }
466
+
467
+ private stopWelcomeReveal(): void {
468
+ if (!this.welcomeInterval) return;
469
+ clearInterval(this.welcomeInterval);
470
+ this.welcomeInterval = undefined;
471
+ }
472
+
473
+ private start(): void {
474
+ if (this.interval) return;
475
+ this.interval = setInterval(() => {
476
+ if (this.paused) return;
477
+ if (this.shouldHoldBuildFrame()) return;
478
+ this.advanceFrame();
479
+ this.version += 1;
480
+ this.tui.requestRender();
481
+ }, this.options.tickMs);
482
+ this.interval.unref?.();
483
+ }
484
+
485
+ private shouldHoldBuildFrame(): boolean {
486
+ if (this.frameIndex >= ZOB_INTRO_MORPH_FRAMES.length) {
487
+ this.buildHoldTick = 0;
488
+ return false;
489
+ }
490
+ this.buildHoldTick = (this.buildHoldTick + 1) % BUILD_FRAME_TICK_HOLD;
491
+ return this.buildHoldTick !== 0;
492
+ }
493
+
494
+ private advanceFrame(): void {
495
+ this.frameIndex += 1;
496
+ if (this.frameIndex < ZOB_INTRO_FRAME_COUNT) return;
497
+
498
+ this.completedCycles += 1;
499
+ if (this.options.repeat !== null && this.completedCycles >= this.options.repeat) {
500
+ this.frameIndex = ZOB_INTRO_FRAME_COUNT - 1;
501
+ this.close();
502
+ return;
503
+ }
504
+ this.frameIndex = 0;
505
+ }
506
+
507
+ private close(): void {
508
+ if (this.closed) return;
509
+ this.closed = true;
510
+ this.dispose();
511
+ this.onDone();
512
+ }
513
+
514
+ private renderWelcomeLines(width: number): string[] {
515
+ const titleVisible = this.revealText(WELCOME_TITLE, this.welcomeTick);
516
+ const promptStartTick = WELCOME_TITLE.length + 8;
517
+ const promptVisible = this.revealText(START_PROMPT, Math.max(0, this.welcomeTick - promptStartTick));
518
+ const title = this.styles.bold(this.styles.accent(titleVisible)) + " ".repeat(Math.max(0, WELCOME_TITLE.length - titleVisible.length));
519
+ const promptPulse = Math.floor(this.welcomeTick / 12) % 2 === 0;
520
+ const promptText = promptPulse ? this.styles.dim(promptVisible) : this.styles.accent(promptVisible);
521
+ const prompt = promptText + " ".repeat(Math.max(0, START_PROMPT.length - promptVisible.length));
522
+ return ["", title, prompt].filter((line) => visibleWidth(line) <= width || line.length > 0);
523
+ }
524
+
525
+ private revealText(text: string, tick: number): string {
526
+ if (tick <= 0) return "";
527
+ return text.slice(0, Math.min(text.length, tick));
528
+ }
529
+
530
+ private logicalColsForWidth(width: number): number {
531
+ return Math.max(ZOB_INTRO_MIN_CANVAS_COLS, Math.floor(width / Math.max(1, this.options.blockWidth)));
532
+ }
533
+
534
+ private renderArt(blockWidth: number, blockHeight: number, logicalCols: number): string[] {
535
+ const frame = buildIntroFrame(this.frameIndex, logicalCols);
536
+ const lines: string[] = [];
537
+
538
+ for (let rowIndex = 0; rowIndex < LOGICAL_ROWS; rowIndex += 1) {
539
+ const logicalRow = (frame[rowIndex] ?? "").padEnd(logicalCols, ".").slice(0, logicalCols);
540
+ for (let subRow = 0; subRow < blockHeight; subRow += 1) {
541
+ let renderedRow = "";
542
+ for (const cell of logicalRow) renderedRow += this.renderCell(cell, subRow, blockWidth, blockHeight);
543
+ lines.push(renderedRow);
544
+ }
545
+ }
546
+ return lines;
547
+ }
548
+
549
+ private renderCell(cell: string, subRow: number, blockWidth: number, blockHeight: number): string {
550
+ const blank = " ".repeat(blockWidth);
551
+ const solid = (glyph: string, color: (text: string) => string): string => {
552
+ const text = glyph.repeat(blockWidth);
553
+ return this.options.style === "plain" ? text : color(text);
554
+ };
555
+ if (cell === "#") return solid("█", this.styles.block);
556
+ if (cell === "*") return solid("▓", this.styles.exhaustHot);
557
+ if (cell === "+") return solid("▒", this.styles.exhaustDim);
558
+ if (cell === "=") {
559
+ const middle = Math.floor(blockHeight / 2);
560
+ return subRow === middle ? solid("━", this.styles.exhaustHot) : blank;
561
+ }
562
+ if (cell === "~") {
563
+ const middle = Math.floor(blockHeight / 2);
564
+ return subRow === middle ? solid("·", this.styles.exhaustDim) : blank;
565
+ }
566
+ if (cell === "-") {
567
+ const middle = Math.floor(blockHeight / 2);
568
+ return subRow === middle ? solid("─", this.styles.exhaustDim) : blank;
569
+ }
570
+ return blank;
571
+ }
572
+
573
+ private centerLine(line: string, width: number, shiftRight = 0): string {
574
+ if (width <= 0) return "";
575
+ const lineWidth = visibleWidth(line);
576
+ if (lineWidth >= width) return truncateToWidth(line, width, "");
577
+ const leftPad = Math.max(0, Math.min(width - lineWidth, Math.floor((width - lineWidth) / 2) + shiftRight));
578
+ return `${" ".repeat(leftPad)}${line}`;
579
+ }
580
+ }
581
+
582
+ async function handleZobIntroCommand(pi: ExtensionAPI, args: string, ctx: ExtensionContext): Promise<void> {
583
+ const normalized = args.trim().toLowerCase();
584
+ if (normalized === "reset-first-run" || normalized === "reset-startup" || normalized === "reset") {
585
+ await resetFirstRunIntro(ctx.cwd);
586
+ appendZobIntroLedger(pi, DEFAULT_ZOB_INTRO_OPTIONS, "reset");
587
+ ctx.ui.notify("ZOB intro first-run marker reset. Quit and relaunch Pi/ZOB to see the startup intro again.", "info");
588
+ return;
589
+ }
590
+
591
+ const parsed = parseZobIntroArgs(args);
592
+ if (parsed.ok && parsed.help) {
593
+ appendZobIntroLedger(pi, parsed.options, "help");
594
+ ctx.ui.setEditorText(zobIntroHelpTemplate());
595
+ ctx.ui.notify("ZOB intro help inserted. Use /zob-intro or /zob-intro loop.", "info");
596
+ return;
597
+ }
598
+ if (!parsed.ok) {
599
+ appendZobIntroLedger(pi, parsed.options, "blocked", parsed.errors);
600
+ ctx.ui.notify(`/zob-intro blocked: ${parsed.errors.join(" | ")}. Use /zob-intro help.`, "warning");
601
+ return;
602
+ }
603
+ if (!ctx.hasUI) {
604
+ appendZobIntroLedger(pi, parsed.options, "blocked", ["interactive UI required"]);
605
+ ctx.ui.notify("/zob-intro requires interactive Pi TUI mode.", "warning");
606
+ return;
607
+ }
608
+
609
+ appendZobIntroLedger(pi, parsed.options, "played");
610
+ await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
611
+ const styles: ZobIntroStyles = {
612
+ accent: (text) => theme.fg("accent", text),
613
+ block: (text) => theme.fg("accent", text),
614
+ bold: (text) => theme.bold(text),
615
+ dim: (text) => theme.fg("dim", text),
616
+ exhaustHot: (text) => theme.fg("warning", text),
617
+ exhaustDim: (text) => theme.fg("dim", text),
618
+ warning: (text) => theme.fg("warning", text),
619
+ };
620
+ return new ZobIntroComponent(tui, styles, parsed.options, () => done(undefined));
621
+ }, {
622
+ overlay: true,
623
+ overlayOptions: {
624
+ width: "100%",
625
+ maxHeight: "100%",
626
+ anchor: "center",
627
+ margin: 0,
628
+ },
629
+ });
630
+ }
631
+
632
+ export function registerZobIntroCommand(pi: ExtensionAPI): void {
633
+ const command = {
634
+ description: "Play the custom ZOB pixel-art terminal intro animation. Options: once|loop|fast|slow|5x3|6x3|plain|neon.",
635
+ getArgumentCompletions: zobIntroArgumentCompletions,
636
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
637
+ await handleZobIntroCommand(pi, args, ctx);
638
+ },
639
+ };
640
+
641
+ pi.registerCommand("zob-intro", command);
642
+ pi.registerCommand("zintro", { ...command, description: "Alias for /zob-intro." });
643
+ pi.registerCommand("intro", { ...command, description: "Alias for /zob-intro." });
644
+
645
+ pi.on("session_start", async (event, ctx) => {
646
+ if (event.reason !== "startup") return;
647
+ if (!ctx.hasUI) return;
648
+ if (await hasSeenFirstRunIntro(ctx.cwd)) {
649
+ appendZobIntroLedger(pi, DEFAULT_ZOB_INTRO_OPTIONS, "auto_skipped");
650
+ return;
651
+ }
652
+ await markFirstRunIntroSeen(ctx.cwd);
653
+ await handleZobIntroCommand(pi, FIRST_RUN_ARGS, ctx);
654
+ });
655
+
656
+ pi.on("input", async (event, ctx) => {
657
+ const match = /^\/(zob-intro|zintro|intro)(?:\s+(.*))?$/.exec(event.text.trim());
658
+ if (!match) return { action: "continue" as const };
659
+ await handleZobIntroCommand(pi, match[2] ?? "", ctx);
660
+ return { action: "handled" as const };
661
+ });
662
+ }
@@ -15,13 +15,17 @@ Use this skill for:
15
15
  ## Active backend rules
16
16
 
17
17
  1. Prefer `zob_context_search` for repo-local discovery when the runtime tool is available.
18
- 2. When ColGREP is installed and ready, use it as the preferred broad/semantic discovery backend.
19
- 3. Always use grep/find/read or exact file refs for verification before making claims.
20
- 4. When ColGREP is missing, unavailable, or not indexed, fall back to grep/find/read. Missing ColGREP is not a blocker for normal ZOB work.
21
- 5. Do not auto-install ColGREP, run network/package-manager installer commands, or mutate user tooling without explicit owner approval.
22
- 6. Keep search bounded to repo-local allowed paths and task-relevant globs.
23
- 7. Never read forbidden paths or secret-like files, including `.env`, `**/.env`, `**/*secret*`, `**/*key*`, private keys, `.pi/sessions`, `.pi/agent-sessions`, `node_modules`, `dist`, or `build`.
24
- 8. Persist only safe metadata/artifact refs for context packs. Do not persist raw secret/session bodies.
18
+ 2. When ColGREP is installed and ready, exploratory or natural-language repo discovery MUST start with `zob_context_search`/ColGREP before broad grep/find. This includes prompts like “explore this repo”, “where is this mechanism?”, or “find the design/flow”.
19
+ 3. If `zob_context_search` is not exposed in the current session/toolset but `bash` is available, use the compact local wrapper before broad grep: `npm run --silent zob:context:query -- --query "<natural language query>" --max-results 6 --max-context-lines 1`.
20
+ 4. Do not treat missing native-tool exposure as permission to immediately use broad `rg`/`grep`; the wrapper is the ColGREP path for those sessions.
21
+ 5. Run one exploratory context search, then read returned refs; retry only when results are empty or clearly irrelevant.
22
+ 6. Use grep/find/read after semantic discovery for exact proof, known identifiers/strings, final citations, and line refs. If the exact identifier/path/string is already known, grep/read may be used directly.
23
+ 7. When ColGREP is missing, unavailable, or not indexed, fall back to grep/find/read. Missing ColGREP is not a blocker for normal ZOB work.
24
+ 8. Do not auto-install ColGREP, run network/package-manager installer commands, or mutate user tooling without explicit owner approval.
25
+ 9. Keep search bounded to repo-local allowed paths and task-relevant globs.
26
+ 10. Never run broad grep/find over `.pi` unless `.pi/sessions` and `.pi/agent-sessions` are explicitly excluded/pruned.
27
+ 11. Never read forbidden paths or secret-like files, including `.env`, `**/.env`, `**/*secret*`, `**/*key*`, private keys, `.pi/sessions`, `.pi/agent-sessions`, `node_modules`, `dist`, or `build`.
28
+ 12. Persist only safe metadata/artifact refs for context packs. Do not persist raw secret/session bodies.
25
29
 
26
30
  ## User setup and scripts
27
31
 
@@ -34,7 +38,12 @@ Use this skill for:
34
38
 
35
39
  - Active-backend prompt injection is controlled by `.pi/context-discovery.json` under `promptInjection.enabled`.
36
40
  - The injected block must stay concise, current-repo scoped, and bounded by the configured include/exclude roots; it is a discovery hint, not a context pack or evidence source.
37
- - Do not inject stale/global context or raw search results into the prompt. Use `zob_context_search` and then read exact files when details are needed.
41
+ - Do not inject stale/global context or raw search results into the prompt. Use `zob_context_search` or the compact `npm run --silent zob:context:query` wrapper, then read exact files when details are needed.
42
+
43
+ ## UX / performance posture
44
+
45
+ - Native `zob_context_search` execution must remain async/non-blocking from the Pi extension perspective; avoid synchronous long-running ColGREP calls in tool handlers.
46
+ - Default discovery output should be compact: a small set of refs/previews, then read exact files. Do not dump raw ColGREP JSON or code bodies into the chat by default.
38
47
 
39
48
  ## Evidence expectations
40
49
 
@@ -34,7 +34,7 @@ Communication is a core deliverable. Prefer one parent-visible control room by d
34
34
  1. Classify the task as one of: `explore`, `plan`, `implement`, `oracle`, `factory`, `orchestrator`. When `.pi/routing/intent-classifier.json` enables an optional model classifier, treat it as advisory intent routing only; regex fallback and deterministic safety hard-blocks remain authoritative. `autoSwitchIntents` controls which detected intents switch mode directly; this project enables all ZOB modes by default. Use `/intent-classifier status|regex|model-strict|model-fallback|test` (alias `/intent`) to switch/test routing without editing JSON by hand.
35
35
  2. For non-trivial or tool-ambiguous work, apply `zob-tool-router`: classify applicable families, then use/delegate/skip each with a reason.
36
36
  3. Use `orchestrator` when the task needs Chief Vision coordination, multi-agent decomposition, Lead/Worker routing, goal/TODO graph governance, or parent-owned dispatch; the root should delegate substantive work rather than do it directly.
37
- 4. Check `.pi/capabilities/zob-public-runtime-capabilities.json` for the relevant tool/command family, mode allowlist, skill refs, and no-ship notes. For context tasks, prefer the active backend via `zob_context_search`; use ColGREP when ready, and grep/find/read for fallback plus exact verification.
37
+ 4. Check `.pi/capabilities/zob-public-runtime-capabilities.json` for the relevant tool/command family, mode allowlist, skill refs, and no-ship notes. For exploratory/natural-language context tasks, start with the active backend via `zob_context_search`; use ColGREP when ready before broad grep/find. If `zob_context_search` is not exposed but bash is available, run `npm run --silent zob:context:query -- --query "<query>" --max-results 6 --max-context-lines 1` before `rg`/`grep`. Use grep/find/read for fallback plus exact verification. Never broad-grep `.pi` without pruning `.pi/sessions` and `.pi/agent-sessions`.
38
38
  5. If broad or risky, use the `delegate_agent` tool before editing.
39
39
  6. For delegated work, use the six-part contract:
40
40
  - TASK
@@ -53,7 +53,7 @@ Do not use this skill for a small factual answer when no tools are needed.
53
53
  | delegation | Specialist review, parallel discovery, implementation handoff, independent QA, or uncertain broad work | `zob_delegation_catalog`, `delegate_task`, `delegate_agent`; skill `zob-delegation-routing` |
54
54
  | orchestration | Lead/Worker lanes, Chief Vision coordination, parent-owned dispatch, multi-agent workgraph | `/zmode orchestrator`, `orchestrate_run`, `chain_run`, goal/TODO tools |
55
55
  | compute | Complexity, budget, model class, max profile, or multi-agent sizing matters | `zob_compute_preview`, `zob_compute_resolve_profile`, `zob_compute_plan_workflow`, `zob_compute_validate_profile`; skill `zob-compute-profile` |
56
- | context / ProjectDNA | Need bounded repo/reference context, scan artifacts, cited context packs, or writeback proposals | `zob_context_validate_scope`, `zob_context_readiness`, `zob_project_dna_query`, `zob_project_dna_federated_query`, `zob_project_dna_readiness`; skill `zob-project-dna` |
56
+ | context / ProjectDNA | Need bounded repo/reference context, scan artifacts, cited context packs, or writeback proposals | `zob_context_search`, `zob_context_validate_scope`, `zob_context_readiness`, `zob_project_dna_query`, `zob_project_dna_federated_query`, `zob_project_dna_readiness`; skills `zob-context-discovery`, `zob-project-dna` |
57
57
  | factory | Repeated workflow, smoke/pilot/batch gates, manifests, checkpoints, sentinels | `factory_run`, factory quarantine tools, autonomous factory read-only smokes; skill `zob-factory` |
58
58
  | coms / goal-room | Parent-visible coordination, live required-local handoff, blockers, TODO claims, status refs | `zob_goal_room_*`, `zob_coms_*`; skills `zob-coms-v2-live`, `zob-coms-safety` |
59
59
  | workspace / merge queue | Parallel write intent, sandbox diff review, parent-owned manual apply decisions | `zob_workspace_claim`, `zob_workspace_release`, `zob_merge_candidate_submit`, `zob_merge_queue_decide`; skill `zob-sandbox` |
package/AGENTS.md CHANGED
@@ -49,7 +49,7 @@ Use the registry plus the domain skill instead of copying tool docs into prompts
49
49
  - Delegation/catalog: `zob_delegation_catalog`, `delegate_agent`, `delegate_task` -> `.pi/skills/zob-delegation-routing/SKILL.md` and `.pi/skills/zob-harness/SKILL.md`; call the catalog before first delegation when agent/contract/tool routing is uncertain, normally omit `delegate_task.output_contract` and `delegate_task.required_tools`, and never invent contract IDs or agent tools.
50
50
  - Live coms: `zob_coms_*` -> `.pi/skills/zob-coms-v2-live/SKILL.md` and `.pi/skills/zob-coms-safety/SKILL.md`; required-local delivery must be live, never append-only success.
51
51
  - Mission Control: `zob_mission_control_*` / readiness -> `.pi/skills/zob-mission-control-coms/SKILL.md`; command writes are proposals only.
52
- - Context: `zob_context_search`, `zob_context_*` -> registry no-ship notes plus `.pi/skills/zob-context-discovery/SKILL.md`, `.pi/skills/zob-harness/SKILL.md`, and `.pi/skills/zob-spec/SKILL.md` as applicable; prefer the active search backend (ColGREP when ready, grep/find/read fallback) and verify exact claims with file refs.
52
+ - Context: `zob_context_search`, `zob_context_*` -> registry no-ship notes plus `.pi/skills/zob-context-discovery/SKILL.md`, `.pi/skills/zob-harness/SKILL.md`, and `.pi/skills/zob-spec/SKILL.md` as applicable; for exploratory/natural-language repo discovery, start with the active search backend (`zob_context_search`/ColGREP when ready, grep/find/read fallback) before broad grep/find; if the native tool is not exposed but bash is available, run the compact wrapper `npm run --silent zob:context:query -- --query "<query>" --max-results 6 --max-context-lines 1` before `rg`/`grep`; then verify exact claims with grep/read/file refs; never broad-grep `.pi` without pruning `.pi/sessions` and `.pi/agent-sessions`.
53
53
  - Compute profile / effort routing: `zob_compute_*`, `npm run preview:compute-profile:project-dna-smoke`, `npm run validate:compute-profile:project-dna-smoke` -> `.pi/skills/zob-compute-profile/SKILL.md`; preview/resolve/report tools are metadata-only and never bypass safety, budget, oracle, sandbox, or parent-owned dispatch gates.
54
54
  - Autonomy: `zob_autonomous_*` -> `.pi/skills/zob-autonomous-runtime/SKILL.md`; dry-run/readonly smoke/validation are supervised evidence only, not global autonomy completion.
55
55
  - Factory quarantine/run: `factory_quarantine_*`, `factory_run` -> `.pi/skills/zob-factory/SKILL.md` and `.pi/skills/zob-sandbox/SKILL.md`.
package/README.md CHANGED
@@ -332,7 +332,7 @@ This release adds the handoff runtime/docs and `npm run smoke:goal-todo-handoff`
332
332
 
333
333
  ### Use active context discovery
334
334
 
335
- ZOB can adapt context search to the active backend. Use `zob_context_search` inside Pi when available: it prefers ColGREP for broad/semantic repo discovery when ColGREP is installed and indexed, and falls back to grep/find/read when it is missing or unavailable. Treat broad search hits as leads and verify exact claims with file refs before editing or reporting readiness.
335
+ ZOB can adapt context search to the active backend. Use `zob_context_search` inside Pi when available: it prefers ColGREP for broad/semantic repo discovery when ColGREP is installed and indexed, runs native search asynchronously from the extension handler, and falls back to grep/find/read when it is missing or unavailable. Treat broad search hits as leads and verify exact claims with file refs before editing or reporting readiness.
336
336
 
337
337
  ColGREP setup is optional and owner-driven; ZOB must not auto-install it or run installer/package-manager commands. Local helpers:
338
338
 
@@ -343,7 +343,7 @@ npm run zob:context:query -- "goal todo routing" # one-shot query with grep fal
343
343
  npm run smoke:context-discovery # deterministic fallback smoke; passes without ColGREP
344
344
  ```
345
345
 
346
- Forbidden/secret/session/vendor/build paths remain excluded from discovery. Active-backend prompt injection is bounded and configurable through `.pi/context-discovery.json` (`promptInjection.enabled`); it should never inject stale/global context or raw search results. Oracle/no-ship review for this feature checks context freshness, citation coverage, exact grep/read verification, forbidden-source violations, and no unapproved installer/network behavior.
346
+ Forbidden/secret/session/vendor/build paths remain excluded from discovery. Active-backend prompt injection is bounded and configurable through `.pi/context-discovery.json` (`promptInjection.enabled`); it should never inject stale/global context or raw search results. When ColGREP is ready, exploratory/natural-language repo discovery should start with `zob_context_search`/ColGREP before broad grep/find; if the native tool is not exposed but `bash` is available, use the compact wrapper `npm run --silent zob:context:query -- --query "<query>" --max-results 6 --max-context-lines 1` before `rg`/`grep`. Then use grep/read for exact proof and citations. Broad grep/find over `.pi` must explicitly prune `.pi/sessions` and `.pi/agent-sessions`. Oracle/no-ship review for this feature checks context freshness, citation coverage, exact grep/read verification, forbidden-source violations, and no unapproved installer/network behavior.
347
347
 
348
348
  See [`reports/context-discovery/design.md`](reports/context-discovery/design.md), [`.pi/skills/zob-context-discovery/SKILL.md`](.pi/skills/zob-context-discovery/SKILL.md), and [scripts/README.md](scripts/README.md) for the operating rules and script map.
349
349
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zob-harness",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "type": "module",
5
5
  "description": "A governed Agent Factory for Pi: launch communicating agent teams, run tmux-backed factories, validate artifacts, and package repeatable workflows.",
6
6
  "license": "MIT",
package/scripts/README.md CHANGED
@@ -8,7 +8,7 @@ These script families are intended to be part of the normal tracked repo workflo
8
8
 
9
9
  - `scripts/autonomy/` — static/read-only autonomy readiness smokes.
10
10
  - `scripts/compute-profile/` — compute profile policy and regression checks.
11
- - `scripts/context-discovery/` — adaptive active search backend helpers: `npm run zob:context:doctor`, `npm run zob:context:init`, `npm run zob:context:query`, and `npm run smoke:context-discovery`; prefers ColGREP when installed/ready and validates grep fallback when it is absent.
11
+ - `scripts/context-discovery/` — adaptive active search backend helpers: `npm run zob:context:doctor`, `npm run zob:context:init`, `npm run zob:context:query`, and `npm run smoke:context-discovery`; starts exploratory/natural-language discovery with ColGREP when installed/ready, gives sessions without native `zob_context_search` a compact wrapper command before `rg`/`grep`, validates grep fallback when absent, and keeps broad grep/find away from `.pi/sessions` / `.pi/agent-sessions`.
12
12
  - `scripts/git-ops/` — governed `/zcommit` policy smokes.
13
13
  - `scripts/goal-todo/` — Goal/TODO tree compatibility smokes, including `scripts/goal-todo/handoff-static-smoke.mjs` coverage for the Goal TODO ZPeer/ZTeam handoff script (`npm run smoke:goal-todo-handoff`).
14
14
  - `scripts/harness-intake/` — natural-language harness setup/session analyzer that produces quarantined ZOB team/factory proposals plus tmux launch support.
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawnSync } from "node:child_process";
3
- import { detectColgrep, fallbackSearch, loadConfig, parseArgs, printHumanSearch, printJson } from "./shared.mjs";
3
+ import { detectColgrep, fallbackSearch, loadConfig, normalizeColgrepResults, parseArgs, printHumanSearch, printJson } from "./shared.mjs";
4
4
 
5
5
  const args = parseArgs(process.argv.slice(2));
6
6
  const query = args.query ?? args.q ?? args._.join(" ");
@@ -32,15 +32,9 @@ if (colgrep.ready) {
32
32
  });
33
33
 
34
34
  if (colgrepResult.status === 0) {
35
- result = {
36
- provider: "colgrep",
37
- fallback: false,
38
- query,
39
- resultCount: undefined,
40
- raw: colgrepResult.stdout.trim(),
41
- stderr: colgrepResult.stderr.trim(),
42
- recommendedVerification: ["Use grep/read on returned repo-relative refs for exact proof."],
43
- };
35
+ result = normalizeColgrepResults(colgrepResult.stdout, { query, config, maxResults, maxContextLines });
36
+ result.stderr = colgrepResult.stderr.trim().slice(0, 240);
37
+ result.colgrepArgs = colgrepArgs;
44
38
  } else {
45
39
  result = runFallback("colgrep-query-failed");
46
40
  result.colgrepQueryStatus = colgrepResult.status;
@@ -53,9 +47,6 @@ if (colgrep.ready) {
53
47
 
54
48
  if (args.json) {
55
49
  printJson(result);
56
- } else if (result.provider === "colgrep") {
57
- console.log("provider: colgrep");
58
- console.log(result.raw);
59
50
  } else {
60
51
  printHumanSearch(result);
61
52
  }
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
4
- import { basename, dirname, extname, join, normalize, relative, sep } from "node:path";
4
+ import { basename, extname, isAbsolute, join, normalize, relative, sep } from "node:path";
5
5
 
6
6
  export const repoRoot = process.cwd();
7
7
  export const configPath = ".pi/context-discovery.json";
@@ -33,8 +33,8 @@ export const defaultConfig = {
33
33
  "build",
34
34
  ],
35
35
  limits: {
36
- maxResults: 20,
37
- maxContextLines: 2,
36
+ maxResults: 6,
37
+ maxContextLines: 1,
38
38
  maxFileBytes: 1024 * 1024,
39
39
  },
40
40
  promptInjection: {
@@ -126,13 +126,35 @@ export function parseArgs(argv) {
126
126
  }
127
127
 
128
128
  export function normalizeRepoPath(raw) {
129
- const normalized = normalize(String(raw).replace(/^\.\//u, ""));
129
+ if (typeof raw !== "string" || raw.trim().length === 0 || raw.includes("\0") || raw.includes("\\")) {
130
+ return null;
131
+ }
132
+ const trimmed = raw.trim().replace(/^\.\//u, "");
133
+ if (isAbsolute(trimmed)) {
134
+ return null;
135
+ }
136
+ const normalized = normalize(trimmed);
130
137
  if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith(`..${sep}`)) {
131
138
  return null;
132
139
  }
133
140
  return normalized.split(sep).join("/");
134
141
  }
135
142
 
143
+ export function normalizeBackendPath(raw) {
144
+ if (typeof raw !== "string" || raw.trim().length === 0 || raw.includes("\0")) {
145
+ return null;
146
+ }
147
+ const trimmed = raw.trim();
148
+ if (!isAbsolute(trimmed)) {
149
+ return normalizeRepoPath(trimmed);
150
+ }
151
+ const relPath = relative(repoRoot, trimmed);
152
+ if (!relPath || relPath === ".." || relPath.startsWith(`..${sep}`) || isAbsolute(relPath)) {
153
+ return null;
154
+ }
155
+ return normalizeRepoPath(relPath);
156
+ }
157
+
136
158
  function globToRegExp(pattern) {
137
159
  const escaped = pattern
138
160
  .split("*")
@@ -141,7 +163,7 @@ function globToRegExp(pattern) {
141
163
  return new RegExp(`^${escaped}$`, "iu");
142
164
  }
143
165
 
144
- function isExcluded(relPath, excludePaths) {
166
+ export function isExcluded(relPath, excludePaths) {
145
167
  const normalized = normalizeRepoPath(relPath);
146
168
  if (!normalized) {
147
169
  return true;
@@ -231,6 +253,51 @@ export function fallbackSearch({ query, config, maxResults, maxContextLines }) {
231
253
  };
232
254
  }
233
255
 
256
+ export function normalizeColgrepResults(stdout, { query, config, maxResults, maxContextLines }) {
257
+ let parsed;
258
+ try {
259
+ parsed = JSON.parse(stdout);
260
+ } catch {
261
+ parsed = [];
262
+ }
263
+ const candidates = Array.isArray(parsed)
264
+ ? parsed
265
+ : parsed && typeof parsed === "object"
266
+ ? [parsed.results, parsed.matches, parsed.items].find(Array.isArray) ?? []
267
+ : [];
268
+ const results = [];
269
+ for (const item of candidates) {
270
+ if (!item || typeof item !== "object") continue;
271
+ const unit = item.unit && typeof item.unit === "object" ? item.unit : {};
272
+ const path = normalizeBackendPath(item.path ?? item.file ?? item.filename ?? item.source_path ?? unit.path ?? unit.file ?? unit.filename ?? unit.source_path);
273
+ if (!path || isExcluded(path, config.excludePaths) || !existsSync(join(repoRoot, path))) continue;
274
+ const lineValue = item.line ?? item.lineNumber ?? item.line_number ?? item.start_line ?? unit.line ?? unit.lineNumber ?? unit.line_number ?? unit.start_line;
275
+ const line = Number.isFinite(Number(lineValue)) ? Math.max(1, Math.floor(Number(lineValue))) : undefined;
276
+ const previewSource = item.preview ?? item.text ?? item.lineText ?? item.match ?? unit.docstring ?? unit.signature ?? unit.qualified_name ?? unit.name ?? path;
277
+ const preview = String(previewSource).replace(/\s+/gu, " ").trim().slice(0, 240);
278
+ results.push({
279
+ path,
280
+ line,
281
+ ref: line ? `${path}:${line}` : path,
282
+ preview,
283
+ score: Number.isFinite(Number(item.score)) ? Number(item.score) : undefined,
284
+ });
285
+ if (results.length >= maxResults) break;
286
+ }
287
+ return {
288
+ provider: "colgrep",
289
+ fallback: false,
290
+ query,
291
+ maxResults,
292
+ maxContextLines,
293
+ resultCount: results.length,
294
+ results,
295
+ recommendedVerification: results.length
296
+ ? [`read ${results[0].path}`, "After reading, grep exact identifiers/strings found in returned refs for final proof."]
297
+ : ["No compact ColGREP refs parsed; retry with a narrower query or inspect ColGREP status."],
298
+ };
299
+ }
300
+
234
301
  export function printJson(value) {
235
302
  process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
236
303
  }
@@ -242,6 +309,10 @@ export function printHumanSearch(result) {
242
309
  for (const item of result.results ?? []) {
243
310
  console.log(`- ${item.ref}: ${item.preview}`);
244
311
  }
312
+ const verification = Array.isArray(result.recommendedVerification) ? result.recommendedVerification.slice(0, 2) : [];
313
+ if (verification.length > 0) {
314
+ console.log(`verify: ${verification.join(" ; ")}`);
315
+ }
245
316
  }
246
317
 
247
318
  export function repoRelative(path) {
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawnSync } from "node:child_process";
3
+ import { readFileSync } from "node:fs";
3
4
 
4
5
  const env = { ...process.env, ZOB_CONTEXT_FORCE_FALLBACK: "1" };
5
6
  const result = spawnSync(process.execPath, ["scripts/context-discovery/query.mjs", "--query", "ZOB Harness", "--json", "--max-results", "5"], {
@@ -31,6 +32,62 @@ if (parsed.provider !== "grep-fallback" || parsed.fallback !== true || parsed.re
31
32
  process.exit(1);
32
33
  }
33
34
 
35
+ const constantsSource = readFileSync(".pi/extensions/zob-harness/src/core/constants.ts", "utf8");
36
+ const runtimeSource = readFileSync(".pi/extensions/zob-harness/src/domains/context/context-discovery.ts", "utf8");
37
+ const querySource = readFileSync("scripts/context-discovery/query.mjs", "utf8");
38
+ const sharedSource = readFileSync("scripts/context-discovery/shared.mjs", "utf8");
39
+ const requiredPromptFragments = [
40
+ "For exploratory, natural-language",
41
+ "start with zob_context_search/ColGREP before grep/find",
42
+ "npm run --silent zob:context:query -- --query",
43
+ "Do not conclude the native tool is unavailable and immediately use broad rg/grep",
44
+ "Run one exploratory context search, then read the returned refs",
45
+ "Use grep/read after semantic discovery",
46
+ "Never run broad grep/find over .pi unless .pi/sessions and .pi/agent-sessions are explicitly excluded/pruned",
47
+ "unit?.file",
48
+ "normalizeBackendPath(repoRoot, rawPath)",
49
+ ];
50
+ const requiredWrapperFragments = [
51
+ "normalizeColgrepResults",
52
+ "printHumanSearch(result)",
53
+ ];
54
+ const requiredAsyncRuntimeFragments = [
55
+ "async function runColgrep",
56
+ "spawn(\"colgrep\"",
57
+ "export async function runContextSearch",
58
+ "await runColgrep",
59
+ "COLGREP_TIMEOUT_MS = 30_000",
60
+ ];
61
+ const forbiddenWrapperFragments = [
62
+ "console.log(result.raw)",
63
+ "raw: colgrepResult.stdout",
64
+ ];
65
+ const missingWrapperFragments = requiredWrapperFragments.filter((fragment) => !querySource.includes(fragment) && !sharedSource.includes(fragment));
66
+ const missingAsyncRuntimeFragments = requiredAsyncRuntimeFragments.filter((fragment) => !runtimeSource.includes(fragment));
67
+ const presentForbiddenWrapperFragments = forbiddenWrapperFragments.filter((fragment) => querySource.includes(fragment));
68
+ const contextReadToolsLine = constantsSource.match(/ZOB_CONTEXT_READ_TOOLS = \[(?<tools>[^\]]+)\]/u)?.groups?.tools ?? "";
69
+ if (!contextReadToolsLine.includes('"zob_context_search"')) {
70
+ console.error("context-discovery smoke FAIL: zob_context_search missing from ZOB_CONTEXT_READ_TOOLS native mode allowlist");
71
+ process.exit(1);
72
+ }
73
+
74
+ const missingPromptFragments = requiredPromptFragments.filter((fragment) => !runtimeSource.includes(fragment));
75
+ if (missingPromptFragments.length > 0) {
76
+ console.error("context-discovery smoke FAIL: runtime prompt hardening fragments missing");
77
+ console.error(JSON.stringify({ missingPromptFragments }, null, 2));
78
+ process.exit(1);
79
+ }
80
+ if (missingWrapperFragments.length > 0 || presentForbiddenWrapperFragments.length > 0) {
81
+ console.error("context-discovery smoke FAIL: ColGREP wrapper compact-output contract broken");
82
+ console.error(JSON.stringify({ missingWrapperFragments, presentForbiddenWrapperFragments }, null, 2));
83
+ process.exit(1);
84
+ }
85
+ if (missingAsyncRuntimeFragments.length > 0) {
86
+ console.error("context-discovery smoke FAIL: native ColGREP execution must stay async/non-blocking");
87
+ console.error(JSON.stringify({ missingAsyncRuntimeFragments }, null, 2));
88
+ process.exit(1);
89
+ }
90
+
34
91
  console.log("context-discovery smoke PASS");
35
92
  console.log(`provider=${parsed.provider} reason=${parsed.reason} results=${parsed.resultCount}`);
36
93
  console.log(`evidence=${parsed.results.find((entry) => typeof entry.path === "string")?.ref}`);