@garaje/base 0.1.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.
- package/LICENSE +21 -0
- package/README.md +10 -0
- package/agents/doc-writer.md +28 -0
- package/agents/implementer.md +31 -0
- package/agents/planner.md +33 -0
- package/agents/reviewer.md +36 -0
- package/extensions/.gitkeep +0 -0
- package/extensions/README.md +96 -0
- package/extensions/agent-rules.ts +29 -0
- package/extensions/command-guard.ts +94 -0
- package/extensions/confirm-destructive.ts +59 -0
- package/extensions/custom-compaction.ts +127 -0
- package/extensions/dirty-repo-guard.ts +56 -0
- package/extensions/git-checkpoint.ts +53 -0
- package/extensions/model-status.ts +31 -0
- package/extensions/notify.ts +55 -0
- package/extensions/plan-mode/README.md +72 -0
- package/extensions/plan-mode/index.ts +396 -0
- package/extensions/plan-mode/utils.ts +168 -0
- package/extensions/preset.ts +412 -0
- package/extensions/protected-paths.ts +69 -0
- package/extensions/role-commands.ts +69 -0
- package/extensions/session-name.ts +27 -0
- package/extensions/standalone-commands.ts +101 -0
- package/extensions/status-line.ts +32 -0
- package/extensions/subagent/agents.ts +125 -0
- package/extensions/subagent/index.ts +1015 -0
- package/extensions/usage-status.ts +75 -0
- package/lib/agents.ts +5 -0
- package/lib/base-paths.ts +8 -0
- package/lib/presets.ts +23 -0
- package/lib/rules.ts +37 -0
- package/package.json +42 -0
- package/presets.json +58 -0
- package/prompts/roles/doc-writer.md +26 -0
- package/prompts/roles/implementer.md +29 -0
- package/prompts/roles/planner.md +31 -0
- package/prompts/roles/reviewer.md +34 -0
- package/rules/00-workspace.md +23 -0
- package/rules/10-safety.md +20 -0
- package/rules/20-self-modification.md +32 -0
- package/skills/park/SKILL.md +113 -0
- package/skills/upgrade-bay/SKILL.md +35 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Role Commands Extension
|
|
3
|
+
*
|
|
4
|
+
* Thin wrappers over the `preset` extension for the canonical roles defined
|
|
5
|
+
* in `packages/base/prompts/roles/` (and emitted into `packages/base/presets.json`
|
|
6
|
+
* by `bin/build-roles`). Each command activates the matching preset and kicks
|
|
7
|
+
* off a templated user message.
|
|
8
|
+
*
|
|
9
|
+
* /plan [topic] → preset planner + plan request
|
|
10
|
+
* /spec [feature] → preset planner + spec request
|
|
11
|
+
* /review [scope] → preset reviewer + review request
|
|
12
|
+
*
|
|
13
|
+
* `/spec` reuses the `planner` preset because spec'ing is read-only design
|
|
14
|
+
* work — what diverges is the kickoff message.
|
|
15
|
+
*
|
|
16
|
+
* Implementation: each handler sends `/preset <name>` as a user message
|
|
17
|
+
* (which pi dispatches to the preset extension's command, not to the LLM),
|
|
18
|
+
* then sends the kickoff as a normal user message that triggers a turn.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
|
|
23
|
+
type Wrapper = {
|
|
24
|
+
preset: string;
|
|
25
|
+
description: string;
|
|
26
|
+
kickoff: (args: string) => string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const WRAPPERS: Record<string, Wrapper> = {
|
|
30
|
+
plan: {
|
|
31
|
+
preset: "planner",
|
|
32
|
+
description: "Switch to planner role and draft a plan [topic]",
|
|
33
|
+
kickoff: (args) =>
|
|
34
|
+
args
|
|
35
|
+
? `Draft an implementation plan for: ${args}`
|
|
36
|
+
: "Draft an implementation plan for the work we just discussed. If there is none, ask what to plan.",
|
|
37
|
+
},
|
|
38
|
+
spec: {
|
|
39
|
+
preset: "planner",
|
|
40
|
+
description: "Switch to planner role and write a spec [feature]",
|
|
41
|
+
kickoff: (args) =>
|
|
42
|
+
args
|
|
43
|
+
? `Write a short spec for: ${args}. Capture goal, non-goals, surface area, open questions, and acceptance proof.`
|
|
44
|
+
: "Write a short spec for the feature under discussion. Capture goal, non-goals, surface area, open questions, and acceptance proof.",
|
|
45
|
+
},
|
|
46
|
+
review: {
|
|
47
|
+
preset: "reviewer",
|
|
48
|
+
description: "Switch to reviewer role and review changes [scope]",
|
|
49
|
+
kickoff: (args) =>
|
|
50
|
+
args
|
|
51
|
+
? `Review: ${args}. Follow the reviewer role's report structure.`
|
|
52
|
+
: "Review the staged changes (fall back to the most recent commit if nothing is staged). Follow the reviewer role's report structure.",
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export default function roleCommands(pi: ExtensionAPI) {
|
|
57
|
+
for (const [name, wrapper] of Object.entries(WRAPPERS)) {
|
|
58
|
+
pi.registerCommand(name, {
|
|
59
|
+
description: wrapper.description,
|
|
60
|
+
handler: async (args: string | undefined, _ctx: ExtensionCommandContext) => {
|
|
61
|
+
const kickoff = wrapper.kickoff((args ?? "").trim());
|
|
62
|
+
// First message is a slash command, dispatched synchronously by pi.
|
|
63
|
+
pi.sendUserMessage(`/preset ${wrapper.preset}`);
|
|
64
|
+
// Second message is the actual prompt; this one triggers the LLM turn.
|
|
65
|
+
pi.sendUserMessage(kickoff);
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session naming example.
|
|
3
|
+
*
|
|
4
|
+
* Shows setSessionName/getSessionName to give sessions friendly names
|
|
5
|
+
* that appear in the session selector instead of the first message.
|
|
6
|
+
*
|
|
7
|
+
* Usage: /session-name [name] - set or show session name
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
|
|
12
|
+
export default function (pi: ExtensionAPI) {
|
|
13
|
+
pi.registerCommand("session-name", {
|
|
14
|
+
description: "Set or show session name (usage: /session-name [new name])",
|
|
15
|
+
handler: async (args, ctx) => {
|
|
16
|
+
const name = args.trim();
|
|
17
|
+
|
|
18
|
+
if (name) {
|
|
19
|
+
pi.setSessionName(name);
|
|
20
|
+
ctx.ui.notify(`Session named: ${name}`, "info");
|
|
21
|
+
} else {
|
|
22
|
+
const current = pi.getSessionName();
|
|
23
|
+
ctx.ui.notify(current ? `Session: ${current}` : "No session name set", "info");
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone Commands Extension
|
|
3
|
+
*
|
|
4
|
+
* Repo-flavored slash commands that don't wrap a preset:
|
|
5
|
+
*
|
|
6
|
+
* /checkpoint [label] Create a named git stash checkpoint right now.
|
|
7
|
+
* Complements git-checkpoint.ts's per-turn auto-stash
|
|
8
|
+
* by giving the agent (or user) an on-demand snapshot.
|
|
9
|
+
*
|
|
10
|
+
* /handoff [brief] Write a handoff brief to scratch/HANDOFF.md and
|
|
11
|
+
* notify. Use when ending a session so the next
|
|
12
|
+
* session (or human) can pick up the thread.
|
|
13
|
+
*
|
|
14
|
+
* /proof [target] Kick off a "prove the edit took effect" turn —
|
|
15
|
+
* nudges the agent to hit the affected parked bay's
|
|
16
|
+
* endpoint over the compose network and report results.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import * as fs from "node:fs";
|
|
20
|
+
import * as path from "node:path";
|
|
21
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
|
|
23
|
+
function ts(): string {
|
|
24
|
+
return new Date().toISOString().replace(/[:.]/g, "-");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default function standaloneCommands(pi: ExtensionAPI) {
|
|
28
|
+
pi.registerCommand("checkpoint", {
|
|
29
|
+
description: "Create a named git stash checkpoint [label]",
|
|
30
|
+
handler: async (args: string | undefined, ctx: ExtensionCommandContext) => {
|
|
31
|
+
const label = (args ?? "").trim() || "manual";
|
|
32
|
+
try {
|
|
33
|
+
const create = await pi.exec("git", ["stash", "create"]);
|
|
34
|
+
const ref = create.stdout.trim();
|
|
35
|
+
if (!ref) {
|
|
36
|
+
ctx.ui.notify("Nothing to checkpoint — working tree is clean.", "info");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const message = `pi-checkpoint: ${label} @ ${new Date().toISOString()}`;
|
|
40
|
+
await pi.exec("git", ["stash", "store", "-m", message, ref]);
|
|
41
|
+
ctx.ui.notify(`Checkpoint stored: ${label} (${ref.slice(0, 8)})`, "info");
|
|
42
|
+
} catch (err) {
|
|
43
|
+
ctx.ui.notify(`Checkpoint failed: ${(err as Error).message}`, "error");
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
pi.registerCommand("handoff", {
|
|
49
|
+
description: "Write a handoff brief to scratch/HANDOFF.md [brief]",
|
|
50
|
+
handler: async (args: string | undefined, ctx: ExtensionCommandContext) => {
|
|
51
|
+
const brief = (args ?? "").trim();
|
|
52
|
+
const scratchDir = path.join(ctx.cwd, "scratch");
|
|
53
|
+
try {
|
|
54
|
+
fs.mkdirSync(scratchDir, { recursive: true });
|
|
55
|
+
} catch (err) {
|
|
56
|
+
ctx.ui.notify(`Cannot create scratch dir: ${(err as Error).message}`, "error");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const file = path.join(scratchDir, "HANDOFF.md");
|
|
61
|
+
const session = ctx.sessionManager.getSessionFile() ?? "(unknown session)";
|
|
62
|
+
const header = [
|
|
63
|
+
`# Handoff — ${new Date().toISOString()}`,
|
|
64
|
+
"",
|
|
65
|
+
`- session: \`${session}\``,
|
|
66
|
+
"",
|
|
67
|
+
].join("\n");
|
|
68
|
+
|
|
69
|
+
if (!brief) {
|
|
70
|
+
// No brief provided — ask the agent to write one.
|
|
71
|
+
const archive = path.join(scratchDir, `HANDOFF-${ts()}.md`);
|
|
72
|
+
if (fs.existsSync(file)) fs.copyFileSync(file, archive);
|
|
73
|
+
fs.writeFileSync(file, `${header}\n_Awaiting brief from agent._\n`);
|
|
74
|
+
pi.sendUserMessage(
|
|
75
|
+
`Write a handoff brief to \`scratch/HANDOFF.md\` summarising: what was the goal, what shipped, what is left, what to read next, and any open questions. Overwrite the placeholder. Keep it under 30 lines.`,
|
|
76
|
+
);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
fs.writeFileSync(file, `${header}\n${brief}\n`);
|
|
81
|
+
ctx.ui.notify(`Handoff written: ${path.relative(ctx.cwd, file)}`, "info");
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
pi.registerCommand("proof", {
|
|
86
|
+
description: "Ask the agent to prove the recent edit took effect [target]",
|
|
87
|
+
handler: async (args: string | undefined, _ctx: ExtensionCommandContext) => {
|
|
88
|
+
const target = (args ?? "").trim();
|
|
89
|
+
const scope = target
|
|
90
|
+
? `Focus on: ${target}.`
|
|
91
|
+
: `Cover every service whose source you touched this session.`;
|
|
92
|
+
pi.sendUserMessage(
|
|
93
|
+
[
|
|
94
|
+
`Prove the recent edit took effect, per AGENTS.md → "Proving an edit took effect".`,
|
|
95
|
+
scope,
|
|
96
|
+
`For each affected service, wait a beat for that codebase's reloader, then \`curl\` it over the compose network (using the recipe's process name/port, e.g. \`curl http://<bay>-web:3000/up\`) and quote the response. If the response does not reflect the edit, stop and diagnose.`,
|
|
97
|
+
].join(" "),
|
|
98
|
+
);
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status Line Extension
|
|
3
|
+
*
|
|
4
|
+
* Demonstrates ctx.ui.setStatus() for displaying persistent status text in the footer.
|
|
5
|
+
* Shows turn progress with themed colors.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
|
|
10
|
+
export default function (pi: ExtensionAPI) {
|
|
11
|
+
let turnCount = 0;
|
|
12
|
+
|
|
13
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
14
|
+
const theme = ctx.ui.theme;
|
|
15
|
+
ctx.ui.setStatus("status-demo", theme.fg("dim", "Ready"));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
pi.on("turn_start", async (_event, ctx) => {
|
|
19
|
+
turnCount++;
|
|
20
|
+
const theme = ctx.ui.theme;
|
|
21
|
+
const spinner = theme.fg("accent", "●");
|
|
22
|
+
const text = theme.fg("dim", ` Turn ${turnCount}...`);
|
|
23
|
+
ctx.ui.setStatus("status-demo", spinner + text);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
27
|
+
const theme = ctx.ui.theme;
|
|
28
|
+
const check = theme.fg("success", "✓");
|
|
29
|
+
const text = theme.fg("dim", ` Turn ${turnCount} complete`);
|
|
30
|
+
ctx.ui.setStatus("status-demo", check + text);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent discovery and configuration
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
import { CONFIG_DIR_NAME, getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { dedupeAgentsByName } from "../../lib/agents.ts";
|
|
9
|
+
import { packageRoot } from "../../lib/base-paths.ts";
|
|
10
|
+
|
|
11
|
+
export type AgentScope = "user" | "project" | "both";
|
|
12
|
+
|
|
13
|
+
export interface AgentConfig {
|
|
14
|
+
name: string;
|
|
15
|
+
description: string;
|
|
16
|
+
tools?: string[];
|
|
17
|
+
model?: string;
|
|
18
|
+
systemPrompt: string;
|
|
19
|
+
source: "user" | "project";
|
|
20
|
+
filePath: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface AgentDiscoveryResult {
|
|
24
|
+
agents: AgentConfig[];
|
|
25
|
+
projectAgentsDir: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig[] {
|
|
29
|
+
const agents: AgentConfig[] = [];
|
|
30
|
+
|
|
31
|
+
if (!fs.existsSync(dir)) {
|
|
32
|
+
return agents;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let entries: fs.Dirent[];
|
|
36
|
+
try {
|
|
37
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
38
|
+
} catch {
|
|
39
|
+
return agents;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
if (!entry.name.endsWith(".md")) continue;
|
|
44
|
+
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
|
|
45
|
+
|
|
46
|
+
const filePath = path.join(dir, entry.name);
|
|
47
|
+
let content: string;
|
|
48
|
+
try {
|
|
49
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
50
|
+
} catch {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const { frontmatter, body } = parseFrontmatter<Record<string, string>>(content);
|
|
55
|
+
|
|
56
|
+
if (!frontmatter.name || !frontmatter.description) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const tools = frontmatter.tools
|
|
61
|
+
?.split(",")
|
|
62
|
+
.map((t: string) => t.trim())
|
|
63
|
+
.filter(Boolean);
|
|
64
|
+
|
|
65
|
+
agents.push({
|
|
66
|
+
name: frontmatter.name,
|
|
67
|
+
description: frontmatter.description,
|
|
68
|
+
tools: tools && tools.length > 0 ? tools : undefined,
|
|
69
|
+
model: frontmatter.model,
|
|
70
|
+
systemPrompt: body,
|
|
71
|
+
source,
|
|
72
|
+
filePath,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return agents;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isDirectory(p: string): boolean {
|
|
80
|
+
try {
|
|
81
|
+
return fs.statSync(p).isDirectory();
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function findNearestProjectAgentsDir(cwd: string): string | null {
|
|
88
|
+
let currentDir = cwd;
|
|
89
|
+
while (true) {
|
|
90
|
+
const candidate = path.join(currentDir, CONFIG_DIR_NAME, "agents");
|
|
91
|
+
if (isDirectory(candidate)) return candidate;
|
|
92
|
+
|
|
93
|
+
const parentDir = path.dirname(currentDir);
|
|
94
|
+
if (parentDir === currentDir) return null;
|
|
95
|
+
currentDir = parentDir;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {
|
|
100
|
+
const userDir = path.join(getAgentDir(), "agents");
|
|
101
|
+
const projectAgentsDir = findNearestProjectAgentsDir(cwd);
|
|
102
|
+
|
|
103
|
+
const baseAgents = loadAgentsFromDir(path.join(packageRoot(), "agents"), "user"); // base layer, always available
|
|
104
|
+
const userAgents = scope === "project" ? [] : loadAgentsFromDir(userDir, "user");
|
|
105
|
+
const projectAgents = scope === "user" || !projectAgentsDir ? [] : loadAgentsFromDir(projectAgentsDir, "project");
|
|
106
|
+
|
|
107
|
+
const lists =
|
|
108
|
+
scope === "user"
|
|
109
|
+
? [baseAgents, userAgents]
|
|
110
|
+
: scope === "project"
|
|
111
|
+
? [baseAgents, projectAgents]
|
|
112
|
+
: [baseAgents, userAgents, projectAgents];
|
|
113
|
+
|
|
114
|
+
return { agents: dedupeAgentsByName(lists), projectAgentsDir };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function formatAgentList(agents: AgentConfig[], maxItems: number): { text: string; remaining: number } {
|
|
118
|
+
if (agents.length === 0) return { text: "none", remaining: 0 };
|
|
119
|
+
const listed = agents.slice(0, maxItems);
|
|
120
|
+
const remaining = agents.length - listed.length;
|
|
121
|
+
return {
|
|
122
|
+
text: listed.map((a) => `${a.name} (${a.source}): ${a.description}`).join("; "),
|
|
123
|
+
remaining,
|
|
124
|
+
};
|
|
125
|
+
}
|