Merge pull request #184 from laurentenhoor/feat/183-bootstrap-hook
feat: Implement bootstrap hook for role instruction injection
This commit is contained in:
@@ -1,203 +0,0 @@
|
|||||||
/**
|
|
||||||
* Proof of Concept: Bootstrap Hook for Role Instruction Injection
|
|
||||||
*
|
|
||||||
* This demonstrates how devclaw can use OpenClaw's agent:bootstrap hook
|
|
||||||
* to inject role instructions without triggering file-read-network audit patterns.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// NOTE: This is a PoC snippet. In production, this would go in index.ts register()
|
|
||||||
|
|
||||||
import type { InternalHookEvent } from "openclaw/hooks/internal-hooks";
|
|
||||||
import type { WorkspaceBootstrapFile } from "openclaw/agents/workspace";
|
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper: Check if hook event is an agent bootstrap event
|
|
||||||
*/
|
|
||||||
function isAgentBootstrapEvent(event: InternalHookEvent): boolean {
|
|
||||||
if (event.type !== "agent" || event.action !== "bootstrap") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const context = event.context as Record<string, unknown>;
|
|
||||||
return (
|
|
||||||
typeof context.workspaceDir === "string" &&
|
|
||||||
Array.isArray(context.bootstrapFiles)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper: Parse DevClaw worker sessionKey
|
|
||||||
* Format: subagent:agentId/projectName/role/level
|
|
||||||
* Example: subagent:devclaw/my-app/dev/medior
|
|
||||||
*/
|
|
||||||
function parseWorkerSession(sessionKey?: string): {
|
|
||||||
projectName: string;
|
|
||||||
role: "dev" | "qa";
|
|
||||||
} | null {
|
|
||||||
if (!sessionKey) return null;
|
|
||||||
|
|
||||||
const match = sessionKey.match(/^subagent:[^/]+\/([^/]+)\/(dev|qa)/);
|
|
||||||
if (!match) return null;
|
|
||||||
|
|
||||||
const [_, projectName, role] = match;
|
|
||||||
return { projectName, role: role as "dev" | "qa" };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load role instructions (same logic as current dispatch.ts)
|
|
||||||
*/
|
|
||||||
async function loadRoleInstructions(
|
|
||||||
workspaceDir: string,
|
|
||||||
projectName: string,
|
|
||||||
role: "dev" | "qa"
|
|
||||||
): Promise<string> {
|
|
||||||
// Try project-specific instructions first
|
|
||||||
const projectFile = path.join(
|
|
||||||
workspaceDir,
|
|
||||||
"projects",
|
|
||||||
"roles",
|
|
||||||
projectName,
|
|
||||||
`${role}.md`
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await fs.readFile(projectFile, "utf-8");
|
|
||||||
} catch {
|
|
||||||
// Fallback to default instructions
|
|
||||||
const defaultFile = path.join(
|
|
||||||
workspaceDir,
|
|
||||||
"projects",
|
|
||||||
"roles",
|
|
||||||
"default",
|
|
||||||
`${role}.md`
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await fs.readFile(defaultFile, "utf-8");
|
|
||||||
} catch {
|
|
||||||
return ""; // No instructions found
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bootstrap hook handler for DevClaw role instruction injection
|
|
||||||
*
|
|
||||||
* This runs during agent initialization (system prompt construction).
|
|
||||||
* It injects role-specific instructions as a virtual workspace file.
|
|
||||||
*
|
|
||||||
* Benefits:
|
|
||||||
* - No file-read-network pattern in dispatch code
|
|
||||||
* - Instructions appear in system prompt automatically
|
|
||||||
* - Session-aware dynamic injection
|
|
||||||
* - Zero changes to OpenClaw core
|
|
||||||
*/
|
|
||||||
export async function devclawBootstrapHook(event: InternalHookEvent): Promise<void> {
|
|
||||||
// Validate event type
|
|
||||||
if (!isAgentBootstrapEvent(event)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const context = event.context as {
|
|
||||||
workspaceDir: string;
|
|
||||||
bootstrapFiles: WorkspaceBootstrapFile[];
|
|
||||||
sessionKey?: string;
|
|
||||||
sessionId?: string;
|
|
||||||
agentId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const { workspaceDir, bootstrapFiles, sessionKey } = context;
|
|
||||||
|
|
||||||
// Check if this is a DevClaw worker session
|
|
||||||
const parsed = parseWorkerSession(sessionKey);
|
|
||||||
if (!parsed) {
|
|
||||||
// Not a DevClaw worker, skip
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { projectName, role } = parsed;
|
|
||||||
|
|
||||||
// Load role instructions
|
|
||||||
const instructions = await loadRoleInstructions(workspaceDir, projectName, role);
|
|
||||||
|
|
||||||
if (!instructions) {
|
|
||||||
// No instructions found (not an error, just no custom instructions)
|
|
||||||
console.warn(
|
|
||||||
`[DevClaw] No role instructions found for ${projectName}/${role} ` +
|
|
||||||
`(checked projects/roles/${projectName}/${role}.md and default/${role}.md)`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inject as virtual workspace file
|
|
||||||
bootstrapFiles.push({
|
|
||||||
name: "WORKER_INSTRUCTIONS.md",
|
|
||||||
path: `<devclaw:${projectName}:${role}>`, // Virtual path marker
|
|
||||||
content: instructions,
|
|
||||||
missing: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[DevClaw] ✅ Injected ${instructions.length} chars of ${role.toUpperCase()} ` +
|
|
||||||
`instructions for project "${projectName}" via bootstrap hook`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// USAGE EXAMPLE (in index.ts)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/*
|
|
||||||
import { registerInternalHook } from "openclaw/hooks/internal-hooks";
|
|
||||||
import { devclawBootstrapHook } from "./lib/bootstrap-hook.js";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
id: "devclaw",
|
|
||||||
name: "DevClaw",
|
|
||||||
// ... config ...
|
|
||||||
|
|
||||||
register(api: OpenClawPluginApi) {
|
|
||||||
// ... existing tool/CLI/service registration ...
|
|
||||||
|
|
||||||
// Register bootstrap hook for role instruction injection
|
|
||||||
registerInternalHook("agent:bootstrap", devclawBootstrapHook);
|
|
||||||
|
|
||||||
api.logger.info(
|
|
||||||
"DevClaw plugin registered (11 tools, 1 CLI, 1 service, 1 hook)"
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// TESTING
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/*
|
|
||||||
# 1. Start a DevClaw worker session
|
|
||||||
openclaw session create subagent:devclaw/my-app/dev/medior --model claude-sonnet-4
|
|
||||||
|
|
||||||
# 2. Check system prompt includes instructions
|
|
||||||
openclaw session context subagent:devclaw/my-app/dev/medior
|
|
||||||
|
|
||||||
# 3. Verify WORKER_INSTRUCTIONS.md appears in workspace files section
|
|
||||||
|
|
||||||
# 4. Dispatch a task and verify worker behavior unchanged
|
|
||||||
devclaw work start --project my-app --issue 42 --role dev --level medior
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// MIGRATION CHECKLIST
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/*
|
|
||||||
[ ] Create lib/bootstrap-hook.ts with devclawBootstrapHook
|
|
||||||
[ ] Register hook in index.ts register()
|
|
||||||
[ ] Remove loadRoleInstructions from lib/dispatch.ts
|
|
||||||
[ ] Remove roleInstructions from buildTaskMessage
|
|
||||||
[ ] Update tests to verify hook injection
|
|
||||||
[ ] Run security audit to confirm no false positive
|
|
||||||
[ ] Update AGENTS.md to document injection mechanism
|
|
||||||
[ ] Test dev + qa workflows end-to-end
|
|
||||||
*/
|
|
||||||
6
index.ts
6
index.ts
@@ -12,6 +12,7 @@ import { createOnboardTool } from "./lib/tools/onboard.js";
|
|||||||
import { createAutoConfigureModelsTool } from "./lib/tools/autoconfigure-models.js";
|
import { createAutoConfigureModelsTool } from "./lib/tools/autoconfigure-models.js";
|
||||||
import { registerCli } from "./lib/cli.js";
|
import { registerCli } from "./lib/cli.js";
|
||||||
import { registerHeartbeatService } from "./lib/services/heartbeat.js";
|
import { registerHeartbeatService } from "./lib/services/heartbeat.js";
|
||||||
|
import { registerBootstrapHook } from "./lib/bootstrap-hook.js";
|
||||||
import { initRunCommand } from "./lib/run-command.js";
|
import { initRunCommand } from "./lib/run-command.js";
|
||||||
|
|
||||||
const plugin = {
|
const plugin = {
|
||||||
@@ -119,8 +120,11 @@ const plugin = {
|
|||||||
// Services
|
// Services
|
||||||
registerHeartbeatService(api);
|
registerHeartbeatService(api);
|
||||||
|
|
||||||
|
// Bootstrap hook for worker instruction injection
|
||||||
|
registerBootstrapHook(api);
|
||||||
|
|
||||||
api.logger.info(
|
api.logger.info(
|
||||||
"DevClaw plugin registered (11 tools, 1 CLI command group, 1 service)",
|
"DevClaw plugin registered (11 tools, 1 CLI command group, 1 service, 1 hook)",
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
107
lib/bootstrap-hook.test.ts
Normal file
107
lib/bootstrap-hook.test.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Tests for bootstrap hook session key parsing and instruction loading.
|
||||||
|
* Run with: npx tsx --test lib/bootstrap-hook.test.ts
|
||||||
|
*/
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import assert from "node:assert";
|
||||||
|
import { parseDevClawSessionKey, loadRoleInstructions } from "./bootstrap-hook.js";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
|
||||||
|
describe("parseDevClawSessionKey", () => {
|
||||||
|
it("should parse a standard dev session key", () => {
|
||||||
|
const result = parseDevClawSessionKey("agent:devclaw:subagent:my-project-dev-medior");
|
||||||
|
assert.deepStrictEqual(result, { projectName: "my-project", role: "dev" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse a qa session key", () => {
|
||||||
|
const result = parseDevClawSessionKey("agent:devclaw:subagent:webapp-qa-reviewer");
|
||||||
|
assert.deepStrictEqual(result, { projectName: "webapp", role: "qa" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle project names with hyphens", () => {
|
||||||
|
const result = parseDevClawSessionKey("agent:devclaw:subagent:my-cool-project-dev-junior");
|
||||||
|
assert.deepStrictEqual(result, { projectName: "my-cool-project", role: "dev" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle project names with multiple hyphens and qa role", () => {
|
||||||
|
const result = parseDevClawSessionKey("agent:devclaw:subagent:a-b-c-d-qa-tester");
|
||||||
|
assert.deepStrictEqual(result, { projectName: "a-b-c-d", role: "qa" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for non-subagent session keys", () => {
|
||||||
|
const result = parseDevClawSessionKey("agent:devclaw:main");
|
||||||
|
assert.strictEqual(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for session keys without role", () => {
|
||||||
|
const result = parseDevClawSessionKey("agent:devclaw:subagent:project-unknown-level");
|
||||||
|
assert.strictEqual(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for empty string", () => {
|
||||||
|
const result = parseDevClawSessionKey("");
|
||||||
|
assert.strictEqual(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse senior dev level", () => {
|
||||||
|
const result = parseDevClawSessionKey("agent:devclaw:subagent:devclaw-dev-senior");
|
||||||
|
assert.deepStrictEqual(result, { projectName: "devclaw", role: "dev" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse simple project name", () => {
|
||||||
|
const result = parseDevClawSessionKey("agent:devclaw:subagent:api-dev-junior");
|
||||||
|
assert.deepStrictEqual(result, { projectName: "api", role: "dev" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loadRoleInstructions", () => {
|
||||||
|
it("should load project-specific instructions", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-"));
|
||||||
|
const projectDir = path.join(tmpDir, "projects", "roles", "test-project");
|
||||||
|
await fs.mkdir(projectDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(projectDir, "dev.md"), "# Dev Instructions\nDo the thing.");
|
||||||
|
|
||||||
|
const result = await loadRoleInstructions(tmpDir, "test-project", "dev");
|
||||||
|
assert.strictEqual(result, "# Dev Instructions\nDo the thing.");
|
||||||
|
|
||||||
|
await fs.rm(tmpDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fall back to default instructions", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-"));
|
||||||
|
const defaultDir = path.join(tmpDir, "projects", "roles", "default");
|
||||||
|
await fs.mkdir(defaultDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(defaultDir, "qa.md"), "# QA Default\nReview carefully.");
|
||||||
|
|
||||||
|
const result = await loadRoleInstructions(tmpDir, "nonexistent-project", "qa");
|
||||||
|
assert.strictEqual(result, "# QA Default\nReview carefully.");
|
||||||
|
|
||||||
|
await fs.rm(tmpDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return empty string when no instructions exist", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-"));
|
||||||
|
|
||||||
|
const result = await loadRoleInstructions(tmpDir, "missing", "dev");
|
||||||
|
assert.strictEqual(result, "");
|
||||||
|
|
||||||
|
await fs.rm(tmpDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should prefer project-specific over default", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-"));
|
||||||
|
const projectDir = path.join(tmpDir, "projects", "roles", "my-project");
|
||||||
|
const defaultDir = path.join(tmpDir, "projects", "roles", "default");
|
||||||
|
await fs.mkdir(projectDir, { recursive: true });
|
||||||
|
await fs.mkdir(defaultDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(projectDir, "dev.md"), "Project-specific instructions");
|
||||||
|
await fs.writeFile(path.join(defaultDir, "dev.md"), "Default instructions");
|
||||||
|
|
||||||
|
const result = await loadRoleInstructions(tmpDir, "my-project", "dev");
|
||||||
|
assert.strictEqual(result, "Project-specific instructions");
|
||||||
|
|
||||||
|
await fs.rm(tmpDir, { recursive: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
117
lib/bootstrap-hook.ts
Normal file
117
lib/bootstrap-hook.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* bootstrap-hook.ts — Agent bootstrap hook for injecting role instructions.
|
||||||
|
*
|
||||||
|
* Registers an `agent:bootstrap` hook that intercepts DevClaw worker session
|
||||||
|
* startup and injects role-specific instructions as a virtual workspace file.
|
||||||
|
*
|
||||||
|
* This eliminates the file-read-network-send pattern in dispatch.ts that
|
||||||
|
* triggered the security auditor's potential-exfiltration warning.
|
||||||
|
*/
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a DevClaw subagent session key to extract project name and role.
|
||||||
|
*
|
||||||
|
* Session key format: `agent:{agentId}:subagent:{projectName}-{role}-{level}`
|
||||||
|
* Examples:
|
||||||
|
* - `agent:devclaw:subagent:my-project-dev-medior` → { projectName: "my-project", role: "dev" }
|
||||||
|
* - `agent:devclaw:subagent:webapp-qa-reviewer` → { projectName: "webapp", role: "qa" }
|
||||||
|
*
|
||||||
|
* Note: projectName may contain hyphens, so we match role from the end.
|
||||||
|
*/
|
||||||
|
export function parseDevClawSessionKey(
|
||||||
|
sessionKey: string,
|
||||||
|
): { projectName: string; role: "dev" | "qa" } | null {
|
||||||
|
// Match `:subagent:` prefix, then capture everything up to the last `-dev-` or `-qa-`
|
||||||
|
const match = sessionKey.match(/:subagent:(.+)-(dev|qa)-[^-]+$/);
|
||||||
|
if (!match) return null;
|
||||||
|
return { projectName: match[1], role: match[2] as "dev" | "qa" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load role-specific instructions from workspace.
|
||||||
|
* Tries project-specific file first, then falls back to default.
|
||||||
|
*
|
||||||
|
* This is the same logic previously in dispatch.ts loadRoleInstructions(),
|
||||||
|
* now called from the bootstrap hook instead of during dispatch.
|
||||||
|
*/
|
||||||
|
export async function loadRoleInstructions(
|
||||||
|
workspaceDir: string,
|
||||||
|
projectName: string,
|
||||||
|
role: "dev" | "qa",
|
||||||
|
): Promise<string> {
|
||||||
|
const projectFile = path.join(workspaceDir, "projects", "roles", projectName, `${role}.md`);
|
||||||
|
try {
|
||||||
|
return await fs.readFile(projectFile, "utf-8");
|
||||||
|
} catch {
|
||||||
|
/* not found — try default */
|
||||||
|
}
|
||||||
|
const defaultFile = path.join(workspaceDir, "projects", "roles", "default", `${role}.md`);
|
||||||
|
try {
|
||||||
|
return await fs.readFile(defaultFile, "utf-8");
|
||||||
|
} catch {
|
||||||
|
/* not found */
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the agent:bootstrap hook for DevClaw worker instruction injection.
|
||||||
|
*
|
||||||
|
* When a DevClaw worker session starts, this hook:
|
||||||
|
* 1. Detects it's a DevClaw subagent via session key pattern
|
||||||
|
* 2. Extracts project name and role
|
||||||
|
* 3. Loads role-specific instructions from workspace
|
||||||
|
* 4. Injects them as a virtual workspace file (WORKER_INSTRUCTIONS.md)
|
||||||
|
*
|
||||||
|
* OpenClaw automatically includes bootstrap files in the agent's system prompt,
|
||||||
|
* so workers receive their instructions without any file-read in dispatch.ts.
|
||||||
|
*/
|
||||||
|
export function registerBootstrapHook(api: OpenClawPluginApi): void {
|
||||||
|
api.registerHook("agent:bootstrap", async (event) => {
|
||||||
|
const sessionKey = event.sessionKey;
|
||||||
|
if (!sessionKey) return;
|
||||||
|
|
||||||
|
const parsed = parseDevClawSessionKey(sessionKey);
|
||||||
|
if (!parsed) return;
|
||||||
|
|
||||||
|
const context = event.context as {
|
||||||
|
workspaceDir?: string;
|
||||||
|
bootstrapFiles?: Array<{
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
content?: string;
|
||||||
|
missing: boolean;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const workspaceDir = context.workspaceDir;
|
||||||
|
if (!workspaceDir || typeof workspaceDir !== "string") return;
|
||||||
|
|
||||||
|
const bootstrapFiles = context.bootstrapFiles;
|
||||||
|
if (!Array.isArray(bootstrapFiles)) return;
|
||||||
|
|
||||||
|
const instructions = await loadRoleInstructions(
|
||||||
|
workspaceDir,
|
||||||
|
parsed.projectName,
|
||||||
|
parsed.role,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!instructions) return;
|
||||||
|
|
||||||
|
// Inject as a virtual bootstrap file. OpenClaw includes these in the
|
||||||
|
// agent's system prompt automatically (via buildBootstrapContextFiles).
|
||||||
|
bootstrapFiles.push({
|
||||||
|
name: "WORKER_INSTRUCTIONS.md" as any,
|
||||||
|
path: `<devclaw:${parsed.projectName}:${parsed.role}>`,
|
||||||
|
content: instructions.trim(),
|
||||||
|
missing: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
api.logger.info(
|
||||||
|
`Bootstrap hook: injected ${parsed.role} instructions for project "${parsed.projectName}"`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,8 +4,6 @@
|
|||||||
* Handles: session lookup, spawn/reuse via Gateway RPC, task dispatch via CLI,
|
* Handles: session lookup, spawn/reuse via Gateway RPC, task dispatch via CLI,
|
||||||
* state update (activateWorker), and audit logging.
|
* state update (activateWorker), and audit logging.
|
||||||
*/
|
*/
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||||
import { log as auditLog } from "./audit.js";
|
import { log as auditLog } from "./audit.js";
|
||||||
import { runCommand } from "./run-command.js";
|
import { runCommand } from "./run-command.js";
|
||||||
@@ -58,10 +56,12 @@ export type DispatchResult = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the task message sent to a worker session.
|
* Build the task message sent to a worker session.
|
||||||
* Reads role-specific instructions from workspace/projects/roles/<project>/<role>.md (falls back to projects/roles/default/).
|
*
|
||||||
|
* Role-specific instructions are no longer included in the message body.
|
||||||
|
* They are injected via the agent:bootstrap hook (see bootstrap-hook.ts)
|
||||||
|
* into the worker's system prompt as WORKER_INSTRUCTIONS.md.
|
||||||
*/
|
*/
|
||||||
export async function buildTaskMessage(opts: {
|
export function buildTaskMessage(opts: {
|
||||||
workspaceDir: string;
|
|
||||||
projectName: string;
|
projectName: string;
|
||||||
role: "dev" | "qa";
|
role: "dev" | "qa";
|
||||||
issueId: number;
|
issueId: number;
|
||||||
@@ -72,14 +72,12 @@ export async function buildTaskMessage(opts: {
|
|||||||
baseBranch: string;
|
baseBranch: string;
|
||||||
groupId: string;
|
groupId: string;
|
||||||
comments?: Array<{ author: string; body: string; created_at: string }>;
|
comments?: Array<{ author: string; body: string; created_at: string }>;
|
||||||
}): Promise<string> {
|
}): string {
|
||||||
const {
|
const {
|
||||||
workspaceDir, projectName, role, issueId, issueTitle,
|
projectName, role, issueId, issueTitle,
|
||||||
issueDescription, issueUrl, repo, baseBranch, groupId,
|
issueDescription, issueUrl, repo, baseBranch, groupId,
|
||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
const roleInstructions = await loadRoleInstructions(workspaceDir, projectName, role);
|
|
||||||
|
|
||||||
const availableResults =
|
const availableResults =
|
||||||
role === "dev"
|
role === "dev"
|
||||||
? '"done" (completed successfully) or "blocked" (cannot complete, need help)'
|
? '"done" (completed successfully) or "blocked" (cannot complete, need help)'
|
||||||
@@ -109,10 +107,6 @@ export async function buildTaskMessage(opts: {
|
|||||||
`Project group ID: ${groupId}`,
|
`Project group ID: ${groupId}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (roleInstructions) {
|
|
||||||
parts.push(``, `---`, ``, roleInstructions.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
parts.push(
|
parts.push(
|
||||||
``, `---`, ``,
|
``, `---`, ``,
|
||||||
`## MANDATORY: Task Completion`,
|
`## MANDATORY: Task Completion`,
|
||||||
@@ -166,8 +160,8 @@ export async function dispatchTask(
|
|||||||
// Fetch comments to include in task context
|
// Fetch comments to include in task context
|
||||||
const comments = await provider.listComments(issueId);
|
const comments = await provider.listComments(issueId);
|
||||||
|
|
||||||
const taskMessage = await buildTaskMessage({
|
const taskMessage = buildTaskMessage({
|
||||||
workspaceDir, projectName: project.name, role, issueId,
|
projectName: project.name, role, issueId,
|
||||||
issueTitle, issueDescription, issueUrl,
|
issueTitle, issueDescription, issueUrl,
|
||||||
repo: project.repo, baseBranch: project.baseBranch, groupId,
|
repo: project.repo, baseBranch: project.baseBranch, groupId,
|
||||||
comments,
|
comments,
|
||||||
@@ -240,21 +234,6 @@ export async function dispatchTask(
|
|||||||
// Private helpers — exist so dispatchTask reads as a sequence of steps
|
// Private helpers — exist so dispatchTask reads as a sequence of steps
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
|
||||||
* Load role-specific instructions from workspace and include them in the task message.
|
|
||||||
* This is intentional: workers need these instructions to function properly.
|
|
||||||
* (Not data exfiltration — just standard task dispatch context.)
|
|
||||||
*/
|
|
||||||
async function loadRoleInstructions(
|
|
||||||
workspaceDir: string, projectName: string, role: "dev" | "qa",
|
|
||||||
): Promise<string> {
|
|
||||||
const projectFile = path.join(workspaceDir, "projects", "roles", projectName, `${role}.md`);
|
|
||||||
try { return await fs.readFile(projectFile, "utf-8"); } catch { /* none */ }
|
|
||||||
const defaultFile = path.join(workspaceDir, "projects", "roles", "default", `${role}.md`);
|
|
||||||
try { return await fs.readFile(defaultFile, "utf-8"); } catch { /* none */ }
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fire-and-forget session creation/update.
|
* Fire-and-forget session creation/update.
|
||||||
* Session key is deterministic, so we don't need to wait for confirmation.
|
* Session key is deterministic, so we don't need to wait for confirmation.
|
||||||
|
|||||||
Reference in New Issue
Block a user