@selesai/code 0.5.9 → 0.5.11
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/dist/extensions/grep-app/index.test.ts +51 -0
- package/dist/extensions/grep-app/index.ts +200 -0
- package/dist/extensions/package.json +1 -0
- package/dist/extensions/pi-subagents/agents/researcher.md +6 -4
- package/dist/extensions/workflow/adapter.ts +39 -8
- package/dist/extensions/workflow/modes/task.ts +44 -9
- package/docs/workflows.md +9 -8
- package/package.json +1 -1
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildSearchUrl, formatSearchResponse } from "./index.ts";
|
|
3
|
+
|
|
4
|
+
describe("grep-app extension", () => {
|
|
5
|
+
it("maps search filters to grep.app parameters", () => {
|
|
6
|
+
const url = buildSearchUrl({
|
|
7
|
+
query: "useEffect cleanup",
|
|
8
|
+
page: 2,
|
|
9
|
+
caseSensitive: true,
|
|
10
|
+
useRegex: true,
|
|
11
|
+
repo: "facebook/react",
|
|
12
|
+
path: "packages/",
|
|
13
|
+
language: "TypeScript",
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
expect(Object.fromEntries(url.searchParams)).toEqual({
|
|
17
|
+
q: "useEffect cleanup",
|
|
18
|
+
page: "2",
|
|
19
|
+
case: "true",
|
|
20
|
+
regexp: "true",
|
|
21
|
+
"f.repo.pattern": "facebook/react",
|
|
22
|
+
"f.path.pattern": "packages/",
|
|
23
|
+
"f.lang": "TypeScript",
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("turns grep.app HTML snippets into readable numbered source", () => {
|
|
28
|
+
const output = formatSearchResponse(
|
|
29
|
+
{
|
|
30
|
+
hits: {
|
|
31
|
+
total: 42,
|
|
32
|
+
hits: [
|
|
33
|
+
{
|
|
34
|
+
repo: "owner/repo",
|
|
35
|
+
branch: "main",
|
|
36
|
+
path: "src/a file.ts",
|
|
37
|
+
content: {
|
|
38
|
+
snippet: '<table><tr data-line="7"><td><pre>const x = <mark>;</pre></td></tr></table>',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
3,
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
expect(output).toContain("42 total matches; page 3");
|
|
48
|
+
expect(output).toContain("L7: const x = <mark>;");
|
|
49
|
+
expect(output).toContain("https://github.com/owner/repo/blob/main/src/a%20file.ts#L7");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/** Native Selesai extension inspired by ai-tools-all/grep_app_mcp (ISC). */
|
|
2
|
+
|
|
3
|
+
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_MAX_BYTES,
|
|
8
|
+
DEFAULT_MAX_LINES,
|
|
9
|
+
formatSize,
|
|
10
|
+
truncateHead,
|
|
11
|
+
type ExtensionAPI,
|
|
12
|
+
withFileMutationQueue,
|
|
13
|
+
} from "@selesai/code";
|
|
14
|
+
import { load } from "cheerio";
|
|
15
|
+
import { Type } from "typebox";
|
|
16
|
+
|
|
17
|
+
const API_URL = "https://grep.app/api/search";
|
|
18
|
+
const RAW_GITHUB_URL = "https://raw.githubusercontent.com";
|
|
19
|
+
|
|
20
|
+
interface GrepAppHit {
|
|
21
|
+
repo: string;
|
|
22
|
+
branch: string;
|
|
23
|
+
path: string;
|
|
24
|
+
content: { snippet: string };
|
|
25
|
+
total_matches?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface GrepAppResponse {
|
|
29
|
+
hits: { total: number; hits: GrepAppHit[] };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface SearchParams {
|
|
33
|
+
query: string;
|
|
34
|
+
page?: number;
|
|
35
|
+
caseSensitive?: boolean;
|
|
36
|
+
useRegex?: boolean;
|
|
37
|
+
wholeWords?: boolean;
|
|
38
|
+
repo?: string;
|
|
39
|
+
path?: string;
|
|
40
|
+
language?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function buildSearchUrl(params: SearchParams): URL {
|
|
44
|
+
const url = new URL(API_URL);
|
|
45
|
+
url.searchParams.set("q", params.query);
|
|
46
|
+
url.searchParams.set("page", String(params.page ?? 1));
|
|
47
|
+
if (params.caseSensitive) url.searchParams.set("case", "true");
|
|
48
|
+
if (params.useRegex) url.searchParams.set("regexp", "true");
|
|
49
|
+
if (params.wholeWords) url.searchParams.set("words", "true");
|
|
50
|
+
if (params.repo) url.searchParams.set("f.repo.pattern", params.repo);
|
|
51
|
+
if (params.path) url.searchParams.set("f.path.pattern", params.path);
|
|
52
|
+
if (params.language) url.searchParams.set("f.lang", params.language);
|
|
53
|
+
return url;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function snippetLines(snippet: string): Array<{ line: string; text: string }> {
|
|
57
|
+
const $ = load(snippet);
|
|
58
|
+
return $("tr")
|
|
59
|
+
.toArray()
|
|
60
|
+
.map((row) => ({
|
|
61
|
+
line: $(row).attr("data-line") ?? "?",
|
|
62
|
+
text: $(row).find("pre").text().replace(/\s+$/, ""),
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function encodePath(value: string): string {
|
|
67
|
+
return value
|
|
68
|
+
.split("/")
|
|
69
|
+
.filter(Boolean)
|
|
70
|
+
.map(encodeURIComponent)
|
|
71
|
+
.join("/");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function formatSearchResponse(response: GrepAppResponse, page: number): string {
|
|
75
|
+
if (response.hits.hits.length === 0) return "No matches found.";
|
|
76
|
+
|
|
77
|
+
const results = response.hits.hits.map((hit, index) => {
|
|
78
|
+
const lines = snippetLines(hit.content.snippet);
|
|
79
|
+
const firstLine = lines.find((line) => line.line !== "?")?.line;
|
|
80
|
+
const url = `https://github.com/${hit.repo}/blob/${encodePath(hit.branch)}/${encodePath(hit.path)}${firstLine ? `#L${firstLine}` : ""}`;
|
|
81
|
+
const body = lines.map(({ line, text }) => ` L${line}: ${text}`).join("\n");
|
|
82
|
+
return `${index + 1}. ${hit.repo}:${hit.path} (${hit.branch})\n${body}\n ${url}`;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return `grep.app: ${response.hits.total} total matches; page ${page}\n\n${results.join("\n\n")}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function fetchWithBrowser(url: URL | string, signal: AbortSignal | undefined): Promise<string> {
|
|
89
|
+
const { chromium } = await import("playwright");
|
|
90
|
+
const browser = await chromium.launch({ headless: true });
|
|
91
|
+
const abort = () => void browser.close();
|
|
92
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
93
|
+
try {
|
|
94
|
+
const page = await browser.newPage();
|
|
95
|
+
await page.goto(String(url), { waitUntil: "networkidle", timeout: 30_000 });
|
|
96
|
+
await page.waitForFunction(() => document.body.innerText.trimStart().startsWith("{"), undefined, { timeout: 15_000 });
|
|
97
|
+
return await page.locator("body").innerText();
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (signal?.aborted) throw signal.reason ?? new Error("Cancelled");
|
|
100
|
+
throw error;
|
|
101
|
+
} finally {
|
|
102
|
+
signal?.removeEventListener("abort", abort);
|
|
103
|
+
await browser.close();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function fetchText(url: URL | string, signal: AbortSignal | undefined, browserFallback = false): Promise<string> {
|
|
108
|
+
const response = await fetch(url, {
|
|
109
|
+
signal,
|
|
110
|
+
headers: { Accept: "application/json, text/plain;q=0.9", "User-Agent": "selesai-grep-app-extension" },
|
|
111
|
+
});
|
|
112
|
+
if (browserFallback && response.status === 429 && response.headers.get("x-vercel-mitigated") === "challenge") {
|
|
113
|
+
return fetchWithBrowser(url, signal);
|
|
114
|
+
}
|
|
115
|
+
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
|
116
|
+
return response.text();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function truncateOutput(output: string): Promise<{ text: string; fullOutputPath?: string }> {
|
|
120
|
+
const truncated = truncateHead(output, { maxBytes: DEFAULT_MAX_BYTES, maxLines: DEFAULT_MAX_LINES });
|
|
121
|
+
if (!truncated.truncated) return { text: output };
|
|
122
|
+
|
|
123
|
+
const directory = await mkdtemp(join(tmpdir(), "selesai-grep-app-"));
|
|
124
|
+
const fullOutputPath = join(directory, "output.txt");
|
|
125
|
+
await withFileMutationQueue(fullOutputPath, () => writeFile(fullOutputPath, output, "utf8"));
|
|
126
|
+
return {
|
|
127
|
+
text: `${truncated.content}\n\n[Output truncated to ${truncated.outputLines} lines / ${formatSize(truncated.outputBytes)}. Full output: ${fullOutputPath}]`,
|
|
128
|
+
fullOutputPath,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export default function grepAppExtension(pi: ExtensionAPI): void {
|
|
133
|
+
pi.registerTool({
|
|
134
|
+
name: "grep_app_search",
|
|
135
|
+
label: "grep.app Search",
|
|
136
|
+
description: "Search public GitHub code through grep.app. Returns one page (up to 10 files); use page to continue.",
|
|
137
|
+
promptSnippet: "Search public GitHub code through grep.app",
|
|
138
|
+
promptGuidelines: ["Use grep_app_search to find real public-code implementations and usage examples across GitHub."],
|
|
139
|
+
parameters: Type.Object({
|
|
140
|
+
query: Type.String({ minLength: 1, description: "Code or pattern to search for." }),
|
|
141
|
+
page: Type.Optional(Type.Integer({ minimum: 1, description: "Result page; defaults to 1." })),
|
|
142
|
+
caseSensitive: Type.Optional(Type.Boolean({ description: "Match case." })),
|
|
143
|
+
useRegex: Type.Optional(Type.Boolean({ description: "Treat query as a regular expression." })),
|
|
144
|
+
wholeWords: Type.Optional(Type.Boolean({ description: "Match whole words. Cannot be combined with useRegex." })),
|
|
145
|
+
repo: Type.Optional(Type.String({ description: "Repository filter, for example facebook/react." })),
|
|
146
|
+
path: Type.Optional(Type.String({ description: "File path filter, for example src/components/." })),
|
|
147
|
+
language: Type.Optional(Type.String({ description: "Language filter, for example TypeScript." })),
|
|
148
|
+
}),
|
|
149
|
+
async execute(_toolCallId, params, signal) {
|
|
150
|
+
if (params.useRegex && params.wholeWords) throw new Error("useRegex and wholeWords cannot both be true.");
|
|
151
|
+
|
|
152
|
+
const page = params.page ?? 1;
|
|
153
|
+
const raw = await fetchText(buildSearchUrl(params), signal, true);
|
|
154
|
+
let response: GrepAppResponse;
|
|
155
|
+
try {
|
|
156
|
+
response = JSON.parse(raw) as GrepAppResponse;
|
|
157
|
+
} catch {
|
|
158
|
+
throw new Error("grep.app returned invalid JSON.");
|
|
159
|
+
}
|
|
160
|
+
if (!response.hits || !Array.isArray(response.hits.hits)) throw new Error("grep.app returned an unexpected response.");
|
|
161
|
+
|
|
162
|
+
const output = await truncateOutput(formatSearchResponse(response, page));
|
|
163
|
+
return {
|
|
164
|
+
content: [{ type: "text", text: output.text }],
|
|
165
|
+
details: { total: response.hits.total, page, resultCount: response.hits.hits.length, fullOutputPath: output.fullOutputPath },
|
|
166
|
+
};
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
pi.registerTool({
|
|
171
|
+
name: "grep_app_fetch",
|
|
172
|
+
label: "GitHub File",
|
|
173
|
+
description: "Fetch a public GitHub file found via grep.app. Supports line ranges; output is truncated to 50KB or 2000 lines.",
|
|
174
|
+
promptSnippet: "Fetch a public GitHub file found via grep.app",
|
|
175
|
+
promptGuidelines: ["Use grep_app_fetch after grep_app_search when the full source context is needed."],
|
|
176
|
+
parameters: Type.Object({
|
|
177
|
+
repo: Type.String({ pattern: "^[^/\\s]+/[^/\\s]+$", description: "owner/repository." }),
|
|
178
|
+
path: Type.String({ minLength: 1, description: "Path inside the repository." }),
|
|
179
|
+
ref: Type.Optional(Type.String({ minLength: 1, description: "Branch, tag, or commit; defaults to HEAD." })),
|
|
180
|
+
startLine: Type.Optional(Type.Integer({ minimum: 1, description: "First line; defaults to 1." })),
|
|
181
|
+
endLine: Type.Optional(Type.Integer({ minimum: 1, description: "Last line; defaults to end of file." })),
|
|
182
|
+
}),
|
|
183
|
+
async execute(_toolCallId, params, signal) {
|
|
184
|
+
const startLine = params.startLine ?? 1;
|
|
185
|
+
if (params.endLine !== undefined && params.endLine < startLine) throw new Error("endLine must be greater than or equal to startLine.");
|
|
186
|
+
|
|
187
|
+
const rawUrl = `${RAW_GITHUB_URL}/${encodePath(params.repo)}/${encodePath(params.ref ?? "HEAD")}/${encodePath(params.path)}`;
|
|
188
|
+
const source = await fetchText(rawUrl, signal);
|
|
189
|
+
const allLines = source.split("\n");
|
|
190
|
+
const endLine = Math.min(params.endLine ?? allLines.length, allLines.length);
|
|
191
|
+
const selected = allLines.slice(startLine - 1, endLine).map((line, index) => `${startLine + index}: ${line}`).join("\n");
|
|
192
|
+
const output = await truncateOutput(selected);
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
content: [{ type: "text", text: output.text || "(empty file or range)" }],
|
|
196
|
+
details: { repo: params.repo, path: params.path, ref: params.ref ?? "HEAD", startLine, endLine, totalLines: allLines.length, fullOutputPath: output.fullOutputPath },
|
|
197
|
+
};
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: researcher
|
|
3
|
-
description: Autonomous
|
|
4
|
-
tools: read, web_explore
|
|
3
|
+
description: Autonomous code-first researcher that produces a focused sourced brief
|
|
4
|
+
tools: read, grep_app_search, grep_app_fetch, web_explore
|
|
5
5
|
thinking: medium
|
|
6
6
|
systemPromptMode: replace
|
|
7
7
|
inheritProjectContext: false
|
|
@@ -10,9 +10,11 @@ defaultContext: fresh
|
|
|
10
10
|
skill: ponytail, caveman
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
-
You are a
|
|
13
|
+
You are a code-first research subagent. Answer the supplied question with a concise, well-sourced brief. Do not edit project files, write output files, or launch subagents.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
Start with `grep_app_search` for public-code evidence and use `grep_app_fetch` only for the most relevant source files. Search 2–4 distinct angles, prefer original implementations and repository-owned examples, then narrow follow-ups only for material gaps.
|
|
16
|
+
|
|
17
|
+
Treat `web_explore` as a last resort: use it only when grep.app cannot answer the question, the task requires non-code sources or official documentation, or grep.app fails. If `web_explore` is unavailable, report the gap instead of fetching URLs through shell commands.
|
|
16
18
|
|
|
17
19
|
Output:
|
|
18
20
|
|
|
@@ -226,8 +226,38 @@ async function resumeController(
|
|
|
226
226
|
try {
|
|
227
227
|
await persistAfter(controller, before);
|
|
228
228
|
const reconcileBefore = checkpoint(controller);
|
|
229
|
-
|
|
230
|
-
|
|
229
|
+
let reconciled: WorkflowEffect = { kind: "noOp" };
|
|
230
|
+
try {
|
|
231
|
+
reconciled = await sm.onArtifactMaybe(controller.deps);
|
|
232
|
+
await persistRun(controller);
|
|
233
|
+
// ponytail: when a resumed run has multiple already-written parent-owned
|
|
234
|
+
// artifacts, one onArtifactMaybe only advances one phase. Keep reconciling
|
|
235
|
+
// until we either catch up to the durable file state or hit a blocked/
|
|
236
|
+
// terminal effect, so resume lands on the furthest valid phase.
|
|
237
|
+
while (sm.snapshot.active && sm.snapshot.autoArmed) {
|
|
238
|
+
const further = await sm.onArtifactMaybe(controller.deps);
|
|
239
|
+
if (
|
|
240
|
+
further.kind === "noOp" ||
|
|
241
|
+
further.kind === "blocked" ||
|
|
242
|
+
further.kind === "terminalNeedsArtifacts"
|
|
243
|
+
) break;
|
|
244
|
+
reconciled = further;
|
|
245
|
+
await persistRun(controller);
|
|
246
|
+
if (further.kind === "terminalReady") break;
|
|
247
|
+
}
|
|
248
|
+
} catch (error) {
|
|
249
|
+
// Atomic rollback: on any failure during reconciliation, restore both
|
|
250
|
+
// in-memory state and durable state to the checkpoint before the whole
|
|
251
|
+
// reconcile sequence. Do not leave the controller at an intermediate
|
|
252
|
+
// phase while the run file has already advanced further.
|
|
253
|
+
restoreCheckpoint(controller, reconcileBefore);
|
|
254
|
+
try {
|
|
255
|
+
await persistRun(controller);
|
|
256
|
+
} catch {
|
|
257
|
+
// Best-effort rollback failed; still report the original error.
|
|
258
|
+
}
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
231
261
|
const current = sm.continueCurrent();
|
|
232
262
|
const terminal = config.phases[config.phases.length - 1];
|
|
233
263
|
const terminalReady = reconciled.kind === "terminalReady" || (sm.snapshot.phase === terminal && !sm.snapshot.autoArmed);
|
|
@@ -304,7 +334,7 @@ function makeDeps(pi: ExtensionAPI, config: WorkflowConfig): WorkflowDeps {
|
|
|
304
334
|
|
|
305
335
|
// ponytail: the git-based skip predicate shared by both modes today.
|
|
306
336
|
// A mode that wants a different skip rule supplies its own SkipRule.
|
|
307
|
-
async function
|
|
337
|
+
async function hasNoGitHistory(pi: ExtensionAPI): Promise<boolean> {
|
|
308
338
|
try {
|
|
309
339
|
const result = await pi.exec("git", ["log", "--oneline", "-1"]);
|
|
310
340
|
return result.code !== 0 || !result.stdout.trim();
|
|
@@ -482,7 +512,8 @@ function registerSharedArtifactWriter(pi: ExtensionAPI): void {
|
|
|
482
512
|
};
|
|
483
513
|
}
|
|
484
514
|
// Most workflows pause at a user-controlled artifact boundary. Task's
|
|
485
|
-
// plan → loop
|
|
515
|
+
// plan → reuse → handoff → loop transitions queue the next phase prompt
|
|
516
|
+
// immediately when continueAfterArtifact is true.
|
|
486
517
|
const queueNextPhase = controller.config.continueAfterArtifact === true && eff.kind === "advanced";
|
|
487
518
|
applyControllerEffect(controller, ctx, eff, { queuePrompt: queueNextPhase });
|
|
488
519
|
if (eff.kind === "blocked" && eff.reason) {
|
|
@@ -519,13 +550,13 @@ export function createWorkflowExtension(
|
|
|
519
550
|
return function workflowExtension(pi: ExtensionAPI): void {
|
|
520
551
|
replaceControllerFor(pi, config.mode);
|
|
521
552
|
const deps = makeDeps(pi, config);
|
|
522
|
-
// ponytail: default skip rule — reuse is skipped
|
|
523
|
-
//
|
|
524
|
-
// supplies its own skipRules in config and we respect them as-is.
|
|
553
|
+
// ponytail: default skip rule — reuse is skipped when the project has no
|
|
554
|
+
// git history. Shared by every mode today; a mode that wants a different
|
|
555
|
+
// rule supplies its own skipRules in config and we respect them as-is.
|
|
525
556
|
const skipRules = config.skipRules ?? [
|
|
526
557
|
{
|
|
527
558
|
phase: "reuse",
|
|
528
|
-
shouldSkip: async () =>
|
|
559
|
+
shouldSkip: async () => hasNoGitHistory(pi),
|
|
529
560
|
},
|
|
530
561
|
];
|
|
531
562
|
const sm = new WorkflowStateMachine({ ...config, skipRules });
|
|
@@ -4,11 +4,17 @@ import type {
|
|
|
4
4
|
WorkflowConfig,
|
|
5
5
|
WorkflowModeRegistration,
|
|
6
6
|
} from "../state-machine.ts";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
handoffValidator,
|
|
9
|
+
loopCompleteValidator,
|
|
10
|
+
planValidator,
|
|
11
|
+
} from "../validators.ts";
|
|
8
12
|
|
|
9
|
-
// ponytail: task workflow is
|
|
10
|
-
//
|
|
11
|
-
|
|
13
|
+
// ponytail: task workflow is now plan → reuse → handoff → loop. Each parent-
|
|
14
|
+
// owned artifact (plan.md, reuse.md, handoff.md) advances automatically, and
|
|
15
|
+
// the loop's clean marker makes the workflow terminal-ready. The explicit end
|
|
16
|
+
// tool is the only completion.
|
|
17
|
+
const phases: Phase[] = ["plan", "reuse", "handoff", "loop"];
|
|
12
18
|
|
|
13
19
|
const prompts: Partial<Record<Phase, (ctx: PromptContext) => string>> = {
|
|
14
20
|
plan: ({ artifactDir, userPrompt }) =>
|
|
@@ -24,12 +30,38 @@ Call the subagent tool with { agent: "architect", task: "...", output: false } (
|
|
|
24
30
|
Inspect that result, verify it ends with exactly one machine-readable line on its own:
|
|
25
31
|
WORKFLOW_PLAN_STATUS: ready
|
|
26
32
|
Then immediately call write_workflow_artifact with the complete validated plan as content. Only that parent tool call writes ${artifactDir}/plan.md and advances the workflow.`,
|
|
33
|
+
reuse: ({ artifactDir }) =>
|
|
34
|
+
`You are in the REUSE phase of a TASK workflow (optional).
|
|
35
|
+
|
|
36
|
+
The adapter automatically skips this phase when the project has no git history, so codebase exploration is only useful when commits already exist. Decide whether exploration is still useful; skip if the task creates something wholly new or there is clearly nothing to reuse.
|
|
37
|
+
|
|
38
|
+
If unsure, ask the user one focused question: "Should I explore the existing codebase for reusable patterns before implementing?" Then follow their answer.
|
|
39
|
+
|
|
40
|
+
If YES (or user confirms): call the subagent tool with { agent: "explorer", task: "...", output: false } (do NOT pass a model parameter). Craft the task from ${artifactDir}/plan.md pointing it at relevant areas, patterns, and dependencies. It must return the reuse findings inline; do not tell it to write any artifact. Immediately call write_workflow_artifact with those findings as content for ${artifactDir}/reuse.md.
|
|
41
|
+
|
|
42
|
+
If NO: call write_workflow_artifact with a brief skip note explaining why.
|
|
43
|
+
|
|
44
|
+
The workflow advances once ${artifactDir}/reuse.md exists.`,
|
|
45
|
+
handoff: ({ artifactDir }) =>
|
|
46
|
+
`You are in the HANDOFF phase of a TASK workflow.
|
|
47
|
+
|
|
48
|
+
Compile a self-contained handoff document so loop-phase sub-agents understand full project context without re-grilling.
|
|
49
|
+
|
|
50
|
+
Draw from all prior phases:
|
|
51
|
+
- ${artifactDir}/plan.md
|
|
52
|
+
- ${artifactDir}/reuse.md
|
|
53
|
+
|
|
54
|
+
Call the subagent tool with { agent: "recapper", task: "...", output: false } (do NOT pass a model parameter). Craft the task pointing the recapper at both artifact files and telling it what the task is about. It must return the complete handoff inline; do not tell it to write any artifact.
|
|
55
|
+
|
|
56
|
+
Inspect that result, verify it ends with exactly one machine-readable line on its own:
|
|
57
|
+
WORKFLOW_HANDOFF_STATUS: ready
|
|
58
|
+
Then immediately call write_workflow_artifact with the complete validated handoff as content. Only that parent tool call writes ${artifactDir}/handoff.md and advances the workflow.`,
|
|
27
59
|
loop: ({ artifactDir, loopMaxIterations }) =>
|
|
28
60
|
`You are in the LOOP (orchestration) phase of a TASK workflow. The workflow ENGINE owns the implement→review loop — you do NOT track iterations or decide when the loop is clean.
|
|
29
61
|
|
|
30
|
-
Use read to inspect ${artifactDir}/plan.md. Using that context, GENERATE YOUR OWN delegation prompt:
|
|
62
|
+
Use read to inspect ${artifactDir}/plan.md, ${artifactDir}/reuse.md, and ${artifactDir}/handoff.md. Using that context, GENERATE YOUR OWN delegation prompt:
|
|
31
63
|
|
|
32
|
-
Call the subagent tool with { agent: "builder", task: "...", output: false } (do NOT pass a model parameter). Give it
|
|
64
|
+
Call the subagent tool with { agent: "builder", task: "...", output: false } (do NOT pass a model parameter). Give it plan + reuse + handoff context, tailored to this task. Instruct it to implement every task in plan.md in order. All code changes go in the workspace, never in ${artifactDir}. The builder must return its completion summary inline.
|
|
33
65
|
|
|
34
66
|
After the builder returns, call the subagent tool with { agent: "commentator", task: "...", output: false }. Craft the review task YOURSELF. The commentator must return its review inline. Each commentator review MUST end with exactly one machine-readable line:
|
|
35
67
|
WORKFLOW_REVIEW_STATUS: clean
|
|
@@ -44,19 +76,21 @@ const config: WorkflowConfig = {
|
|
|
44
76
|
phases,
|
|
45
77
|
phaseArtifacts: {
|
|
46
78
|
plan: "plan.md",
|
|
79
|
+
reuse: "reuse.md",
|
|
80
|
+
handoff: "handoff.md",
|
|
47
81
|
loop: "loop-complete.md",
|
|
48
82
|
},
|
|
49
83
|
prompts,
|
|
50
84
|
skipRules: [],
|
|
51
85
|
artifactValidators: {
|
|
52
86
|
plan: planValidator,
|
|
87
|
+
handoff: handoffValidator,
|
|
53
88
|
loop: loopCompleteValidator,
|
|
54
89
|
},
|
|
55
90
|
closeValidators: { "loop-complete.md": loopCompleteValidator },
|
|
56
91
|
closeArtifacts: ["loop-complete.md"],
|
|
57
92
|
loopMaxIterations: 3,
|
|
58
|
-
//
|
|
59
|
-
// finishes, rather than requiring a second /workflow-task interaction.
|
|
93
|
+
// Each parent-owned artifact automatically queues the next phase prompt.
|
|
60
94
|
continueAfterArtifact: true,
|
|
61
95
|
statusKey: "task",
|
|
62
96
|
entryType: "task-phase",
|
|
@@ -66,7 +100,8 @@ const config: WorkflowConfig = {
|
|
|
66
100
|
export const taskMode: WorkflowModeRegistration = {
|
|
67
101
|
config,
|
|
68
102
|
commandName: "workflow-task",
|
|
69
|
-
commandDescription:
|
|
103
|
+
commandDescription:
|
|
104
|
+
"Run the task workflow (plan → reuse → handoff → build↔review loop)",
|
|
70
105
|
toolNames: {
|
|
71
106
|
start: "start_task_workflow",
|
|
72
107
|
resume: "resume_task_workflow",
|
package/docs/workflows.md
CHANGED
|
@@ -120,17 +120,18 @@ That's it. The loader picks it up at boot (`package.json` loads only `./extensio
|
|
|
120
120
|
|
|
121
121
|
## Built-in modes
|
|
122
122
|
|
|
123
|
-
### `task` —
|
|
123
|
+
### `task` — plan → codebase exploration → handoff → build/review loop
|
|
124
124
|
|
|
125
|
-
|
|
125
|
+
Task now follows the same phase shape as the other modes, minus grilling/research/audit: an architect subagent produces a validated `plan.md`, an optional explorer subagent produces `reuse.md`, a recapper subagent produces a validated `handoff.md`, and then a builder↔commentator review loop runs (max 3 blocking rounds). A clean review makes the workflow terminal-ready; `end_task_workflow` completes it.
|
|
126
126
|
|
|
127
|
-
Lifecycle: `plan → loop (build ↔ review) → terminal-ready → end_task_workflow`
|
|
127
|
+
Lifecycle: `plan → reuse → handoff → loop (build ↔ review) → terminal-ready → end_task_workflow`
|
|
128
128
|
|
|
129
129
|
- `/workflow-task <goal>` — start a new run
|
|
130
130
|
- `/workflow-task resume` — list and resume active runs
|
|
131
131
|
- `/workflow-task help` — show the lifecycle
|
|
132
|
-
-
|
|
133
|
-
- No grilling, research,
|
|
132
|
+
- Valid `plan.md`, `reuse.md`, and `handoff.md` each automatically queue the next phase prompt (the workflow does not pause at those boundaries)
|
|
133
|
+
- No grilling, research, or audit phases
|
|
134
|
+
- `reuse.md` is optional; it is skipped automatically when the project has no git history
|
|
134
135
|
|
|
135
136
|
## Config reference
|
|
136
137
|
|
|
@@ -141,11 +142,11 @@ Lifecycle: `plan → loop (build ↔ review) → terminal-ready → end_task_wor
|
|
|
141
142
|
| `phaseArtifacts` | `Partial<Record<Phase, string>>` | The artifact file each phase must produce before advancing. Omit a phase to skip its gate. |
|
|
142
143
|
| `prompts` | `Partial<Record<Phase, (ctx) => string>>` | Prompt generator per phase. `ctx = { artifactDir, userPrompt }`. |
|
|
143
144
|
| `closeArtifacts` | `string[]` | Files that must exist before `end()` succeeds. Config-owned, no built-in default. |
|
|
144
|
-
| `skipRules?` | `{ phase, shouldSkip }[]` | Optional per-phase skip rules. `shouldSkip` is a boolean predicate; when true the engine skips to the next phase. Omit to use the adapter's default (skip `reuse`
|
|
145
|
+
| `skipRules?` | `{ phase, shouldSkip }[]` | Optional per-phase skip rules. `shouldSkip` is a boolean predicate; when true the engine skips to the next phase. Omit to use the adapter's default (skip `reuse` when the project has no git history). |
|
|
145
146
|
| `statusKey` | `string` | Footer status key. |
|
|
146
147
|
| `entryType` | `string` | Session-history custom-type. It stores a pointer only; `workflow.json` is canonical. |
|
|
147
148
|
| `footerLabel` | `string` | Label shown in the footer (`● label · step/total phase`). |
|
|
148
|
-
| `continueAfterArtifact?` | `boolean` | Queue the next phase prompt after the parent writes a valid artifact. `task` enables this
|
|
149
|
+
| `continueAfterArtifact?` | `boolean` | Queue the next phase prompt after the parent writes a valid artifact. `task` enables this at every parent-owned artifact boundary (plan, reuse, handoff) so the workflow flows automatically into the loop. |
|
|
149
150
|
|
|
150
151
|
### Adapter options
|
|
151
152
|
|
|
@@ -169,7 +170,7 @@ Runs are **never** auto-resumed on session start. At most one run can be attache
|
|
|
169
170
|
- `/workflow-prototype resume`, `/workflow-quick resume`, or `/workflow-task resume` lists active runs (and offers a UI picker when available).
|
|
170
171
|
- `/workflow-prototype help`, `/workflow-quick help`, or `/workflow-task help` shows the start, resume, continue, and explicit-completion lifecycle.
|
|
171
172
|
|
|
172
|
-
Resume validates the selected file is under the artifacts base, belongs to that mode, is active, and matches its containing directory. It reconciles the current expected artifact once before emitting the current prompt, covering a crash after `write_workflow_artifact` writes the file but before the phase-state write. Artifact writes do not inject the next phase prompt or launch the next subagent; they terminate the parent turn and wait for the user to continue. A mode can opt out of that pause after a parent artifact write; `task` does so
|
|
173
|
+
Resume validates the selected file is under the artifacts base, belongs to that mode, is active, and matches its containing directory. It reconciles the current expected artifact once before emitting the current prompt, covering a crash after `write_workflow_artifact` writes the file but before the phase-state write. Artifact writes do not inject the next phase prompt or launch the next subagent; they terminate the parent turn and wait for the user to continue. A mode can opt out of that pause after a parent artifact write; `task` does so at every parent-owned artifact boundary (`plan.md`, `reuse.md`, `handoff.md`) so the build loop starts immediately after a valid handoff. Corrupt records are skipped during discovery.
|
|
173
174
|
|
|
174
175
|
A valid terminal artifact makes a workflow **terminal-ready**; it does not complete the run. Call the mode-specific `end_*_workflow` tool to write `status: "completed"`, append the done entry, and terminate. This is the only completion path.
|
|
175
176
|
|