pi-crew 0.8.4 → 0.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,56 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.5] — Per-write validator (T5) + validateWorkflowForTeam race note (2026-06-16)
4
+
5
+ Third APPLIED technique from the pi-ecosystem distillation (pi-lens /
6
+ apmantza — the "inline channel"). Adds real-time feedback on file
7
+ writes/edits: a CHEAP synchronous validator runs on every `write`/`edit`
8
+ tool result and appends a `🔴` blocker to the tool result on failure, so
9
+ malformed files are caught the moment they're written — not at the next
10
+ load.
11
+
12
+ ### Latency-safe v1 design (deliberate scope)
13
+
14
+ pi-lens runs LSP servers + linters per write. That is expensive and would
15
+ cause latency storms if naively ported (seconds of spawn per edit, firing in
16
+ the main session AND every worker). This v1 ships ONLY zero-cost, zero-spawn,
17
+ synchronous validators:
18
+
19
+ - **`json` → `JSON.parse`** (nanoseconds, built-in, no process spawn).
20
+
21
+ The registry is extensible — process-spawning validators (`.js` → `node
22
+ --check`, `.sh` → `bash -n`, `.py` → `py_compile`) are a FUTURE opt-in
23
+ (never default-on), and will need to be async + debounced (pi-lens's
24
+ `inFlightPipelines` / debounce-window pattern) when added.
25
+
26
+ ### Contract guarantees
27
+ - Synchronous. No `await`, no `spawn`, no disk write.
28
+ - One disk READ per validated file (after a cheap extension check, so
29
+ non-validated files cost nothing).
30
+ - Dedup by content: the same path+content is validated at most once per
31
+ process.
32
+ - Silent on success; appends exactly one TextContent block on failure.
33
+ - Best-effort: any internal error is swallowed (never breaks a write).
34
+ - Toggle: `runtime.reliability.perWriteValidation` (default `true` → opt-out).
35
+
36
+ ### Files
37
+ - NEW `src/runtime/per-write-validator.ts` — `validateJson`, the extensible
38
+ `PerWriteValidator` registry, dedup cache, `validateWrittenFile`, and
39
+ `buildValidationBlocker`. Test seams: `setPerWriteValidatorsForTest`,
40
+ `resetPerWriteValidatorCache`.
41
+ - `src/config/types.ts` — `reliability.perWriteValidation?: boolean`.
42
+ - `src/extension/register.ts` — `pi.on("tool_result", ...)` handler for
43
+ `write`/`edit` (pi-crew previously subscribed only to `tool_call`).
44
+ - NEW `test/unit/t5-per-write-validator.test.ts` (15 tests).
45
+ - NEW `.github/issues/2026-06-16-validateworkflowf-team-cold-start-race.md` —
46
+ honest note that the `validateWorkflowForTeam` cold-start error (same
47
+ class as v0.8.1's `existsSync`) was NOT actually fixed by v0.8.1's latch
48
+ (that covered only the peer-dep namespace). Documents the corrected
49
+ root cause (tsx makes every named import a runtime namespace access) and
50
+ 4 candidate fixes for the later pass.
51
+
52
+ typecheck clean; full suite 0 failures.
53
+
3
54
  ## [0.8.4] — cold-verifier agent (T9) (2026-06-16)
4
55
 
5
56
  Second APPLIED technique from the pi-ecosystem distillation (piolium /
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.8.4",
3
+ "version": "0.8.5",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -180,6 +180,15 @@ export interface CrewReliabilityConfig {
180
180
  cleanupOrphanedTempDirs?: boolean;
181
181
  /** Inject a compact ambient crew-status note into the agent's context on every LLM call while crew runs are in-flight, so the agent stays continuously aware of active runs without calling the `team` tool. No-op when no runs are active. Default: true. */
182
182
  ambientStatusInjection?: boolean;
183
+ /**
184
+ * Per-write validation (T5). On every `write`/`edit` tool result, run a
185
+ * zero-cost synchronous validator for the file type and append a `🔴`
186
+ * blocker to the tool result on failure (e.g. malformed JSON). v1 ships
187
+ * JSON only (`JSON.parse` — instant, no process spawn); process-spawning
188
+ * validators (.js/.sh/.py) are a future opt-in. Default: true (opt-out).
189
+ * Set to `false` to disable.
190
+ */
191
+ perWriteValidation?: boolean;
183
192
  /**
184
193
  * Opt-in model scope enforcement (F7). When true, subagent model choices
185
194
  * that fall outside the user's pi `enabledModels` allowlist are flagged:
@@ -82,6 +82,7 @@ import {
82
82
  import { RenderScheduler } from "../ui/render-scheduler.ts";
83
83
  import { runEventBus } from "../ui/run-event-bus.ts";
84
84
  import { createTerminalStatusController, type TerminalStatusController } from "../ui/terminal-status.ts";
85
+ import { extractPathFromInput, validateWrittenFile, buildValidationBlocker } from "../runtime/per-write-validator.ts";
85
86
  import { createRunSnapshotCache } from "../ui/run-snapshot-cache.ts";
86
87
  import { closeWatcher } from "../utils/fs-watch.ts";
87
88
  import { RunWatcherRegistry } from "../utils/run-watcher-registry.ts";
@@ -1986,6 +1987,27 @@ export function registerPiTeams(pi: ExtensionAPI): void {
1986
1987
  };
1987
1988
  });
1988
1989
 
1990
+ // T5 (v0.8.5): per-write validation. On write/edit, run a zero-cost
1991
+ // SYNCHRONOUS validator (v1: JSON.parse) and append a 🔴 blocker to the
1992
+ // tool result on failure — catches malformed config the moment it's
1993
+ // written, not at the next load. Latency-safe by construction: no process
1994
+ // spawn, one disk read ONLY for validated extensions, dedup'd by content.
1995
+ // Toggle via runtime.reliability.perWriteValidation (default true).
1996
+ // Process-spawning validators (.js/.sh/.py) are a future opt-in.
1997
+ pi.on("tool_result", (event, ctx) => {
1998
+ try {
1999
+ if (event.toolName !== "write" && event.toolName !== "edit") return;
2000
+ if (loadConfig(ctx.cwd).config.reliability?.perWriteValidation === false) return;
2001
+ const filePath = extractPathFromInput(event.input);
2002
+ if (!filePath) return;
2003
+ const result = validateWrittenFile(filePath);
2004
+ if (!result || result.ok) return;
2005
+ return { content: [...event.content, buildValidationBlocker(filePath, result.error ?? "validation failed")] };
2006
+ } catch {
2007
+ // best-effort: never break a tool result
2008
+ }
2009
+ });
2010
+
1989
2011
  registerTeamTool(pi, {
1990
2012
  foregroundControllers,
1991
2013
  startForegroundRun,
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Per-write validator — real-time feedback on file writes/edits (T5).
3
+ *
4
+ * Distilled from pi-lens (apmantza) — the "inline channel": on every
5
+ * `write`/`edit` tool result, run a CHEAP synchronous validator for the file
6
+ * type and, on failure, append a `🔴` blocker block to the tool result the
7
+ * agent sees next. This catches silent-breaking errors (malformed JSON
8
+ * config) at the moment they're introduced instead of at the next load.
9
+ *
10
+ * CRITICAL LATENCY-SAFETY DESIGN (the reason this is a careful slice, not the
11
+ * full pi-lens pipeline): pi-lens runs LSP servers + linters per write. That
12
+ * is expensive and would cause latency storms if naively ported (seconds of
13
+ * spawn per edit, firing in the main session AND every worker). This module's
14
+ * v1 deliberately ships ONLY zero-cost, zero-spawn, synchronous validators:
15
+ *
16
+ * - `json` → `JSON.parse` (nanoseconds, built-in, no process spawn).
17
+ *
18
+ * The registry is extensible — future validators (`.js` → `node --check`,
19
+ * `.sh` → `bash -n`, `.py` → `py_compile`) are process-spawning and MUST be
20
+ * added behind an explicit opt-in (never default-on) to preserve the
21
+ * latency guarantee. A process-spawning validator would also need to be async
22
+ * and debounced (pi-lens's `inFlightPipelines` / debounce-window pattern),
23
+ * which the current sync contract intentionally avoids.
24
+ *
25
+ * Contract guarantees for v1:
26
+ * - Synchronous. No `await`, no `spawn`, no disk write.
27
+ * - One disk READ per validated file (after a cheap extension check, so
28
+ * non-validated files cost nothing).
29
+ * - Dedup by content: the same path+content is validated at most once per
30
+ * process (a repeated identical write doesn't re-report).
31
+ * - Silent on success; appends exactly one TextContent block on failure.
32
+ * - Best-effort: any internal error is swallowed (never breaks a write).
33
+ *
34
+ * @module per-write-validator
35
+ */
36
+
37
+ import { readFileSync } from "node:fs";
38
+ import { extname as pathExtname } from "node:path";
39
+
40
+ /** Outcome of validating a file's content. */
41
+ export interface ValidationResult {
42
+ ok: boolean;
43
+ /** Human-readable error message when `ok` is false. */
44
+ error?: string;
45
+ }
46
+
47
+ /** A synchronous validator: content + path → result. */
48
+ export type PerWriteValidator = (content: string, filePath: string) => ValidationResult;
49
+
50
+ // ─────────────────────────────────────────────────────────────────────────
51
+ // Validators (zero-cost, synchronous, dependency-free for v1)
52
+ // ─────────────────────────────────────────────────────────────────────────
53
+
54
+ /** JSON: parse with `JSON.parse`. Catches malformed config/manifests instantly. */
55
+ export function validateJson(content: string, _filePath: string): ValidationResult {
56
+ if (content.trim() === "") return { ok: true }; // empty file is valid JSON absence, not a parse error
57
+ try {
58
+ JSON.parse(content);
59
+ return { ok: true };
60
+ } catch (error) {
61
+ const message = error instanceof Error ? error.message : String(error);
62
+ return { ok: false, error: `Invalid JSON: ${message}` };
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Registry of default-on validators, keyed by extension (lowercase, no dot).
68
+ * ONLY zero-cost synchronous validators belong here. Process-spawning
69
+ * validators must be registered via a future opt-in path (see module doc).
70
+ */
71
+ const DEFAULT_VALIDATORS: ReadonlyMap<string, PerWriteValidator> = new Map([
72
+ ["json", validateJson],
73
+ ]);
74
+
75
+ // ─────────────────────────────────────────────────────────────────────────
76
+ // Dedup cache (path → last-validated content). Bounded; small.
77
+ // ─────────────────────────────────────────────────────────────────────────
78
+
79
+ const MAX_DEDUP_ENTRIES = 256;
80
+ const seenContent = new Map<string, string>();
81
+
82
+ function rememberSeen(path: string, content: string): void {
83
+ if (seenContent.has(path)) seenContent.delete(path); // refresh LRU position
84
+ seenContent.set(path, content);
85
+ while (seenContent.size > MAX_DEDUP_ENTRIES) {
86
+ const oldest = seenContent.keys().next().value;
87
+ if (oldest === undefined) break;
88
+ seenContent.delete(oldest);
89
+ }
90
+ }
91
+
92
+ /** Test seam: reset the dedup cache between tests. */
93
+ export function resetPerWriteValidatorCache(): void {
94
+ seenContent.clear();
95
+ }
96
+
97
+ /**
98
+ * Replace the validator registry (test seam). Production uses
99
+ * DEFAULT_VALIDATORS; tests inject a custom map to exercise specific extensions.
100
+ */
101
+ let validators: ReadonlyMap<string, PerWriteValidator> = DEFAULT_VALIDATORS;
102
+
103
+ export function setPerWriteValidatorsForTest(map: ReadonlyMap<string, PerWriteValidator> | undefined): void {
104
+ validators = map ?? DEFAULT_VALIDATORS;
105
+ }
106
+
107
+ /**
108
+ * Normalise an extension to the registry key form (lowercase, no leading dot).
109
+ * "" for files with no extension.
110
+ */
111
+ export function extensionKey(filePath: string): string {
112
+ return pathExtname(filePath).replace(/^\./, "").toLowerCase();
113
+ }
114
+
115
+ // ─────────────────────────────────────────────────────────────────────────
116
+ // Path extraction from a tool_result event input (defensive — pi-ai types
117
+ // aren't exported here, so accept a record and probe common field names).
118
+ // ─────────────────────────────────────────────────────────────────────────
119
+
120
+ const PATH_FIELDS = ["filePath", "path", "file"] as const;
121
+
122
+ /** Extract the written/edited path from a tool result input, if present. */
123
+ export function extractPathFromInput(input: unknown): string | undefined {
124
+ if (!input || typeof input !== "object") return undefined;
125
+ const record = input as Record<string, unknown>;
126
+ for (const field of PATH_FIELDS) {
127
+ const value = record[field];
128
+ if (typeof value === "string" && value.length > 0) return value;
129
+ }
130
+ return undefined;
131
+ }
132
+
133
+ // ─────────────────────────────────────────────────────────────────────────
134
+ // Core entry point
135
+ // ─────────────────────────────────────────────────────────────────────────
136
+
137
+ /**
138
+ * Validate a just-written/edited file. Returns `null` when there is nothing
139
+ * to report (no validator for the extension, dedup hit, file unreadable, or
140
+ * the content is valid). Returns a `ValidationResult` with `ok:false` when the
141
+ * content fails validation.
142
+ *
143
+ * Reads the file from disk (it's already written by `tool_result` time) so the
144
+ * logic is uniform across `write` (full content) and `edit` (patch). The disk
145
+ * read happens ONLY after a cheap extension check, so non-validated files cost
146
+ * nothing.
147
+ */
148
+ export function validateWrittenFile(filePath: string): ValidationResult | null {
149
+ const key = extensionKey(filePath);
150
+ const validator = validators.get(key);
151
+ if (!validator) return null; // cheap skip: no validator for this file type
152
+ let content: string;
153
+ try {
154
+ content = readFileSync(filePath, "utf-8");
155
+ } catch {
156
+ // Unreadable / missing / permission denied — can't validate; never block.
157
+ return null;
158
+ }
159
+ // Dedup: identical content already validated this process → don't re-report.
160
+ if (seenContent.get(filePath) === content) return null;
161
+ rememberSeen(filePath, content);
162
+ const result = validator(content, filePath);
163
+ return result.ok ? null : result;
164
+ }
165
+
166
+ /**
167
+ * Build the TextContent block to append to a tool_result on validation failure.
168
+ * Uses a strong `🔴` prefix so the agent treats it as a real signal and fixes
169
+ * the file before continuing.
170
+ */
171
+ export function buildValidationBlocker(filePath: string, error: string): { type: "text"; text: string } {
172
+ return {
173
+ type: "text",
174
+ text: [
175
+ "",
176
+ "🔴 pi-crew per-write check FAILED",
177
+ ` ${filePath}`,
178
+ ` ${error}`,
179
+ " The file you just wrote is malformed. Fix it now — a broken file here will",
180
+ " silently fail the next load/parse. Re-write the file with valid content before continuing.",
181
+ ].join("\n"),
182
+ };
183
+ }