pi-harness-runtime 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Git Worktree Manager — RFC-0005
3
+ *
4
+ * Creates isolated workspaces for parallel or recoverable coding tasks.
5
+ *
6
+ * Goals:
7
+ * - avoid branch conflicts
8
+ * - preserve partial work
9
+ * - allow reviewer agent to inspect diffs
10
+ * - allow separate models to work on separate tasks
11
+ */
12
+
13
+ import type { WorktreeInfo } from "../../packages/types/src/runtime-types.ts";
14
+ // @ts-expect-error - Bun has built-in Node.js types
15
+ import { execSync } from "node:child_process";
16
+ // @ts-expect-error - Bun has built-in Node.js types
17
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
18
+ // @ts-expect-error - Bun has built-in Node.js types
19
+ import { join, dirname } from "node:path";
20
+
21
+ export interface WorktreeOptions {
22
+ rootDir: string;
23
+ baseBranch?: string;
24
+ }
25
+
26
+ export interface CreateWorktreeOptions {
27
+ name: string;
28
+ branch?: string;
29
+ jobId?: string;
30
+ taskId?: string;
31
+ startPoint?: string;
32
+ }
33
+
34
+ export interface WorktreeDiff {
35
+ file: string;
36
+ status: "added" | "modified" | "deleted" | "renamed";
37
+ insertions?: number;
38
+ deletions?: number;
39
+ }
40
+
41
+ export class WorktreeManager {
42
+ private readonly rootDir: string;
43
+ private readonly baseBranch: string;
44
+ private readonly metaPath: string;
45
+
46
+ constructor(options: WorktreeOptions) {
47
+ this.rootDir = options.rootDir;
48
+ this.baseBranch = options.baseBranch ?? "main";
49
+ this.metaPath = join(this.rootDir, ".worktrees.json");
50
+ }
51
+
52
+ /**
53
+ * Create a new worktree
54
+ */
55
+ async create(options: CreateWorktreeOptions): Promise<WorktreeInfo> {
56
+ const name = options.name.replace(/[^a-zA-Z0-9-_]/g, "-");
57
+ const branch = options.branch ?? `worktree/${name}`;
58
+ const path = join(this.rootDir, "worktrees", name);
59
+
60
+ // Ensure directory exists
61
+ mkdirSync(dirname(path), { recursive: true });
62
+
63
+ // Create git worktree
64
+ const startPoint = options.startPoint ?? this.baseBranch;
65
+ const worktreePath = this.execGit([
66
+ "worktree",
67
+ "add",
68
+ "-b",
69
+ branch,
70
+ path,
71
+ startPoint,
72
+ ]);
73
+
74
+ // Record metadata
75
+ const worktree: WorktreeInfo = {
76
+ name,
77
+ path: worktreePath,
78
+ branch,
79
+ jobId: options.jobId,
80
+ taskId: options.taskId,
81
+ createdAt: new Date().toISOString(),
82
+ status: "active",
83
+ };
84
+
85
+ this.saveWorktree(worktree);
86
+
87
+ return worktree;
88
+ }
89
+
90
+ /**
91
+ * List all worktrees
92
+ */
93
+ async list(): Promise<WorktreeInfo[]> {
94
+ if (!existsSync(this.metaPath)) {
95
+ return [];
96
+ }
97
+
98
+ try {
99
+ const content = readFileSync(this.metaPath, "utf-8");
100
+ return JSON.parse(content) as WorktreeInfo[];
101
+ } catch {
102
+ return [];
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Get worktree by name
108
+ */
109
+ async get(name: string): Promise<WorktreeInfo | null> {
110
+ const worktrees = await this.list();
111
+ return worktrees.find((w) => w.name === name) ?? null;
112
+ }
113
+
114
+ /**
115
+ * Get worktree by job ID
116
+ */
117
+ async getByJob(jobId: string): Promise<WorktreeInfo | null> {
118
+ const worktrees = await this.list();
119
+ return worktrees.find((w) => w.jobId === jobId) ?? null;
120
+ }
121
+
122
+ /**
123
+ * Remove a worktree
124
+ */
125
+ async remove(name: string, force: boolean = false): Promise<void> {
126
+ const worktree = await this.get(name);
127
+ if (!worktree) {
128
+ throw new Error(`Worktree ${name} not found`);
129
+ }
130
+
131
+ // Remove git worktree
132
+ this.execGit(["worktree", "remove", worktree.path, force ? "--force" : ""]);
133
+
134
+ // Remove from metadata
135
+ const worktrees = await this.list();
136
+ const updated = worktrees.filter((w) => w.name !== name);
137
+ this.saveAllWorktrees(updated);
138
+ }
139
+
140
+ /**
141
+ * Prune stale worktrees
142
+ */
143
+ async prune(): Promise<string[]> {
144
+ const output = this.execGit(["worktree", "list", "--porcelain"]);
145
+ const lines = output.split("\n");
146
+ const pruned: string[] = [];
147
+
148
+ // Parse git worktree list
149
+ const worktreePaths: string[] = [];
150
+ for (const line of lines) {
151
+ if (line.startsWith("worktree ")) {
152
+ worktreePaths.push(line.replace("worktree ", "").trim());
153
+ }
154
+ }
155
+
156
+ // Check our tracked worktrees
157
+ const tracked = await this.list();
158
+ for (const worktree of tracked) {
159
+ if (!worktreePaths.includes(worktree.path)) {
160
+ // Git worktree no longer exists
161
+ worktree.status = "abandoned";
162
+ pruned.push(worktree.name);
163
+ }
164
+ }
165
+
166
+ this.saveAllWorktrees(tracked);
167
+
168
+ return pruned;
169
+ }
170
+
171
+ /**
172
+ * Get diff between worktree and base
173
+ */
174
+ async getDiff(worktreePath: string): Promise<WorktreeDiff[]> {
175
+ const output = this.execGit(["diff", "--stat", `main...${worktreePath}`], {
176
+ cwd: worktreePath,
177
+ });
178
+
179
+ const diffs: WorktreeDiff[] = [];
180
+ const lines = output.split("\n");
181
+
182
+ for (const line of lines) {
183
+ if (!line.includes("|")) continue;
184
+
185
+ const [file, stats] = line.split("|").map((s) => s.trim());
186
+ if (!file || !stats) continue;
187
+
188
+ let status: WorktreeDiff["status"] = "modified";
189
+ if (stats.startsWith("+")) status = "added";
190
+ else if (stats.startsWith("-")) status = "deleted";
191
+
192
+ const match = stats.match(/\+(\d+)/);
193
+ const insertions = match ? parseInt(match[1], 10) : undefined;
194
+
195
+ const delMatch = stats.match(/-(\d+)/);
196
+ const deletions = delMatch ? parseInt(delMatch[1], 10) : undefined;
197
+
198
+ diffs.push({ file, status, insertions, deletions });
199
+ }
200
+
201
+ return diffs;
202
+ }
203
+
204
+ /**
205
+ * Get uncommitted changes
206
+ */
207
+ async getUncommitted(worktreePath: string): Promise<WorktreeDiff[]> {
208
+ const output = this.execGit(["diff", "--numstat"], { cwd: worktreePath });
209
+
210
+ const diffs: WorktreeDiff[] = [];
211
+ const lines = output.split("\n");
212
+
213
+ for (const line of lines) {
214
+ const parts = line.split("\t");
215
+ if (parts.length < 3) continue;
216
+
217
+ const [ins, del, file] = parts;
218
+ diffs.push({
219
+ file,
220
+ status: "modified",
221
+ insertions: parseInt(ins, 10) || 0,
222
+ deletions: parseInt(del, 10) || 0,
223
+ });
224
+ }
225
+
226
+ return diffs;
227
+ }
228
+
229
+ /**
230
+ * Mark worktree as merged
231
+ */
232
+ async markMerged(name: string): Promise<void> {
233
+ const worktrees = await this.list();
234
+ const worktree = worktrees.find((w) => w.name === name);
235
+ if (worktree) {
236
+ worktree.status = "merged";
237
+ this.saveAllWorktrees(worktrees);
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Execute git command
243
+ */
244
+ private execGit(args: string[], options?: { cwd?: string }): string {
245
+ try {
246
+ const cwd = options?.cwd ?? this.rootDir;
247
+ return execSync(["git", ...args].join(" "), {
248
+ cwd,
249
+ encoding: "utf-8",
250
+ stdio: ["pipe", "pipe", "pipe"],
251
+ }).trim();
252
+ } catch (error) {
253
+ const e = error as { message?: string };
254
+ throw new Error(`Git command failed: ${e.message ?? error}`);
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Save worktree to metadata
260
+ */
261
+ private saveWorktree(worktree: WorktreeInfo): void {
262
+ const current = this.listSync();
263
+ const idx = current.findIndex((w) => w.name === worktree.name);
264
+ if (idx >= 0) {
265
+ current[idx] = worktree;
266
+ } else {
267
+ current.push(worktree);
268
+ }
269
+ this.saveAllWorktrees(current);
270
+ }
271
+
272
+ /**
273
+ * Save all worktrees
274
+ */
275
+ private saveAllWorktrees(worktrees: WorktreeInfo[]): void {
276
+ mkdirSync(dirname(this.metaPath), { recursive: true });
277
+ writeFileSync(this.metaPath, JSON.stringify(worktrees, null, 2), "utf-8");
278
+ }
279
+
280
+ /**
281
+ * List worktrees synchronously
282
+ */
283
+ private listSync(): WorktreeInfo[] {
284
+ if (!existsSync(this.metaPath)) {
285
+ return [];
286
+ }
287
+ try {
288
+ return JSON.parse(readFileSync(this.metaPath, "utf-8")) as WorktreeInfo[];
289
+ } catch {
290
+ return [];
291
+ }
292
+ }
293
+ }
@@ -1,95 +1,209 @@
1
- ---
2
- name: harness-runtime
3
- description: Show Codex-style /usage status for pi — local token tracking + manual provider mirror. Use when the user asks about token usage, API quota, 5h limit, weekly limit, or wants to know how much they've spent.
4
- ---
1
+ # Harness Runtime — pi Extension
5
2
 
6
- # pi-harness-runtime
3
+ **Status:** v0.2.0 | **RFCs:** 18 defined | **Implementation:** Phase 1-6
7
4
 
8
- Codex-style `/usage` slash command for pi coding agent.
5
+ ## Overview
9
6
 
10
- ## When to use
7
+ pi-harness-runtime is a local-first, provider-agnostic AI coding harness runtime for pi.dev. It coordinates multiple AI models to complete software engineering tasks with minimal human intervention.
11
8
 
12
- User says:
9
+ ## Core Architecture
13
10
 
14
- - "show me my usage"
15
- - "how much have I used?"
16
- - "what's my 5h limit?"
17
- - "weekly quota?"
18
- - "/usage"
19
- - "/usage sync"
20
- - "how many tokens today?"
11
+ ```
12
+ Human Requirement
13
+ /harness start <requirement>
14
+ Master Planner (creates task graph)
15
+ Loop Runtime (executes tasks)
16
+ Provider Router (selects best model)
17
+ BlackBoard (coordination)
18
+ → Checkpoint Manager (resumability)
19
+ → Quota Manager (avoid exhaustion)
20
+ → Scheduler (pause/resume)
21
+ → Repair Engine (auto-fix failures)
22
+ → /harness status (report)
23
+ ```
21
24
 
22
- ## Quick reference
25
+ ## Commands
23
26
 
24
- ```bash
25
- /usage # full status (model, local tracking, provider mirror)
26
- /usage sync # open form to mirror provider-side quota
27
- /usage today # focused: this 5h + today (UTC)
28
- /usage week # focused: this week + lifetime
29
- /usage reset # clear provider mirror
27
+ ### Usage Commands
28
+
29
+ | Command | Description |
30
+ |---------|-------------|
31
+ | `/usage` | Show full status (local tracking + provider mirror) |
32
+ | `/usage sync` | Sync provider quota from console.minimax.io |
33
+ | `/usage today` | Today's usage + 5h window |
34
+ | `/usage week` | This week's usage + lifetime |
35
+ | `/usage reset` | Clear provider mirror |
36
+
37
+ ### Harness Commands
38
+
39
+ | Command | Description |
40
+ |---------|-------------|
41
+ | `/harness start <requirement>` | Start a new harness job |
42
+ | `/harness status` | Show current job status |
43
+ | `/harness tasks` | List all tasks |
44
+ | `/harness pause` | Pause the current job |
45
+ | `/harness resume` | Resume a paused job |
46
+ | `/harness cancel` | Cancel the current job |
47
+
48
+ ## Job State Machine
49
+
50
+ ```
51
+ created → planning → queued → running → testing → reviewing
52
+ ↓ ↓ ↓ ↓ ↓
53
+ cancelled blocked waiting_human repairing ready_for_client
54
+ ↓ ↓
55
+ paused_quota archived
30
56
  ```
31
57
 
32
- ## Data sources (3-source model)
58
+ ### State Transitions
59
+
60
+ | From | Valid Transitions |
61
+ |------|-----------------|
62
+ | created | planning |
63
+ | planning | queued, cancelled |
64
+ | queued | running, cancelled, waiting_human |
65
+ | running | testing, reviewing, repairing, paused_quota, blocked, waiting_human, cancelled |
66
+ | testing | reviewing, running, repairing, paused_quota, waiting_human, cancelled |
67
+ | reviewing | repairing, running, ready_for_client, paused_quota, waiting_human, cancelled |
68
+ | repairing | running, testing, reviewing, paused_quota, waiting_human, cancelled |
69
+ | paused_quota | running, waiting_human, cancelled |
70
+
71
+ ## Task Graph
72
+
73
+ Tasks are organized as a DAG (Directed Acyclic Graph):
74
+
75
+ - Tasks with dependencies are marked `pending` until all dependencies are `done`
76
+ - Tasks without dependencies start as `ready`
77
+ - Only `ready` tasks are picked for execution
78
+
79
+ ## Key Components
80
+
81
+ ### JobStateMachine (`harness/job-state-machine.ts`)
82
+
83
+ - Manages job lifecycle states
84
+ - Emits events on every transition
85
+ - Auto-checkpoints to disk
86
+
87
+ ### TaskGraphManager (`harness/task-graph.ts`)
88
+
89
+ - DAG-based task representation
90
+ - Tracks task status and dependencies
91
+ - Computes topological execution order
92
+
93
+ ### MasterPlanner (`harness/master-planner.ts`)
94
+
95
+ - Converts requirements to task graphs
96
+ - Heuristic planner (no LLM needed)
97
+ - LLM-based planner (with provider config)
98
+
99
+ ### RepairEngine (`harness/repair-engine.ts`)
100
+
101
+ - Converts failures to repair tasks
102
+ - Retry policy with exponential backoff
103
+ - Auto-escalation after max retries
104
+
105
+ ### SharedBlackboard (`harness/blackboard.ts`)
33
106
 
34
- 1. **Local tracked** — every assistant message is logged to `~/.pi/usage-status/usage.jsonl`
35
- - Auto-tracked via `message_end` event
36
- - Contains: timestamp, model, input/output/cache tokens, cost
37
- - Real-time, exact, but only counts THIS pi session
107
+ - File-based agent coordination
108
+ - Next-action queue
109
+ - Agent registry and locks
38
110
 
39
- 2. **Provider mirror** — manually entered from `https://platform.minimax.io/console/usage`
40
- - Stored at `~/.pi/usage-status/mirror.json`
41
- - Synced via `/usage sync` form
42
- - Ground truth for TOTAL quota (across all clients)
111
+ ### LoopRuntime (`harness/loop-runtime.ts`)
43
112
 
44
- 3. **Derived** burn rate, reset times, divergence
45
- - Local reset time = oldest request in window + window duration
46
- - Burn rate = mirror weekly % / elapsed days
47
- - Divergence warning if local tracking differs from mirror by >5%
113
+ - Core execution loop
114
+ - Picks ready tasks, executes, tests, reviews
115
+ - Handles failures and quota exhaustion
48
116
 
49
- ## Files written
117
+ ### ContextWindowManager (`harness/context-window-manager.ts`)
50
118
 
51
- - `~/.pi/usage-status/usage.jsonl` append-only usage log
52
- - `~/.pi/usage-status/mirror.json` manual provider mirror
119
+ - Tracks context usage per provider
120
+ - Warning at 80%, critical at 95%
121
+ - Truncation strategy
53
122
 
54
- Override location with `PI_USAGE_DIR` env var.
123
+ ### AgentHandoffProtocol (`harness/agent-handoff.ts`)
55
124
 
56
- ## Sample output
125
+ - Clean agent transitions
126
+ - Context transfer
127
+ - Handoff validation
57
128
 
129
+ ### E2ETestEngine (`harness/e2e/test-engine.ts`)
130
+
131
+ - Scenario-based E2E testing
132
+ - Screenshot/video on failure
133
+ - Playwright runner integration
134
+
135
+ ### ProjectDetector (`harness/project-detector/detector.ts`)
136
+
137
+ - Auto-detect: Frappe, Next.js, React, Django, Laravel
138
+ - Seed strategy recommendation
139
+ - E2E strategy recommendation
140
+
141
+ ## Providers
142
+
143
+ ### Provider Adapter (`packages/providers/adapters.ts`)
144
+
145
+ - MiniMax, OpenAI adapters
146
+ - Unified error parsing
147
+ - Quota signal extraction
148
+
149
+ ### Quota Manager (`packages/quota-manager/quota-manager.ts`)
150
+
151
+ - Collects signals from API, Playwright, local estimates
152
+ - Tracks 5h, daily, weekly, monthly windows
153
+ - Selects best available provider
154
+
155
+ ### Worktree Manager (`packages/worktree/worktree.ts`)
156
+
157
+ - Git worktree per task
158
+ - Isolated workspaces
159
+ - Diff tracking
160
+
161
+ ## Usage Example
162
+
163
+ ```typescript
164
+ // Start a new job
165
+ /harness start Build a REST API with authentication
166
+
167
+ // Monitor progress
168
+ /harness status
169
+ /harness tasks
170
+
171
+ // Pause when quota is low
172
+ /harness pause
173
+
174
+ // Resume when quota resets
175
+ /harness resume
176
+
177
+ // Cancel if needed
178
+ /harness cancel
58
179
  ```
59
- Codex-style usage status for pi
60
- ────────────────────────────────────────────────────────────────
61
- Model: minimax/MiniMax-M3
62
- Directory: ~/frappe-bench/apps/thai_business_suite
63
-
64
- ① LOCAL TRACKED (ground truth — we count this)
65
- This session: $0.17 · 142k tokens · 17 requests
66
- This 5h: 384k tokens · 23 requests · $0.04
67
- This week: 1.2M tokens · 67 requests · $0.13
68
- Lifetime: 4592 requests · $81.61
69
-
70
- ② PROVIDER MIRROR (you enter from console.minimax.io)
71
- Last sync: 2 min ago [fresh]
72
- Provider: minimax
73
- 5h limit: [████████░░░░░░░░░░░░] 18% left (resets in 4h 54m)
74
- Weekly limit: [████████████████░░░░] 81% left (resets in 2d 13h)
75
-
76
- ③ LOCAL RESET TIMES (derived from your data)
77
- Local 5h reset: in 3h 12m (oldest request falls out of window)
78
- Local week reset: in 5d 7h (oldest request falls out of window)
79
- Local-vs-mirror: -12.4% ⚠️ divergence > 5%
80
- Burn rate: 11.4% / day → 100% in 2.5 d
81
- ────────────────────────────────────────────────────────────────
180
+
181
+ ## Data Directory
182
+
183
+ All harness data is stored in `~/.pi/harness/`:
184
+
185
+ ```
186
+ ~/.pi/harness/
187
+ jobs/
188
+ <job-id>/
189
+ checkpoint.json
190
+ events.jsonl
191
+ task-graph.json
192
+ blackboard/
193
+ repair-tasks.jsonl
194
+ handoffs/
82
195
  ```
83
196
 
84
- ## Safety properties
197
+ ## Testing
198
+
199
+ Run tests with:
85
200
 
86
- - **No auto-tracking of other clients** — local data is just this pi session
87
- - **No scraping** — provider mirror is manual (5-second task)
88
- - **No fabrication** — divergence warning if local and mirror disagree by >5%
89
- - **Idempotent** — running `/usage` repeatedly has no side effects
90
- - **Privacy-respecting** — all data stays on local disk
201
+ ```bash
202
+ pi-harness-runtime@latest
203
+ ```
91
204
 
92
- ## Related
205
+ ## Related Skills
93
206
 
94
- - `context-mode` provides overall session cost via `ctx_stats`
95
- - pi's built-in footer shows model + git branch
207
+ - `form-state-persistence-fix` Step data persistence patterns
208
+ - `frappe-gl-preview` GL entry preview pattern
209
+ - `frappe-workflow` — Workflow debugging patterns