taskplane 0.22.11 → 0.22.13

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.
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Agent Mailbox — file-based cross-agent messaging utilities.
3
+ *
4
+ * Provides the core mailbox operations for the agent-mailbox-steering
5
+ * protocol: write, read, and acknowledge messages in batch-scoped,
6
+ * session-scoped inbox directories.
7
+ *
8
+ * Directory structure:
9
+ * ```
10
+ * .pi/mailbox/{batchId}/
11
+ * ├── {sessionName}/
12
+ * │ ├── inbox/ ← pending messages
13
+ * │ └── ack/ ← processed messages (moved from inbox)
14
+ * └── _broadcast/
15
+ * └── inbox/ ← messages to all agents
16
+ * ```
17
+ *
18
+ * All file operations are synchronous (matching rpc-wrapper pattern).
19
+ * Write operations are atomic (temp file + rename in same directory).
20
+ * Read/ack operations are best-effort (log warnings, don't crash).
21
+ *
22
+ * @module orch/mailbox
23
+ * @since TP-089
24
+ */
25
+
26
+ import { join, dirname } from "path";
27
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, renameSync, unlinkSync } from "fs";
28
+ import { randomBytes } from "crypto";
29
+ import type { MailboxMessage, MailboxMessageType, WriteMailboxMessageOpts } from "./types.ts";
30
+ import { MAILBOX_DIR_NAME, MAILBOX_MAX_CONTENT_BYTES, MAILBOX_MESSAGE_TYPES } from "./types.ts";
31
+
32
+ // ── Path Helpers ─────────────────────────────────────────────────────
33
+
34
+ /**
35
+ * Root directory for all mailboxes in a batch.
36
+ *
37
+ * @param stateRoot - Root directory containing .pi/ (workspace root or repo root)
38
+ * @param batchId - Batch ID for scoping
39
+ * @returns Absolute path: `{stateRoot}/.pi/mailbox/{batchId}/`
40
+ *
41
+ * @since TP-089
42
+ */
43
+ export function mailboxRoot(stateRoot: string, batchId: string): string {
44
+ return join(stateRoot, ".pi", MAILBOX_DIR_NAME, batchId);
45
+ }
46
+
47
+ /**
48
+ * Inbox directory for a specific agent session.
49
+ *
50
+ * @param stateRoot - Root directory containing .pi/
51
+ * @param batchId - Batch ID
52
+ * @param sessionName - tmux session name (unique per batch)
53
+ * @returns Absolute path: `{stateRoot}/.pi/mailbox/{batchId}/{sessionName}/inbox/`
54
+ *
55
+ * @since TP-089
56
+ */
57
+ export function sessionInboxDir(stateRoot: string, batchId: string, sessionName: string): string {
58
+ return join(stateRoot, ".pi", MAILBOX_DIR_NAME, batchId, sessionName, "inbox");
59
+ }
60
+
61
+ /**
62
+ * Ack directory for a specific agent session.
63
+ *
64
+ * @param stateRoot - Root directory containing .pi/
65
+ * @param batchId - Batch ID
66
+ * @param sessionName - tmux session name
67
+ * @returns Absolute path: `{stateRoot}/.pi/mailbox/{batchId}/{sessionName}/ack/`
68
+ *
69
+ * @since TP-089
70
+ */
71
+ export function sessionAckDir(stateRoot: string, batchId: string, sessionName: string): string {
72
+ return join(stateRoot, ".pi", MAILBOX_DIR_NAME, batchId, sessionName, "ack");
73
+ }
74
+
75
+ /**
76
+ * Broadcast inbox directory (messages to all agents).
77
+ *
78
+ * @param stateRoot - Root directory containing .pi/
79
+ * @param batchId - Batch ID
80
+ * @returns Absolute path: `{stateRoot}/.pi/mailbox/{batchId}/_broadcast/inbox/`
81
+ *
82
+ * @since TP-089
83
+ */
84
+ export function broadcastInboxDir(stateRoot: string, batchId: string): string {
85
+ return join(stateRoot, ".pi", MAILBOX_DIR_NAME, batchId, "_broadcast", "inbox");
86
+ }
87
+
88
+
89
+ // ── Write ────────────────────────────────────────────────────────────
90
+
91
+ /**
92
+ * Write a message to a target agent's inbox.
93
+ *
94
+ * Generates a unique message ID and writes the message atomically
95
+ * (temp file + rename in the same directory). The temp file uses a
96
+ * `.msg.json.tmp` extension that is excluded by the inbox reader's
97
+ * `*.msg.json` filter.
98
+ *
99
+ * @param stateRoot - Root directory containing .pi/
100
+ * @param batchId - Current batch ID
101
+ * @param to - Target session name or `"_broadcast"`
102
+ * @param opts - Message content and metadata from the caller
103
+ * @returns The written MailboxMessage (including generated fields)
104
+ * @throws If content exceeds 4KB UTF-8 bytes or file I/O fails
105
+ *
106
+ * @since TP-089
107
+ */
108
+ export function writeMailboxMessage(
109
+ stateRoot: string,
110
+ batchId: string,
111
+ to: string,
112
+ opts: WriteMailboxMessageOpts,
113
+ ): MailboxMessage {
114
+ // Validate content size (UTF-8 bytes, not string length)
115
+ const contentBytes = Buffer.byteLength(opts.content, "utf8");
116
+ if (contentBytes > MAILBOX_MAX_CONTENT_BYTES) {
117
+ throw new Error(
118
+ `Mailbox message content exceeds ${MAILBOX_MAX_CONTENT_BYTES} byte limit ` +
119
+ `(${contentBytes} bytes). Steering messages should be concise directives. ` +
120
+ `Write larger context to a file and reference it by path.`,
121
+ );
122
+ }
123
+
124
+ // Generate unique message ID
125
+ const timestamp = Date.now();
126
+ const nonce = randomBytes(3).toString("hex").slice(0, 5);
127
+ const id = `${timestamp}-${nonce}`;
128
+
129
+ // Build the full message
130
+ const message: MailboxMessage = {
131
+ id,
132
+ batchId,
133
+ from: opts.from,
134
+ to,
135
+ timestamp,
136
+ type: opts.type,
137
+ content: opts.content,
138
+ expectsReply: opts.expectsReply ?? false,
139
+ replyTo: opts.replyTo ?? null,
140
+ };
141
+
142
+ // Determine inbox directory
143
+ const inboxDir = to === "_broadcast"
144
+ ? broadcastInboxDir(stateRoot, batchId)
145
+ : sessionInboxDir(stateRoot, batchId, to);
146
+
147
+ // Ensure inbox directory exists
148
+ mkdirSync(inboxDir, { recursive: true });
149
+
150
+ // Atomic write: temp file (.msg.json.tmp) then rename to final (.msg.json)
151
+ const finalFilename = `${id}.msg.json`;
152
+ const tempFilename = `${id}.msg.json.tmp`;
153
+ const tempPath = join(inboxDir, tempFilename);
154
+ const finalPath = join(inboxDir, finalFilename);
155
+
156
+ try {
157
+ writeFileSync(tempPath, JSON.stringify(message, null, 2) + "\n", "utf-8");
158
+ renameSync(tempPath, finalPath);
159
+ } catch (err) {
160
+ // Attempt cleanup of temp file on failure
161
+ try {
162
+ if (existsSync(tempPath)) unlinkSync(tempPath);
163
+ } catch {
164
+ // Best effort cleanup
165
+ }
166
+ throw new Error(
167
+ `Failed to write mailbox message to ${finalPath}: ${err instanceof Error ? err.message : String(err)}`,
168
+ );
169
+ }
170
+
171
+ return message;
172
+ }
173
+
174
+
175
+ // ── Read ─────────────────────────────────────────────────────────────
176
+
177
+ /**
178
+ * Read pending messages from an inbox directory.
179
+ *
180
+ * Returns messages sorted by timestamp (ascending), with filename
181
+ * lexical order as tie-breaker. Only reads files matching the
182
+ * `*.msg.json` pattern (excludes `.msg.json.tmp` temp files).
183
+ *
184
+ * Messages with invalid shape or mismatched batchId are logged as
185
+ * warnings and left in the inbox (no throw/crash).
186
+ *
187
+ * @param inboxDir - Absolute path to the inbox directory
188
+ * @param expectedBatchId - Expected batch ID for validation
189
+ * @returns Sorted array of `{ filename, message }` entries
190
+ *
191
+ * @since TP-089
192
+ */
193
+ export function readInbox(
194
+ inboxDir: string,
195
+ expectedBatchId: string,
196
+ ): Array<{ filename: string; message: MailboxMessage }> {
197
+ // Return empty if directory doesn't exist
198
+ if (!existsSync(inboxDir)) return [];
199
+
200
+ let entries: string[];
201
+ try {
202
+ entries = readdirSync(inboxDir);
203
+ } catch (err) {
204
+ process.stderr.write(
205
+ `[mailbox] WARNING: failed to read inbox ${inboxDir}: ${err instanceof Error ? err.message : String(err)}\n`,
206
+ );
207
+ return [];
208
+ }
209
+
210
+ // Filter: only *.msg.json files (excludes .msg.json.tmp, .tmp, etc.)
211
+ const msgFiles = entries.filter(f => f.endsWith(".msg.json") && !f.endsWith(".msg.json.tmp"));
212
+
213
+ const results: Array<{ filename: string; message: MailboxMessage }> = [];
214
+
215
+ for (const filename of msgFiles) {
216
+ const filePath = join(inboxDir, filename);
217
+ let raw: string;
218
+ try {
219
+ raw = readFileSync(filePath, "utf-8");
220
+ } catch (err) {
221
+ process.stderr.write(
222
+ `[mailbox] WARNING: failed to read ${filePath}: ${err instanceof Error ? err.message : String(err)}\n`,
223
+ );
224
+ continue;
225
+ }
226
+
227
+ let parsed: unknown;
228
+ try {
229
+ parsed = JSON.parse(raw);
230
+ } catch {
231
+ process.stderr.write(
232
+ `[mailbox] WARNING: malformed JSON in ${filename}, skipping\n`,
233
+ );
234
+ continue;
235
+ }
236
+
237
+ // Validate shape
238
+ if (!isValidMailboxMessage(parsed)) {
239
+ process.stderr.write(
240
+ `[mailbox] WARNING: invalid message shape in ${filename}, skipping\n`,
241
+ );
242
+ continue;
243
+ }
244
+
245
+ const msg = parsed as MailboxMessage;
246
+
247
+ // Validate batchId
248
+ if (msg.batchId !== expectedBatchId) {
249
+ process.stderr.write(
250
+ `[mailbox] WARNING: batchId mismatch in ${filename} (expected ${expectedBatchId}, got ${msg.batchId}), skipping\n`,
251
+ );
252
+ continue;
253
+ }
254
+
255
+ results.push({ filename, message: msg });
256
+ }
257
+
258
+ // Sort: primary by timestamp (ascending), tie-break by filename lexical
259
+ results.sort((a, b) => {
260
+ const tsDiff = a.message.timestamp - b.message.timestamp;
261
+ if (tsDiff !== 0) return tsDiff;
262
+ return a.filename.localeCompare(b.filename);
263
+ });
264
+
265
+ return results;
266
+ }
267
+
268
+
269
+ // ── Acknowledge ──────────────────────────────────────────────────────
270
+
271
+ /**
272
+ * Move a message from inbox to ack directory.
273
+ *
274
+ * Atomic rename. If the file is already gone (another process acked it),
275
+ * returns false. The ack directory is derived structurally from the inbox
276
+ * directory: `dirname(inboxDir)/ack/`.
277
+ *
278
+ * @param inboxDir - Absolute path to the inbox directory
279
+ * @param filename - Message filename (e.g., `1774744971303-a7f2c.msg.json`)
280
+ * @returns true if acked successfully, false if already acked (ENOENT race)
281
+ *
282
+ * @since TP-089
283
+ */
284
+ export function ackMessage(inboxDir: string, filename: string): boolean {
285
+ const ackDir = join(dirname(inboxDir), "ack");
286
+
287
+ try {
288
+ mkdirSync(ackDir, { recursive: true });
289
+ } catch (err) {
290
+ process.stderr.write(
291
+ `[mailbox] WARNING: failed to create ack dir ${ackDir}: ${err instanceof Error ? err.message : String(err)}\n`,
292
+ );
293
+ return false;
294
+ }
295
+
296
+ const srcPath = join(inboxDir, filename);
297
+ const dstPath = join(ackDir, filename);
298
+
299
+ try {
300
+ renameSync(srcPath, dstPath);
301
+ return true;
302
+ } catch (err: unknown) {
303
+ const code = (err as NodeJS.ErrnoException).code;
304
+ if (code === "ENOENT") {
305
+ // Another process already acked this message — race is harmless
306
+ return false;
307
+ }
308
+ process.stderr.write(
309
+ `[mailbox] WARNING: failed to ack ${filename}: ${err instanceof Error ? err.message : String(err)}\n`,
310
+ );
311
+ return false;
312
+ }
313
+ }
314
+
315
+
316
+ // ── Validation ───────────────────────────────────────────────────────
317
+
318
+ /**
319
+ * Runtime validation for mailbox message shape.
320
+ *
321
+ * Checks that all required fields are present and correctly typed.
322
+ * Does not validate batchId match (caller's responsibility).
323
+ *
324
+ * @param obj - Parsed JSON value to validate
325
+ * @returns true if obj is a valid MailboxMessage shape
326
+ *
327
+ * @since TP-089
328
+ */
329
+ export function isValidMailboxMessage(obj: unknown): obj is MailboxMessage {
330
+ if (!obj || typeof obj !== "object") return false;
331
+ const m = obj as Record<string, unknown>;
332
+ return (
333
+ typeof m.id === "string" &&
334
+ typeof m.batchId === "string" &&
335
+ typeof m.from === "string" &&
336
+ typeof m.to === "string" &&
337
+ typeof m.timestamp === "number" && Number.isFinite(m.timestamp) &&
338
+ typeof m.type === "string" && MAILBOX_MESSAGE_TYPES.has(m.type) &&
339
+ typeof m.content === "string"
340
+ );
341
+ }
@@ -585,6 +585,7 @@ export async function spawnMergeAgent(
585
585
  config: OrchestratorConfig,
586
586
  stateRoot?: string,
587
587
  agentRoot?: string,
588
+ batchId?: string,
588
589
  ): Promise<void> {
589
590
  execLog("merge", sessionName, "preparing to spawn merge agent", {
590
591
  mergeWorkDir,
@@ -658,6 +659,14 @@ export async function spawnMergeAgent(
658
659
  wrapperParts.push("--tools", shellQuote(config.merge.tools));
659
660
  }
660
661
 
662
+ // TP-089: Agent mailbox steering — pass --mailbox-dir when batchId is available.
663
+ if (batchId) {
664
+ const mailboxDir = join(sidecarRoot, "mailbox", batchId, sessionName);
665
+ mkdirSync(join(mailboxDir, "inbox"), { recursive: true });
666
+ wrapperParts.push("--mailbox-dir", shellQuote(mailboxDir));
667
+ execLog("merge", sessionName, "mailbox enabled", { mailboxDir });
668
+ }
669
+
661
670
  const piCommand = wrapperParts.filter(Boolean).join(" ");
662
671
 
663
672
  const tmuxMergeDir = toTmuxPath(mergeWorkDir);
@@ -1511,12 +1520,12 @@ export async function mergeWave(
1511
1520
  }
1512
1521
 
1513
1522
  // Re-spawn merge agent for the retry
1514
- await spawnMergeAgent(sessionName, repoRoot, mergeWorkDir, requestFilePath, config, stateRoot, agentRoot);
1523
+ await spawnMergeAgent(sessionName, repoRoot, mergeWorkDir, requestFilePath, config, stateRoot, agentRoot, batchId);
1515
1524
  // TP-056: Re-register with health monitor after respawn
1516
1525
  if (healthMonitor) healthMonitor.addSession(sessionName, lane.laneNumber, resultFilePath);
1517
1526
  } else {
1518
1527
  // First attempt: spawn merge agent
1519
- await spawnMergeAgent(sessionName, repoRoot, mergeWorkDir, requestFilePath, config, stateRoot, agentRoot);
1528
+ await spawnMergeAgent(sessionName, repoRoot, mergeWorkDir, requestFilePath, config, stateRoot, agentRoot, batchId);
1520
1529
  // TP-056: Register session with health monitor
1521
1530
  if (healthMonitor) healthMonitor.addSession(sessionName, lane.laneNumber, resultFilePath);
1522
1531
  }
@@ -332,7 +332,7 @@ git log --oneline orch/{branch}..task/{lane-branch} # empty = already merged
332
332
  4. After merge, run tests to verify:
333
333
  ```bash
334
334
  git worktree add /tmp/verify orch/{orchBranch} --detach
335
- cd /tmp/verify && cd extensions && npx vitest run
335
+ cd /tmp/verify && cd extensions && node --experimental-strip-types --experimental-test-module-mocks --no-warnings --import ./tests/loader.mjs --test tests/*.test.ts
336
336
  ```
337
337
  5. Update batch state and advance.
338
338
 
@@ -545,7 +545,7 @@ git worktree remove .worktrees/{opId}-{batchId}/merge --force
545
545
  ### Verify orch branch integrity
546
546
  ```bash
547
547
  git worktree add /tmp/tp-verify orch/{orchBranch} --detach
548
- cd /tmp/tp-verify/extensions && npx vitest run
548
+ cd /tmp/tp-verify/extensions && node --experimental-strip-types --experimental-test-module-mocks --no-warnings --import ./tests/loader.mjs --test tests/*.test.ts
549
549
  # Clean up: cd {repoRoot} && git worktree remove /tmp/tp-verify --force
550
550
  ```
551
551
 
@@ -770,6 +770,12 @@ You have these orchestrator tools available:
770
770
  **Note:** `orch_retry_task`, `orch_skip_task`, and `orch_force_merge` require the batch to be paused/stopped first.
771
771
  If the batch is actively running, call `orch_pause()` first.
772
772
 
773
+ **Diagnostic & Recovery Tools (TP-096):**
774
+ - `read_agent_status(lane?)` — Read STATUS.md + telemetry for a lane (step, progress, context %, cost, elapsed). Omit lane for all lanes.
775
+ - `trigger_wrap_up(lane)` — Write `.task-wrap-up` signal to gracefully stop a worker on a lane.
776
+ - `read_lane_logs(lane)` — Read stderr/crash logs and exit diagnostics for a lane.
777
+ - `list_active_agents()` — List all tmux sessions with role, lane, task, context %, elapsed, cost.
778
+
773
779
  Plus general tools: `read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`
774
780
  for inspecting files, running git commands, and editing batch state.
775
781
 
@@ -1333,7 +1339,7 @@ before writing** — if files already exist (partial setup), read and merge.
1333
1339
  **Customization notes:**
1334
1340
  - `project.name`: Use the actual project name (from package.json, README, etc.)
1335
1341
  - `paths.tasks` and `taskAreas`: Match what was agreed in the task area discussion
1336
- - `testing.commands`: Use the detected test command as a named object (e.g., `{"test": "cd extensions && npx vitest run"}`)
1342
+ - `testing.commands`: Use the detected test command as a named object (e.g., `{"test": "cd extensions && node --experimental-strip-types --experimental-test-module-mocks --no-warnings --import ./tests/loader.mjs --test tests/*.test.ts"}`)
1337
1343
  - `orchestrator.spawnMode`: Use `"tmux"` if tmux is available, `"subprocess"` otherwise
1338
1344
  - `orchestrator.maxLanes`: Start with 2 for first-time users (safe default)
1339
1345
  - `merge.verify`: Add the project's test command for post-merge verification
@@ -103,7 +103,7 @@ export const ACTION_CLASSIFICATION_EXAMPLES: Readonly<Record<RecoveryActionClass
103
103
  diagnostic: [
104
104
  "Reading batch-state.json, STATUS.md, events.jsonl, merge results",
105
105
  "Running git status, git log, git diff",
106
- "Running test suites (npx vitest run, etc.)",
106
+ "Running test suites (node --experimental-strip-types --experimental-test-module-mocks --no-warnings --import ./tests/loader.mjs --test ..., etc.)",
107
107
  "Listing tmux sessions (tmux list-sessions)",
108
108
  "Checking worktree health (git worktree list)",
109
109
  "Reading any file for diagnostics",
@@ -2051,7 +2051,7 @@ Every action you take falls into one of three categories:
2051
2051
  ### Diagnostic (always allowed — no confirmation needed)
2052
2052
  - Reading batch-state.json, STATUS.md, events.jsonl, merge results
2053
2053
  - Running \`git status\`, \`git log\`, \`git diff\`
2054
- - Running test suites (\`npx vitest run\`, etc.)
2054
+ - Running test suites (\`node --experimental-strip-types --experimental-test-module-mocks --no-warnings --import ./tests/loader.mjs --test ...\`, etc.)
2055
2055
  - Listing tmux sessions (\`tmux list-sessions\`)
2056
2056
  - Checking worktree health (\`git worktree list\`)
2057
2057
  - Reading any file for diagnostics
@@ -225,7 +225,7 @@ export interface TaskArea {
225
225
  export interface TaskRunnerConfig {
226
226
  task_areas: Record<string, TaskArea>;
227
227
  reference_docs: Record<string, string>;
228
- /** Named testing/verification commands (e.g., { test: "npx vitest run" }). Used for baseline fingerprinting (TP-032). */
228
+ /** Named testing/verification commands (e.g., { test: "node --test tests/*.test.ts" }). Used for baseline fingerprinting (TP-032). */
229
229
  testing_commands?: Record<string, string>;
230
230
  /**
231
231
  * Model fallback behavior when a configured model becomes unavailable mid-batch.
@@ -3357,3 +3357,97 @@ export function createRepoModeContext(
3357
3357
  };
3358
3358
  }
3359
3359
 
3360
+
3361
+ // ── Agent Mailbox Types (TP-089) ─────────────────────────────────────
3362
+
3363
+ /**
3364
+ * Mailbox directory name under .pi/.
3365
+ * @since TP-089
3366
+ */
3367
+ export const MAILBOX_DIR_NAME = "mailbox";
3368
+
3369
+ /**
3370
+ * Maximum content size in UTF-8 bytes.
3371
+ * Steering messages should be concise directives; larger context should be
3372
+ * written to a separate file and referenced by path.
3373
+ * @since TP-089
3374
+ */
3375
+ export const MAILBOX_MAX_CONTENT_BYTES = 4096;
3376
+
3377
+ /**
3378
+ * Message types for the agent mailbox system.
3379
+ *
3380
+ * | Type | Direction | Purpose |
3381
+ * |------------|---------------------|--------------------------------------------|
3382
+ * | `steer` | supervisor → agent | Course correction. Agent must follow. |
3383
+ * | `query` | supervisor → agent | Request for status/info. Agent replies. |
3384
+ * | `abort` | supervisor → agent | Graceful stop. Agent wraps up and exits. |
3385
+ * | `info` | supervisor → agent | FYI context. No action required. |
3386
+ * | `reply` | agent → supervisor | Response to query or steer acknowledgment. |
3387
+ * | `escalate` | agent → supervisor | Agent-initiated: blocked or needs guidance. |
3388
+ *
3389
+ * @since TP-089
3390
+ */
3391
+ export type MailboxMessageType = "steer" | "query" | "abort" | "info" | "reply" | "escalate";
3392
+
3393
+ /**
3394
+ * Set of valid mailbox message types for runtime validation.
3395
+ * @since TP-089
3396
+ */
3397
+ export const MAILBOX_MESSAGE_TYPES: ReadonlySet<string> = new Set<MailboxMessageType>([
3398
+ "steer", "query", "abort", "info", "reply", "escalate",
3399
+ ]);
3400
+
3401
+ /**
3402
+ * Message format for the file-based agent mailbox.
3403
+ *
3404
+ * Messages are written as JSON files in batch-scoped, session-scoped
3405
+ * directories. The rpc-wrapper checks the inbox on every `message_end`
3406
+ * event and injects pending messages into the agent's LLM context via
3407
+ * pi's `steer` RPC command.
3408
+ *
3409
+ * @see docs/specifications/taskplane/agent-mailbox-steering.md
3410
+ * @since TP-089
3411
+ */
3412
+ export interface MailboxMessage {
3413
+ /** Unique message ID: `{timestamp}-{5char-hex-nonce}` */
3414
+ id: string;
3415
+ /** Batch ID — must match current batch for validation */
3416
+ batchId: string;
3417
+ /** Sender identifier: `"supervisor"` or session name */
3418
+ from: string;
3419
+ /** Target session name or `"_broadcast"` */
3420
+ to: string;
3421
+ /** Epoch milliseconds (Date.now()) */
3422
+ timestamp: number;
3423
+ /** Message type */
3424
+ type: MailboxMessageType;
3425
+ /** Message body (max 4KB UTF-8 bytes) */
3426
+ content: string;
3427
+ /** Whether the sender expects a reply (default: false) */
3428
+ expectsReply?: boolean;
3429
+ /** Reference to a previous message ID for threading (default: null) */
3430
+ replyTo?: string | null;
3431
+ }
3432
+
3433
+ /**
3434
+ * Input options for writeMailboxMessage.
3435
+ *
3436
+ * The caller provides these fields; the utility generates `id`, `batchId`,
3437
+ * `to`, and `timestamp` from its own arguments.
3438
+ *
3439
+ * @since TP-089
3440
+ */
3441
+ export interface WriteMailboxMessageOpts {
3442
+ /** Sender identifier: `"supervisor"` or session name */
3443
+ from: string;
3444
+ /** Message type */
3445
+ type: MailboxMessageType;
3446
+ /** Message body (max 4KB UTF-8 bytes) */
3447
+ content: string;
3448
+ /** Whether the sender expects a reply (default: false) */
3449
+ expectsReply?: boolean;
3450
+ /** Reference to a previous message ID for threading (default: null) */
3451
+ replyTo?: string | null;
3452
+ }
3453
+
@@ -27,10 +27,15 @@
27
27
  * 6. Truncate to 512 chars (bound fingerprint size)
28
28
  *
29
29
  * **Fallback for non-JSON output:**
30
- * If vitest JSON parsing fails (truncated, missing, non-JSON), produce a
31
- * single fingerprint with kind: "command_error" and the first 512 chars
30
+ * If legacy Vitest JSON parsing fails (truncated, missing, non-JSON), produce
31
+ * a single fingerprint with kind: "command_error" and the first 512 chars
32
32
  * of stderr (or stdout) as messageNorm.
33
33
  *
34
+ * **Compatibility note:**
35
+ * Taskplane's default tests use Node.js native `node:test`. The Vitest parser
36
+ * in this module is retained only for backward compatibility when projects
37
+ * provide custom `testing.commands` that still emit Vitest JSON.
38
+ *
34
39
  * @module orch/verification
35
40
  */
36
41
  import { spawnSync } from "child_process";
@@ -300,20 +305,20 @@ function classifyFailureKind(message: string): TestFingerprint["kind"] {
300
305
  }
301
306
 
302
307
  /**
303
- * Parse vitest JSON reporter output into test fingerprints.
308
+ * Parse legacy Vitest JSON reporter output into test fingerprints.
304
309
  *
305
- * Expects the stdout to contain a JSON object matching vitest's JSON reporter format.
310
+ * Expects stdout to contain a JSON object matching Vitest's JSON reporter format.
306
311
  * Only failed tests produce fingerprints (passed tests are irrelevant for baseline diffing).
307
312
  *
308
313
  * If JSON parsing fails or the structure is unexpected, returns null to signal
309
314
  * that the caller should use fallback fingerprinting.
310
315
  *
311
316
  * @param commandId - The command that produced this output
312
- * @param stdout - Raw stdout from the vitest command
317
+ * @param stdout - Raw stdout from the Vitest command (legacy compatibility path)
313
318
  * @returns Array of fingerprints for failed tests, or null if parsing fails
314
319
  */
315
320
  export function parseVitestOutput(commandId: string, stdout: string): TestFingerprint[] | null {
316
- // Try to extract JSON from stdout (vitest may prepend/append non-JSON lines)
321
+ // Try to extract JSON from stdout (Vitest may prepend/append non-JSON lines)
317
322
  let json: VitestJsonResult;
318
323
  try {
319
324
  // First attempt: parse the whole stdout as JSON
@@ -363,7 +368,7 @@ export function parseVitestOutput(commandId: string, stdout: string): TestFinger
363
368
  }
364
369
 
365
370
  // Suite-level failures: testResults[].status === "failed" with no assertion-level details.
366
- // This covers setup/import/runtime-at-file-load errors where vitest marks the file as
371
+ // This covers setup/import/runtime-at-file-load errors where Vitest marks the file as
367
372
  // failed but produces no assertionResults (or only non-failed ones).
368
373
  if (testFile.status === "failed") {
369
374
  const hasFailedAssertions = hasAssertions && assertions!.some(a => a.status === "failed");
@@ -388,7 +393,7 @@ export function parseVitestOutput(commandId: string, stdout: string): TestFinger
388
393
  * Parse test output into normalized fingerprints.
389
394
  *
390
395
  * Strategy:
391
- * 1. Try vitest JSON adapter
396
+ * 1. Try legacy Vitest JSON adapter
392
397
  * 2. If parsing fails: produce a fallback command_error fingerprint
393
398
  *
394
399
  * The adapter pattern is extensible — future parsers for jest, pytest, etc.
@@ -416,7 +421,7 @@ export function parseTestOutput(commandResult: CommandResult): TestFingerprint[]
416
421
  return [];
417
422
  }
418
423
 
419
- // Try vitest JSON adapter
424
+ // Try legacy Vitest JSON adapter
420
425
  const vitestFingerprints = parseVitestOutput(commandId, stdout);
421
426
  if (vitestFingerprints !== null && vitestFingerprints.length > 0) {
422
427
  return vitestFingerprints;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.22.11",
3
+ "version": "0.22.13",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -68,7 +68,7 @@ Every action you take falls into one of three categories:
68
68
  ### Diagnostic (always allowed — no confirmation needed)
69
69
  - Reading batch-state.json, STATUS.md, events.jsonl, merge results
70
70
  - Running `git status`, `git log`, `git diff`
71
- - Running test suites (`npx vitest run`, etc.)
71
+ - Running test suites (`node --experimental-strip-types --experimental-test-module-mocks --no-warnings --import ./tests/loader.mjs --test ...`, etc.)
72
72
  - Listing tmux sessions (`tmux list-sessions`)
73
73
  - Checking worktree health (`git worktree list`)
74
74
  - Reading any file for diagnostics
@@ -275,30 +275,24 @@ Run tests at two different scopes depending on where you are in the task:
275
275
 
276
276
  ### During implementation steps (targeted tests)
277
277
 
278
- After implementing each step, run **targeted tests** for fast feedback:
278
+ After implementing each step, run **targeted tests** for fast feedback.
279
+ Use file-targeted runs for the test files that cover your changes:
279
280
 
280
281
  ```bash
281
- cd extensions && npx vitest run --changed
282
+ cd extensions && node --experimental-strip-types --experimental-test-module-mocks --no-warnings --import ./tests/loader.mjs --test tests/some-specific.test.ts
282
283
  ```
283
284
 
284
- - Vitest's `--changed` flag uses git to find modified files since the last commit
285
- and runs only tests related to those files.
286
- - Workers commit at step boundaries, so between commits the changed set is
287
- exactly "what this step modified" this naturally targets the right tests.
288
- - Alternatively, run specific test files that cover the code you modified:
289
- `npx vitest run tests/some-specific.test.ts`
290
- - **If `--changed` returns no tests:** That's fine — it means your changes don't
291
- have directly related test files. The full suite in the Testing step will catch
292
- any indirect regressions.
293
- - **If targeted tests fail:** Fix the failure before proceeding. Don't accumulate
294
- failures across steps.
285
+ - Node's native runner does not provide a reliable project-level `--changed`
286
+ equivalent; select targeted files explicitly.
287
+ - If multiple files are relevant, pass multiple `--test` paths.
288
+ - **If targeted tests fail:** fix them before proceeding. Don't accumulate failures.
295
289
 
296
290
  ### During the Testing & Verification step (full suite)
297
291
 
298
292
  Run the **full test suite** as a quality gate:
299
293
 
300
294
  ```bash
301
- cd extensions && npx vitest run
295
+ cd extensions && node --experimental-strip-types --experimental-test-module-mocks --no-warnings --import ./tests/loader.mjs --test tests/*.test.ts
302
296
  ```
303
297
 
304
298
  - ALL tests must pass — zero failures allowed.