@pi-stef/pair 0.1.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.
@@ -0,0 +1,232 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "@sinclair/typebox";
3
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
4
+ import { join, dirname } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import {
7
+ loadAndResolveDefaults,
8
+ resolveReviewerModel,
9
+ } from "./config/load";
10
+
11
+ export const PAIR_TOOL_NAMES = [
12
+ "sf_pair_plan",
13
+ "sf_pair_implement",
14
+ "sf_pair_task",
15
+ ] as const;
16
+
17
+ const REVIEWER_AGENT_PATH = ".pi/agents/reviewer.md";
18
+
19
+ /**
20
+ * Write the reviewer agent file with the resolved model.
21
+ * This ensures pi-subagents spawns the reviewer with the correct model.
22
+ */
23
+ async function writeReviewerAgent(
24
+ repoRoot: string,
25
+ model: string
26
+ ): Promise<void> {
27
+ const agentPath = join(repoRoot, REVIEWER_AGENT_PATH);
28
+ await mkdir(dirname(agentPath), { recursive: true });
29
+
30
+ // Read the template file from the package
31
+ const templatePath = join(dirname(fileURLToPath(import.meta.url)), "..", "agents", "reviewer.md");
32
+ const template = await readFile(templatePath, "utf8");
33
+
34
+ // Replace the {{REVIEWER_MODEL}} placeholder with the resolved model
35
+ const content = template.replace("{{REVIEWER_MODEL}}", model);
36
+
37
+ await writeFile(agentPath, content, "utf8");
38
+ }
39
+
40
+ /**
41
+ * Extract reviewer model from prompt string.
42
+ * Looks for patterns like "use <model> as reviewer" or "reviewer: <model>"
43
+ */
44
+ function extractReviewerModelFromPrompt(prompt: string): string | undefined {
45
+ const patterns = [
46
+ /use\s+([\w/.-]+)\s+as\s+reviewer/i,
47
+ /reviewer[:\s]+([\w/.-]+)/i,
48
+ /review\s+with\s+([\w/.-]+)/i,
49
+ ];
50
+ for (const pattern of patterns) {
51
+ const match = prompt.match(pattern);
52
+ if (match) return match[1];
53
+ }
54
+ return undefined;
55
+ }
56
+
57
+ export function registerSfPair(pi: ExtensionAPI): void {
58
+ // Register plan tool
59
+ const planSchema = Type.Object(
60
+ {
61
+ prompt: Type.Optional(
62
+ Type.String({ description: "The task to plan. May include reviewer model override." })
63
+ ),
64
+ reviewer_model: Type.Optional(
65
+ Type.String({ description: "Override reviewer model (e.g. 'anthropic/sonnet-4-6')" })
66
+ ),
67
+ },
68
+ { additionalProperties: false }
69
+ );
70
+
71
+ pi.registerTool({
72
+ name: "sf_pair_plan",
73
+ label: "sf_pair_plan",
74
+ description:
75
+ "Create a multi-milestone implementation plan with iterative reviewer approval. Produces a plan folder under ai_plan/.",
76
+ parameters: planSchema as any,
77
+ execute: async (_id, params, _signal, _onUpdate, ctx) => {
78
+ const repoRoot = ctx.cwd ?? process.cwd();
79
+ const defaults = await loadAndResolveDefaults(repoRoot);
80
+ const promptModel = extractReviewerModelFromPrompt((params as any).prompt ?? "");
81
+ const model = resolveReviewerModel(
82
+ (params as any).reviewer_model ?? promptModel,
83
+ defaults
84
+ );
85
+
86
+ if (!model) {
87
+ return {
88
+ content: [
89
+ {
90
+ type: "text" as const,
91
+ text: "No reviewer model configured. Please provide one via:\n1. The prompt (e.g. 'use anthropic/sonnet-4-6 as reviewer')\n2. Config file at .pi/sf/pair/config.json\n3. Environment variable SF_PAIR_REVIEWER_MODEL\n4. Or pass reviewer_model parameter",
92
+ },
93
+ ],
94
+ details: { configured: false },
95
+ };
96
+ }
97
+
98
+ await writeReviewerAgent(repoRoot, model);
99
+
100
+ return {
101
+ content: [
102
+ {
103
+ type: "text" as const,
104
+ text: `Reviewer configured with model: ${model}\nAgent file written to ${REVIEWER_AGENT_PATH}\n\nNow load and follow the plan skill.`,
105
+ },
106
+ ],
107
+ details: { configured: true, model },
108
+ };
109
+ },
110
+ });
111
+
112
+ // Register implement tool
113
+ const implementSchema = Type.Object(
114
+ {
115
+ path: Type.String({
116
+ description:
117
+ "Plan folder path or slug (e.g. '2026-06-17-add-auth' or 'ai_plan/2026-06-17-add-auth')",
118
+ }),
119
+ reviewer_model: Type.Optional(
120
+ Type.String({ description: "Override reviewer model" })
121
+ ),
122
+ },
123
+ { additionalProperties: false }
124
+ );
125
+
126
+ pi.registerTool({
127
+ name: "sf_pair_implement",
128
+ label: "sf_pair_implement",
129
+ description:
130
+ "Execute an approved plan milestone-by-milestone in a git worktree. Creates worktree, implements all milestones with reviewer approval, then rolls up commits and deletes worktree.",
131
+ parameters: implementSchema as any,
132
+ execute: async (_id, params, _signal, _onUpdate, ctx) => {
133
+ const repoRoot = ctx.cwd ?? process.cwd();
134
+ const defaults = await loadAndResolveDefaults(repoRoot);
135
+ const model = resolveReviewerModel((params as any).reviewer_model, defaults);
136
+
137
+ if (!model) {
138
+ return {
139
+ content: [
140
+ {
141
+ type: "text" as const,
142
+ text: "No reviewer model configured. Please provide one via:\n1. Config file at .pi/sf/pair/config.json\n2. Environment variable SF_PAIR_REVIEWER_MODEL\n3. Or pass reviewer_model parameter",
143
+ },
144
+ ],
145
+ details: { configured: false },
146
+ };
147
+ }
148
+
149
+ await writeReviewerAgent(repoRoot, model);
150
+
151
+ return {
152
+ content: [
153
+ {
154
+ type: "text" as const,
155
+ text: `Reviewer configured with model: ${model}\nPlan path: ${(params as any).path}\nAgent file written to ${REVIEWER_AGENT_PATH}\n\nNow load and follow the implement skill.`,
156
+ },
157
+ ],
158
+ details: { configured: true, model, path: (params as any).path },
159
+ };
160
+ },
161
+ });
162
+
163
+ // Register task tool
164
+ const taskSchema = Type.Object(
165
+ {
166
+ prompt: Type.String({
167
+ description: "The task to execute end-to-end",
168
+ }),
169
+ reviewer_model: Type.Optional(
170
+ Type.String({ description: "Override reviewer model" })
171
+ ),
172
+ },
173
+ { additionalProperties: false }
174
+ );
175
+
176
+ pi.registerTool({
177
+ name: "sf_pair_task",
178
+ label: "sf_pair_task",
179
+ description:
180
+ "Execute a single task end-to-end: plan, review, implement, verify, commit. Uses current branch (no worktree).",
181
+ parameters: taskSchema as any,
182
+ execute: async (_id, params, _signal, _onUpdate, ctx) => {
183
+ const repoRoot = ctx.cwd ?? process.cwd();
184
+ const defaults = await loadAndResolveDefaults(repoRoot);
185
+ const promptModel = extractReviewerModelFromPrompt((params as any).prompt);
186
+ const model = resolveReviewerModel(
187
+ (params as any).reviewer_model ?? promptModel,
188
+ defaults
189
+ );
190
+
191
+ if (!model) {
192
+ return {
193
+ content: [
194
+ {
195
+ type: "text" as const,
196
+ text: "No reviewer model configured. Please provide one via:\n1. The prompt (e.g. 'use anthropic/sonnet-4-6 as reviewer')\n2. Config file at .pi/sf/pair/config.json\n3. Environment variable SF_PAIR_REVIEWER_MODEL\n4. Or pass reviewer_model parameter",
197
+ },
198
+ ],
199
+ details: { configured: false },
200
+ };
201
+ }
202
+
203
+ await writeReviewerAgent(repoRoot, model);
204
+
205
+ return {
206
+ content: [
207
+ {
208
+ type: "text" as const,
209
+ text: `Reviewer configured with model: ${model}\nTask: ${(params as any).prompt}\nAgent file written to ${REVIEWER_AGENT_PATH}\n\nNow load and follow the task skill.`,
210
+ },
211
+ ],
212
+ details: { configured: true, model, prompt: (params as any).prompt },
213
+ };
214
+ },
215
+ });
216
+
217
+ // Register slash commands
218
+ for (const name of PAIR_TOOL_NAMES) {
219
+ const slashName = name.replace(/_/g, "-");
220
+ const descriptions: Record<string, string> = {
221
+ sf_pair_plan: "Create implementation plan with reviewer loop",
222
+ sf_pair_implement: "Execute plan in worktree with milestone reviews",
223
+ sf_pair_task: "Execute single task end-to-end",
224
+ };
225
+ pi.registerCommand(slashName, {
226
+ description: descriptions[name] ?? name,
227
+ handler: async (_args, _ctx) => {
228
+ // Slash commands are handled by the agent loading the skill
229
+ },
230
+ });
231
+ }
232
+ }
@@ -0,0 +1,82 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { WorktreeError } from "./validate";
4
+
5
+ const execFileAsync = promisify(execFile);
6
+
7
+ export interface CleanupOptions {
8
+ worktreePath: string;
9
+ branchName: string;
10
+ baseBranch?: string; // defaults to current branch
11
+ }
12
+
13
+ /**
14
+ * Rollup worktree commits to base branch and delete the worktree.
15
+ *
16
+ * Steps:
17
+ * 1. Get current branch (base)
18
+ * 2. Merge worktree branch into base (--ff-only)
19
+ * 3. Remove worktree
20
+ * 4. Delete branch
21
+ */
22
+ export async function rollupAndCleanup(opts: CleanupOptions): Promise<void> {
23
+ const { worktreePath, branchName, baseBranch } = opts;
24
+
25
+ // Get base branch if not specified
26
+ let base = baseBranch;
27
+ if (!base) {
28
+ const { stdout } = await execFileAsync("git", [
29
+ "branch",
30
+ "--show-current",
31
+ ]);
32
+ base = stdout.trim();
33
+ }
34
+
35
+ if (!base) {
36
+ throw new WorktreeError("Could not determine base branch");
37
+ }
38
+
39
+ // Switch to base branch
40
+ await execFileAsync("git", ["checkout", base]);
41
+
42
+ // Merge worktree branch (ff-only) — MUST succeed before cleanup
43
+ try {
44
+ await execFileAsync("git", ["merge", "--ff-only", branchName]);
45
+ } catch (err) {
46
+ const msg = err instanceof Error ? err.message : String(err);
47
+ throw new WorktreeError(
48
+ `Failed to merge ${branchName} into ${base}: ${msg}. Worktree preserved at ${worktreePath} for manual resolution.`
49
+ );
50
+ }
51
+
52
+ // Only remove worktree after successful merge
53
+ try {
54
+ await execFileAsync("git", ["worktree", "remove", worktreePath]);
55
+ } catch {
56
+ // Try force remove if normal remove fails
57
+ await execFileAsync("git", [
58
+ "worktree",
59
+ "remove",
60
+ "--force",
61
+ worktreePath,
62
+ ]);
63
+ }
64
+
65
+ // Delete branch after successful merge
66
+ try {
67
+ await execFileAsync("git", ["branch", "-d", branchName]);
68
+ } catch {
69
+ // Branch may already be deleted by merge
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Remove a worktree without merging (for abort/cleanup scenarios).
75
+ */
76
+ export async function removeWorktree(worktreePath: string): Promise<void> {
77
+ try {
78
+ await execFileAsync("git", ["worktree", "remove", "--force", worktreePath]);
79
+ } catch {
80
+ // Ignore errors during cleanup
81
+ }
82
+ }
@@ -0,0 +1,86 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { existsSync } from "node:fs";
4
+ import { join, dirname } from "node:path";
5
+ import { mkdir } from "node:fs/promises";
6
+ import { validateRepoState, requireInsideWorkTree, WorktreeError } from "./validate";
7
+
8
+ const execFileAsync = promisify(execFile);
9
+
10
+ export interface CreateWorktreeOptions {
11
+ slug: string;
12
+ branchPrefix?: string;
13
+ baseRef?: string;
14
+ allowDirty?: boolean;
15
+ }
16
+
17
+ export interface WorktreeResult {
18
+ worktreePath: string;
19
+ branchName: string;
20
+ baseSha: string;
21
+ }
22
+
23
+ function validateSlug(slug: string): void {
24
+ if (!/^[a-zA-Z0-9._-]+$/.test(slug)) {
25
+ throw new WorktreeError(
26
+ `Invalid slug "${slug}". Only alphanumeric, dots, hyphens, and underscores allowed.`
27
+ );
28
+ }
29
+ }
30
+
31
+ export async function createWorktree(
32
+ opts: CreateWorktreeOptions
33
+ ): Promise<WorktreeResult> {
34
+ const { slug, branchPrefix = "pair/", baseRef = "HEAD", allowDirty } = opts;
35
+ validateSlug(slug);
36
+
37
+ // Validate repo state
38
+ await validateRepoState({ allowDirty });
39
+ const repoRoot = await requireInsideWorkTree();
40
+
41
+ const branchName = `${branchPrefix}${slug}`;
42
+
43
+ // Check branch doesn't exist
44
+ try {
45
+ await execFileAsync("git", ["rev-parse", "--verify", branchName]);
46
+ throw new WorktreeError(`Branch ${branchName} already exists`);
47
+ } catch (err) {
48
+ if (err instanceof WorktreeError) throw err;
49
+ // Branch doesn't exist — good
50
+ }
51
+
52
+ // Resolve base SHA
53
+ const { stdout: baseShaRaw } = await execFileAsync("git", [
54
+ "rev-parse",
55
+ "--verify",
56
+ baseRef,
57
+ ]);
58
+ const baseSha = baseShaRaw.trim();
59
+
60
+ // Pick worktree directory (sibling to repo)
61
+ const parentDir = dirname(repoRoot);
62
+ const worktreeDirName = `pair-${slug}`;
63
+ let worktreePath = join(parentDir, worktreeDirName);
64
+
65
+ // Handle collision
66
+ let suffix = 2;
67
+ while (existsSync(worktreePath)) {
68
+ worktreePath = join(parentDir, `${worktreeDirName}-${suffix}`);
69
+ suffix++;
70
+ }
71
+
72
+ // Create parent if needed
73
+ await mkdir(dirname(worktreePath), { recursive: true });
74
+
75
+ // Create worktree
76
+ await execFileAsync("git", [
77
+ "worktree",
78
+ "add",
79
+ "-b",
80
+ branchName,
81
+ worktreePath,
82
+ baseSha,
83
+ ]);
84
+
85
+ return { worktreePath, branchName, baseSha };
86
+ }
@@ -0,0 +1,50 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ const execFileAsync = promisify(execFile);
5
+
6
+ export class WorktreeError extends Error {
7
+ constructor(message: string) {
8
+ super(message);
9
+ this.name = "WorktreeError";
10
+ }
11
+ }
12
+
13
+ export async function requireGitOrThrow(): Promise<void> {
14
+ try {
15
+ await execFileAsync("git", ["--version"]);
16
+ } catch {
17
+ throw new WorktreeError("git is not available in PATH");
18
+ }
19
+ }
20
+
21
+ export async function validateRepoState(opts: {
22
+ allowDirty?: boolean;
23
+ } = {}): Promise<void> {
24
+ await requireGitOrThrow();
25
+
26
+ if (opts.allowDirty) return;
27
+
28
+ const { stdout } = await execFileAsync("git", ["status", "--porcelain"]);
29
+ if (stdout.trim().length > 0) {
30
+ throw new WorktreeError(
31
+ "Working tree is dirty. Commit or stash changes before creating a worktree."
32
+ );
33
+ }
34
+ }
35
+
36
+ export async function requireInsideWorkTree(): Promise<string> {
37
+ const { stdout } = await execFileAsync("git", [
38
+ "rev-parse",
39
+ "--is-inside-work-tree",
40
+ ]);
41
+ if (stdout.trim() !== "true") {
42
+ throw new WorktreeError("Not inside a git repository");
43
+ }
44
+
45
+ const { stdout: root } = await execFileAsync("git", [
46
+ "rev-parse",
47
+ "--show-toplevel",
48
+ ]);
49
+ return root.trim();
50
+ }
@@ -0,0 +1,145 @@
1
+ # Continuation Runbook: [Plan Title]
2
+
3
+ ## Reference Files (START HERE)
4
+
5
+ Upon resumption, these files in this folder are the ONLY source of truth:
6
+
7
+ | File | Purpose | When to Use |
8
+ |------|---------|-------------|
9
+ | `continuation-runbook.md` | Full context reproduction + execution workflow | Read FIRST |
10
+ | `story-tracker.md` | Current progress and status | Check/update BEFORE and AFTER every story |
11
+ | `milestone-plan.md` | Complete plan with specifications | Reference implementation details |
12
+ | `original-plan.md` | Original approved plan | Reference original intent |
13
+ | `final-transcript.md` | Final planning transcript | Reference reasoning/context |
14
+
15
+ Do NOT reference planner-private files during implementation.
16
+
17
+ ---
18
+
19
+ ## Skill Workflow Guardrails
20
+
21
+ - Load relevant skills before action. If pi did not auto-load them, use `/skill:<name>`.
22
+ - Announce which skill is being used and why.
23
+ - If a checklist-driven workflow applies, keep its state current in the plan artifacts.
24
+ - Do not use deprecated wrapper CLIs.
25
+
26
+ ---
27
+
28
+ ## Quick Resume Instructions
29
+
30
+ 1. Read this runbook completely.
31
+ 2. Check `story-tracker.md`.
32
+ 3. Find next `pending` story and mark as `in-dev` before starting.
33
+ 4. Implement the story.
34
+ 5. Update tracker immediately after each change.
35
+
36
+ ---
37
+
38
+ ## Mandatory Execution Workflow
39
+
40
+ Work from this folder (`ai_plan/[plan-slug]/`) and always follow this order:
41
+
42
+ 1. Read `continuation-runbook.md` first.
43
+ 2. Execute stories milestone by milestone.
44
+ 3. After completing a milestone:
45
+ - Run lint/typecheck/tests, prioritizing changed files for speed.
46
+ - Commit locally (**DO NOT PUSH**).
47
+ - Stop and ask user for feedback.
48
+ 4. If feedback is provided:
49
+ - Apply feedback changes.
50
+ - Re-run checks for changed files.
51
+ - Commit locally again.
52
+ - Ask for milestone approval.
53
+ 5. Only move to next milestone after explicit approval.
54
+ 6. After all milestones are completed and approved:
55
+ - Ask permission to push.
56
+ - If approved, push.
57
+ - Mark plan status as `completed`.
58
+
59
+ ---
60
+
61
+ ## Git Note
62
+
63
+ `ai_plan/` is intentionally local and must stay gitignored. Do not treat inability to commit plan-file updates inside `ai_plan/` as an error.
64
+
65
+ ---
66
+
67
+ ## Full Context Reproduction
68
+
69
+ ### Project Overview
70
+
71
+ [Description of what we're building]
72
+
73
+ ### User Requirements
74
+
75
+ [Numbered list of requirements]
76
+
77
+ ### Scope
78
+
79
+ **In scope:**
80
+ - [Items]
81
+
82
+ **Out of scope:**
83
+ - [Items]
84
+
85
+ ### Dependencies
86
+
87
+ - [External dependencies]
88
+
89
+ ---
90
+
91
+ ## Key Specifications
92
+
93
+ ### Type Definitions
94
+
95
+ ```typescript
96
+ [Types]
97
+ ```
98
+
99
+ ### Enums & Constants
100
+
101
+ ```typescript
102
+ [Constants]
103
+ ```
104
+
105
+ ---
106
+
107
+ ## Critical Design Decisions
108
+
109
+ | Decision | Chosen Approach | Alternatives Rejected | Rationale |
110
+ |----------|----------------|----------------------|-----------|
111
+ | [Topic] | [What] | [What else] | [Why] |
112
+
113
+ ---
114
+
115
+ ## Verification Commands
116
+
117
+ ### Lint (changed files first)
118
+
119
+ ```bash
120
+ [lint command]
121
+ ```
122
+
123
+ ### Typecheck
124
+
125
+ ```bash
126
+ [typecheck command]
127
+ ```
128
+
129
+ ### Tests (target changed scope first)
130
+
131
+ ```bash
132
+ [test command]
133
+ ```
134
+
135
+ ---
136
+
137
+ ## File Quick Reference
138
+
139
+ | File | Purpose |
140
+ |------|---------|
141
+ | `original-plan.md` | Original approved plan |
142
+ | `final-transcript.md` | Final planning transcript |
143
+ | `milestone-plan.md` | Full specification |
144
+ | `story-tracker.md` | Current progress tracker |
145
+ | `continuation-runbook.md` | This runbook |
@@ -0,0 +1,108 @@
1
+ [Plan Title]
2
+ ==============
3
+
4
+ Overview
5
+ --------
6
+
7
+ * **Goal:** [One sentence describing the end state]
8
+ * **Created:** [YYYY-MM-DD]
9
+ * **Status:** [Draft | In Review | Approved | In Progress | Completed]
10
+
11
+ Context
12
+ -------
13
+
14
+ ### Requirements
15
+
16
+ [Numbered list of what the user asked for]
17
+
18
+ ### Constraints
19
+
20
+ [Technical or process constraints]
21
+
22
+ ### Success Criteria
23
+
24
+ [How we know when we're done]
25
+
26
+ Architecture
27
+ ------------
28
+
29
+ ### Design Decisions
30
+
31
+ | Decision | Chosen Approach | Rationale |
32
+ |----------|----------------|-----------|
33
+ | [Topic] | [What we chose] | [Why] |
34
+
35
+ ### Component Relationships
36
+
37
+ ```
38
+ [ASCII or mermaid diagram]
39
+ ```
40
+
41
+ ### Data Flow
42
+
43
+ [How data moves through the system]
44
+
45
+ Milestones
46
+ ----------
47
+
48
+ ### M1: [Milestone Name]
49
+
50
+ **Description:** [What this milestone delivers]
51
+
52
+ **Acceptance Criteria:**
53
+
54
+ * [ ] [Testable criterion 1]
55
+ * [ ] [Testable criterion 2]
56
+
57
+ **Stories:** S-101, S-102, S-103
58
+
59
+ **Milestone Completion Rule (MANDATORY):**
60
+
61
+ * Run lint/typecheck/tests for changed files.
62
+ * Commit locally (DO NOT push).
63
+ * Stop and ask user for feedback.
64
+ * Apply feedback, re-check changed files, commit again.
65
+ * Move to next milestone only after user approval.
66
+
67
+ ---
68
+
69
+ ### M2: [Milestone Name]
70
+
71
+ [Same structure as M1]
72
+
73
+ ---
74
+
75
+ Technical Specifications
76
+ ------------------------
77
+
78
+ ### Types & Interfaces
79
+
80
+ ```typescript
81
+ [Type definitions]
82
+ ```
83
+
84
+ ### Constants & Enums
85
+
86
+ ```typescript
87
+ [Constants and enums]
88
+ ```
89
+
90
+ Files Inventory
91
+ ---------------
92
+
93
+ File | Purpose | Milestone
94
+ --- | --- | ---
95
+ `path/to/file` | What it does | M1
96
+
97
+ ---
98
+
99
+ Related Plan Files
100
+ ------------------
101
+
102
+ This file is part of the plan folder under `ai_plan/`:
103
+
104
+ * `original-plan.md` - Original approved plan (reference for original intent)
105
+ * `final-transcript.md` - Final planning transcript (reference for rationale/context)
106
+ * `milestone-plan.md` - This file (full specification)
107
+ * `story-tracker.md` - Status tracking (must be kept up to date)
108
+ * `continuation-runbook.md` - Resume/execution context (read first)