@plainconceptsplatform/workflows 0.4.32 → 0.4.34
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/README.md +1 -1
- package/dist/action-validation.test.d.ts +1 -0
- package/dist/action-validation.test.js +84 -0
- package/dist/catalog-installation.js +30 -2
- package/dist/catalog-installation.test.js +17 -0
- package/dist/catalog-listing.js +6 -4
- package/dist/catalog-listing.test.js +15 -2
- package/dist/index.js +2 -1
- package/dist/index.test.js +7 -7
- package/dist/route-processing.test.js +12 -4
- package/dist/tui.test.js +8 -8
- package/dist/workflow-catalog.d.ts +3 -2
- package/dist/workflow-catalog.js +6 -0
- package/dist/workflow-catalog.test.js +1 -1
- package/loops/actions/classify-route/action.yml +3 -0
- package/loops/actions/classify-route/classify-route.sh +38 -3
- package/loops/actions/load-issue-context/action.yml +2 -0
- package/loops/actions/validate-merge-gate-output/action.yml +40 -0
- package/loops/actions/validate-merge-gate-output/validate-merge-gate-output.sh +34 -0
- package/loops/actions/validate-review-output/action.yml +35 -0
- package/loops/actions/validate-review-output/validate-review-output.sh +30 -0
- package/loops/actions/validate-triage-output/action.yml +36 -0
- package/loops/actions/validate-triage-output/validate-triage-output.sh +36 -0
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +32 -2
- package/loops/templates/issues/bug_report.yml +109 -0
- package/loops/templates/issues/feature_request.yml +75 -0
- package/loops/workflows/agent-apply-review.md +89 -19
- package/loops/workflows/agent-audit.md +2 -2
- package/loops/workflows/agent-direct.md +14 -2
- package/loops/workflows/agent-implement.md +56 -16
- package/loops/workflows/agent-merge-gate.md +159 -60
- package/loops/workflows/agent-propose.md +1 -1
- package/loops/workflows/agent-refine.md +45 -6
- package/loops/workflows/agent-triage.md +439 -0
- package/loops/workflows/authorize-bot-work.yml +2 -1
- package/loops/workflows/work-router.yml +89 -8
- package/package.json +44 -43
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ The TUI lists all routes and templates with install status. Arrow keys navigate,
|
|
|
14
14
|
|
|
15
15
|
## Install
|
|
16
16
|
|
|
17
|
-
Before installing workflows, install and configure [`PlainConceptsPlatform/
|
|
17
|
+
Before installing workflows, install and configure [`PlainConceptsPlatform/agent-harness`](https://github.com/PlainConceptsPlatform/agent-harness) in the consumer repository. Loop workers invoke the skills and commands it provides. Verify the required skills and commands are available before compiling workflows.
|
|
18
18
|
|
|
19
19
|
For non-interactive use (advanced):
|
|
20
20
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { parse } from "yaml";
|
|
5
|
+
import { catalogSourcePath } from "./catalog-installation.js";
|
|
6
|
+
async function collectActionYmlFiles(actionsDir) {
|
|
7
|
+
const entries = await readdir(actionsDir, { withFileTypes: true });
|
|
8
|
+
const results = [];
|
|
9
|
+
for (const entry of entries) {
|
|
10
|
+
if (entry.isDirectory()) {
|
|
11
|
+
for (const sub of await readdir(join(actionsDir, entry.name), { withFileTypes: true })) {
|
|
12
|
+
if (sub.isFile() && sub.name === "action.yml") {
|
|
13
|
+
results.push(join(actionsDir, entry.name, sub.name));
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return results;
|
|
19
|
+
}
|
|
20
|
+
async function loadManifests() {
|
|
21
|
+
const actionsDir = join(catalogSourcePath(), "actions");
|
|
22
|
+
const files = await collectActionYmlFiles(actionsDir);
|
|
23
|
+
return Promise.all(files.map(async (file) => {
|
|
24
|
+
const content = await readFile(file, "utf8");
|
|
25
|
+
return { file, content, manifest: parse(content) };
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
describe("action.yml manifest validation", () => {
|
|
29
|
+
it("finds action.yml files under loops/actions/", async () => {
|
|
30
|
+
const manifests = await loadManifests();
|
|
31
|
+
expect(manifests.length).toBeGreaterThanOrEqual(1);
|
|
32
|
+
});
|
|
33
|
+
it("every action.yml parses as valid YAML", async () => {
|
|
34
|
+
for (const { file, content } of await loadManifests()) {
|
|
35
|
+
expect(() => parse(content), `${file}: failed to parse as YAML`).not.toThrow();
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
it("every action.yml has runs.using === composite", async () => {
|
|
39
|
+
for (const { file, manifest } of await loadManifests()) {
|
|
40
|
+
expect(manifest.runs?.using, `${file}: runs.using is not "composite"`).toBe("composite");
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
it("every action.yml has at least one step in runs.steps", async () => {
|
|
44
|
+
for (const { file, manifest } of await loadManifests()) {
|
|
45
|
+
expect(manifest.runs?.steps, `${file}: runs.steps is undefined`).toBeDefined();
|
|
46
|
+
expect(manifest.runs.steps.length, `${file}: runs.steps is empty — file may be truncated`).toBeGreaterThanOrEqual(1);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
it("every step with shell: also has run:", async () => {
|
|
50
|
+
for (const { file, manifest } of await loadManifests()) {
|
|
51
|
+
const steps = manifest.runs?.steps ?? [];
|
|
52
|
+
for (const [index, step] of steps.entries()) {
|
|
53
|
+
const label = step.name ?? step.id ?? `step ${index}`;
|
|
54
|
+
if (step.shell !== undefined) {
|
|
55
|
+
expect(step.run, `${file}: step "${label}" has shell: but no run:`).toBeDefined();
|
|
56
|
+
expect(typeof step.run, `${file}: step "${label}" run is not a string`).toBe("string");
|
|
57
|
+
expect(step.run.trim().length, `${file}: step "${label}" run is empty`).toBeGreaterThan(0);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
it("every step with env: also has run: or uses:", async () => {
|
|
63
|
+
for (const { file, manifest } of await loadManifests()) {
|
|
64
|
+
const steps = manifest.runs?.steps ?? [];
|
|
65
|
+
for (const [index, step] of steps.entries()) {
|
|
66
|
+
const label = step.name ?? step.id ?? `step ${index}`;
|
|
67
|
+
if (step.env !== undefined) {
|
|
68
|
+
expect(step.run !== undefined || step.uses !== undefined, `${file}: step "${label}" has env: but no run: or uses:`).toBe(true);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
it("no action.yml is truncated (last step has run: or uses: with non-empty value)", async () => {
|
|
74
|
+
for (const { file, manifest } of await loadManifests()) {
|
|
75
|
+
const steps = manifest.runs?.steps ?? [];
|
|
76
|
+
expect(steps.length, `${file}: no steps found — file may be truncated`).toBeGreaterThanOrEqual(1);
|
|
77
|
+
const lastStep = steps[steps.length - 1];
|
|
78
|
+
const hasTerminal = (lastStep.run !== undefined && lastStep.run.trim().length > 0) ||
|
|
79
|
+
(lastStep.uses !== undefined && lastStep.uses.trim().length > 0) ||
|
|
80
|
+
(lastStep.with !== undefined);
|
|
81
|
+
expect(hasTerminal, `${file}: last step appears truncated`).toBe(true);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -4,6 +4,7 @@ import { constants } from "node:fs";
|
|
|
4
4
|
import { dirname, join, relative, resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { promisify } from "node:util";
|
|
7
|
+
import { parse as parseYaml } from "yaml";
|
|
7
8
|
import { catalogTemplates, mandatoryFiles, routeNames, templateNames, workflowRoutes } from "./workflow-catalog.js";
|
|
8
9
|
import { processRoutes, excludedWorkerFiles } from "./route-processing.js";
|
|
9
10
|
import { generateOpencodeCi, generateOpencodeConfig, generateStackDefaults, injectStackEnv } from "./stack-defaults.js";
|
|
@@ -267,6 +268,31 @@ async function generatedFiles(repositoryPath) {
|
|
|
267
268
|
}
|
|
268
269
|
return updates.sort((left, right) => left.target.localeCompare(right.target));
|
|
269
270
|
}
|
|
271
|
+
function validateActionManifests(updates) {
|
|
272
|
+
for (const { target, content } of updates) {
|
|
273
|
+
if (!target.endsWith("action.yml"))
|
|
274
|
+
continue;
|
|
275
|
+
let manifest;
|
|
276
|
+
try {
|
|
277
|
+
manifest = parseYaml(content);
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
throw new Error(`${target}: invalid YAML — failed to parse action manifest`);
|
|
281
|
+
}
|
|
282
|
+
if (manifest?.runs?.using !== "composite")
|
|
283
|
+
continue;
|
|
284
|
+
const steps = manifest.runs?.steps;
|
|
285
|
+
if (!Array.isArray(steps) || steps.length === 0) {
|
|
286
|
+
throw new Error(`${target}: composite action has no steps — file may be truncated`);
|
|
287
|
+
}
|
|
288
|
+
for (const [index, step] of steps.entries()) {
|
|
289
|
+
const label = step.name ?? step.id ?? `step ${index}`;
|
|
290
|
+
if (step.shell !== undefined && (typeof step.run !== "string" || step.run.trim() === "")) {
|
|
291
|
+
throw new Error(`${target}: step "${label}" has shell: but no run: — manifest is invalid or truncated`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
270
296
|
async function applyTransaction(repositoryPath, updates) {
|
|
271
297
|
const uniqueUpdates = [...new Map(updates.map((update) => [update.target, update])).values()];
|
|
272
298
|
const rollback = await Promise.all(uniqueUpdates.map(async ({ target }) => {
|
|
@@ -275,6 +301,7 @@ async function applyTransaction(repositoryPath, updates) {
|
|
|
275
301
|
}));
|
|
276
302
|
try {
|
|
277
303
|
await writeUpdates(repositoryPath, uniqueUpdates);
|
|
304
|
+
validateActionManifests(uniqueUpdates);
|
|
278
305
|
}
|
|
279
306
|
catch (error) {
|
|
280
307
|
await Promise.all(rollback.map(async ({ target, existed, content }) => {
|
|
@@ -299,11 +326,12 @@ function catalogTemplateMeta(template) {
|
|
|
299
326
|
const entry = catalogTemplates.find((item) => item.name === template);
|
|
300
327
|
if (entry === undefined)
|
|
301
328
|
throw new Error(`Unknown template: ${template}`);
|
|
302
|
-
const directory = template.startsWith("opencode") ? "opencode" : template.startsWith("app-ci-") ? "ci" : template === "github-release" ? "release" : template === "visual-evidence" ? "visual-evidence" : "agentics";
|
|
329
|
+
const directory = template.startsWith("opencode") ? "opencode" : template.startsWith("app-ci-") ? "ci" : template === "github-release" ? "release" : template === "visual-evidence" ? "visual-evidence" : template === "bug-report" || template === "feature-request" ? "issues" : "agentics";
|
|
303
330
|
const isWorkflow = entry.file.endsWith(".yml");
|
|
304
|
-
const
|
|
331
|
+
const inferredTarget = template === "app-ci-dotnet-next"
|
|
305
332
|
? ".github/workflows/app-ci.yml"
|
|
306
333
|
: isWorkflow ? `.github/workflows/${entry.file}` : entry.file;
|
|
334
|
+
const target = entry.target ?? inferredTarget;
|
|
307
335
|
return { directory, file: entry.file, target };
|
|
308
336
|
}
|
|
309
337
|
async function catalogFiles(sourcePath) {
|
|
@@ -384,6 +384,23 @@ describe("catalog installation", () => {
|
|
|
384
384
|
installed: [".github/workflows/agentics-checks.yml"],
|
|
385
385
|
});
|
|
386
386
|
});
|
|
387
|
+
it("installs issue templates to .github/ISSUE_TEMPLATE/", async () => {
|
|
388
|
+
const sourcePath = await createDirectory({
|
|
389
|
+
"templates/issues/bug_report.yml": "name: Bug report\n",
|
|
390
|
+
"templates/issues/feature_request.yml": "name: Feature request\n",
|
|
391
|
+
});
|
|
392
|
+
const repositoryPath = await createDirectory({});
|
|
393
|
+
await expect(installTemplate(repositoryPath, "bug-report", { sourcePath })).resolves.toEqual({
|
|
394
|
+
installed: [".github/ISSUE_TEMPLATE/bug_report.yml"],
|
|
395
|
+
conflicts: [],
|
|
396
|
+
});
|
|
397
|
+
await expect(installTemplate(repositoryPath, "feature-request", { sourcePath })).resolves.toEqual({
|
|
398
|
+
installed: [".github/ISSUE_TEMPLATE/feature_request.yml"],
|
|
399
|
+
conflicts: [],
|
|
400
|
+
});
|
|
401
|
+
await expect(readFile(join(repositoryPath, ".github/ISSUE_TEMPLATE/bug_report.yml"), "utf8")).resolves.toBe("name: Bug report\n");
|
|
402
|
+
await expect(readFile(join(repositoryPath, ".github/ISSUE_TEMPLATE/feature_request.yml"), "utf8")).resolves.toBe("name: Feature request\n");
|
|
403
|
+
});
|
|
387
404
|
});
|
|
388
405
|
describe("route lifecycle", () => {
|
|
389
406
|
it("detects installed route workers in workflowRoutes order", async () => {
|
package/dist/catalog-listing.js
CHANGED
|
@@ -15,7 +15,7 @@ export async function listCatalog(options = {}) {
|
|
|
15
15
|
name: template.name,
|
|
16
16
|
description: template.description,
|
|
17
17
|
file: template.file,
|
|
18
|
-
installed: await isFileInstalled(basePath, template.file),
|
|
18
|
+
installed: await isFileInstalled(basePath, template.file, template.target),
|
|
19
19
|
})));
|
|
20
20
|
return [...routes, ...templates];
|
|
21
21
|
}
|
|
@@ -52,8 +52,8 @@ function formatEntry(entry) {
|
|
|
52
52
|
const mark = entry.installed ? "[x]" : "[ ]";
|
|
53
53
|
return ` ${mark} ${entry.name} — ${entry.description}`;
|
|
54
54
|
}
|
|
55
|
-
async function isFileInstalled(basePath, workerOrTemplateFile) {
|
|
56
|
-
const installedPath = templateInstallPath(basePath, workerOrTemplateFile);
|
|
55
|
+
async function isFileInstalled(basePath, workerOrTemplateFile, explicitTarget) {
|
|
56
|
+
const installedPath = templateInstallPath(basePath, workerOrTemplateFile, explicitTarget);
|
|
57
57
|
try {
|
|
58
58
|
await access(installedPath, constants.F_OK);
|
|
59
59
|
return true;
|
|
@@ -62,7 +62,9 @@ async function isFileInstalled(basePath, workerOrTemplateFile) {
|
|
|
62
62
|
return false;
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
|
-
function templateInstallPath(basePath, file) {
|
|
65
|
+
function templateInstallPath(basePath, file, explicitTarget) {
|
|
66
|
+
if (explicitTarget !== undefined)
|
|
67
|
+
return join(basePath, ...explicitTarget.split("/"));
|
|
66
68
|
const isRootTemplate = file.endsWith(".json");
|
|
67
69
|
return isRootTemplate ? join(basePath, file) : join(basePath, ".github", "workflows", file);
|
|
68
70
|
}
|
|
@@ -15,8 +15,8 @@ describe("catalog listing", () => {
|
|
|
15
15
|
expect(entries).toHaveLength(workflowRoutes.length + catalogTemplates.length);
|
|
16
16
|
const routeNames = entries.filter((entry) => entry.kind === "route").map((entry) => entry.name);
|
|
17
17
|
const templateNames = entries.filter((entry) => entry.kind === "template").map((entry) => entry.name);
|
|
18
|
-
expect(routeNames).toEqual(["refine", "implement", "direct", "apply-review", "merge-gate", "audit", "propose"]);
|
|
19
|
-
expect(templateNames).toEqual(["agentics-checks", "agentics-maintenance", "app-ci-dotnet-next", "app-ci-node-monorepo", "github-release", "opencode.ci.json", "visual-evidence"]);
|
|
18
|
+
expect(routeNames).toEqual(["refine", "implement", "direct", "triage", "apply-review", "merge-gate", "audit", "propose"]);
|
|
19
|
+
expect(templateNames).toEqual(["agentics-checks", "agentics-maintenance", "app-ci-dotnet-next", "app-ci-node-monorepo", "bug-report", "feature-request", "github-release", "opencode.ci.json", "visual-evidence"]);
|
|
20
20
|
});
|
|
21
21
|
it("reports all entries as not installed in an empty repository", async () => {
|
|
22
22
|
const repositoryPath = await createRepository({});
|
|
@@ -41,6 +41,19 @@ describe("catalog listing", () => {
|
|
|
41
41
|
expect(checksEntry).toBeDefined();
|
|
42
42
|
expect(checksEntry.installed).toBe(true);
|
|
43
43
|
});
|
|
44
|
+
it("marks an issue template as installed when its file exists in .github/ISSUE_TEMPLATE/", async () => {
|
|
45
|
+
const repositoryPath = await createRepository({
|
|
46
|
+
".github/ISSUE_TEMPLATE/bug_report.yml": "name: Bug report",
|
|
47
|
+
".github/ISSUE_TEMPLATE/feature_request.yml": "name: Feature request",
|
|
48
|
+
});
|
|
49
|
+
const entries = await listCatalog({ installedPath: repositoryPath });
|
|
50
|
+
const bugEntry = entries.find((entry) => entry.name === "bug-report");
|
|
51
|
+
const featureEntry = entries.find((entry) => entry.name === "feature-request");
|
|
52
|
+
expect(bugEntry).toBeDefined();
|
|
53
|
+
expect(bugEntry.installed).toBe(true);
|
|
54
|
+
expect(featureEntry).toBeDefined();
|
|
55
|
+
expect(featureEntry.installed).toBe(true);
|
|
56
|
+
});
|
|
44
57
|
it("marks the opencode.ci.json template as installed when the file exists at repository root", async () => {
|
|
45
58
|
const repositoryPath = await createRepository({
|
|
46
59
|
"opencode.ci.json": "{ \"model\": \"plainconcepts/glm-5-2\" }",
|
package/dist/index.js
CHANGED
|
@@ -28,7 +28,7 @@ Commands:
|
|
|
28
28
|
search <query> Filter workflows and templates by name or description.
|
|
29
29
|
|
|
30
30
|
Route names (positional arguments to add):
|
|
31
|
-
refine, implement, direct, apply-review, merge-gate, audit, propose
|
|
31
|
+
refine, implement, direct, triage, apply-review, merge-gate, audit, propose
|
|
32
32
|
|
|
33
33
|
add Mandatory files only (opencode.ci.json, compile script,
|
|
34
34
|
shared imports, actions, router, classifier, route matrix).
|
|
@@ -48,6 +48,7 @@ Options:
|
|
|
48
48
|
--template <name> Install a standalone template alongside or instead of routes.
|
|
49
49
|
Templates: agentics-checks, agentics-maintenance,
|
|
50
50
|
app-ci-dotnet-next, app-ci-node-monorepo,
|
|
51
|
+
bug-report, feature-request, github-release,
|
|
51
52
|
opencode.ci.json.
|
|
52
53
|
--force Overwrite managed files that differ from the package source.
|
|
53
54
|
-h, --help Show this help text.
|
package/dist/index.test.js
CHANGED
|
@@ -27,20 +27,20 @@ describe("workflows CLI", () => {
|
|
|
27
27
|
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
28
28
|
await expect(run(["--help"])).resolves.toBe(0);
|
|
29
29
|
const output = log.mock.calls[0][0];
|
|
30
|
-
expect(output).toContain("refine, implement, direct, apply-review, merge-gate, audit, propose");
|
|
30
|
+
expect(output).toContain("refine, implement, direct, triage, apply-review, merge-gate, audit, propose");
|
|
31
31
|
expect(output).toContain("add [routes]");
|
|
32
32
|
log.mockRestore();
|
|
33
33
|
});
|
|
34
34
|
it("rejects an unsupported template name", async () => {
|
|
35
35
|
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
36
36
|
await expect(run(["add", "--template", "unknown"])).resolves.toBe(1);
|
|
37
|
-
expect(error).toHaveBeenCalledWith("--template must be one of: agentics-checks|agentics-maintenance|app-ci-dotnet-next|app-ci-node-monorepo|github-release|opencode.ci.json|visual-evidence.");
|
|
37
|
+
expect(error).toHaveBeenCalledWith("--template must be one of: agentics-checks|agentics-maintenance|app-ci-dotnet-next|app-ci-node-monorepo|bug-report|feature-request|github-release|opencode.ci.json|visual-evidence.");
|
|
38
38
|
error.mockRestore();
|
|
39
39
|
});
|
|
40
40
|
it("rejects an unknown route passed to add", async () => {
|
|
41
41
|
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
42
42
|
await expect(run(["add", "frobnicate"])).resolves.toBe(1);
|
|
43
|
-
expect(error).toHaveBeenCalledWith("Unknown route: frobnicate. Valid routes: refine, implement, direct, apply-review, merge-gate, audit, propose.");
|
|
43
|
+
expect(error).toHaveBeenCalledWith("Unknown route: frobnicate. Valid routes: refine, implement, direct, triage, apply-review, merge-gate, audit, propose.");
|
|
44
44
|
error.mockRestore();
|
|
45
45
|
});
|
|
46
46
|
it("rejects a duplicate route", async () => {
|
|
@@ -65,9 +65,9 @@ describe("workflows CLI", () => {
|
|
|
65
65
|
// None installed: all [ ]
|
|
66
66
|
const installedCount = (output.match(/\[x\]/g) ?? []).length;
|
|
67
67
|
expect(installedCount).toBe(0);
|
|
68
|
-
//
|
|
68
|
+
// 8 routes + 9 templates = 17 entries
|
|
69
69
|
const uninstalledCount = (output.match(/\[ \]/g) ?? []).length;
|
|
70
|
-
expect(uninstalledCount).toBe(
|
|
70
|
+
expect(uninstalledCount).toBe(17);
|
|
71
71
|
log.mockRestore();
|
|
72
72
|
});
|
|
73
73
|
it("marks installed workflows with [x]", async () => {
|
|
@@ -92,7 +92,7 @@ describe("workflows CLI", () => {
|
|
|
92
92
|
expect(output).toContain("audit");
|
|
93
93
|
expect(output).not.toContain("Templates:");
|
|
94
94
|
// Only the audit route should be returned — no other route name should appear.
|
|
95
|
-
const visibleRoutes = ["refine", "implement", "direct", "apply-review", "merge-gate", "propose"];
|
|
95
|
+
const visibleRoutes = ["refine", "implement", "direct", "triage", "apply-review", "merge-gate", "propose"];
|
|
96
96
|
for (const route of visibleRoutes) {
|
|
97
97
|
expect(output).not.toContain(`${route} —`);
|
|
98
98
|
}
|
|
@@ -104,7 +104,7 @@ describe("workflows CLI", () => {
|
|
|
104
104
|
const output = log.mock.calls[0][0];
|
|
105
105
|
expect(output).toContain("dotnet-next");
|
|
106
106
|
expect(output).toContain("node-monorepo");
|
|
107
|
-
expect(output).not.toContain("refine");
|
|
107
|
+
expect(output).not.toContain("refine —");
|
|
108
108
|
log.mockRestore();
|
|
109
109
|
});
|
|
110
110
|
it("search with no matches prints a no-match message", async () => {
|
|
@@ -23,6 +23,7 @@ on:
|
|
|
23
23
|
- refine
|
|
24
24
|
- implement
|
|
25
25
|
- direct
|
|
26
|
+
- triage
|
|
26
27
|
- apply-review
|
|
27
28
|
- merge-gate
|
|
28
29
|
- audit
|
|
@@ -48,6 +49,13 @@ jobs:
|
|
|
48
49
|
secrets:
|
|
49
50
|
OPENAI_API_KEY: \${{ secrets.OPENAI_API_KEY }}
|
|
50
51
|
|
|
52
|
+
call-triage:
|
|
53
|
+
needs: [classify, authorize]
|
|
54
|
+
if: needs.classify.outputs.route == 'triage' && needs.authorize.outputs.is_outside_collaborator == 'true'
|
|
55
|
+
uses: ./.github/workflows/agent-triage.lock.yml
|
|
56
|
+
secrets:
|
|
57
|
+
OPENAI_API_KEY: \${{ secrets.OPENAI_API_KEY }}
|
|
58
|
+
|
|
51
59
|
call-audit:
|
|
52
60
|
needs: classify
|
|
53
61
|
if: needs.classify.outputs.route == 'audit'
|
|
@@ -134,7 +142,7 @@ const MATRIX_SH = `#!/usr/bin/env bash
|
|
|
134
142
|
set -euo pipefail
|
|
135
143
|
|
|
136
144
|
echo "── Router wiring ─────────────────────────────────────────────────────────"
|
|
137
|
-
for route in refine implement direct apply-review merge-gate audit propose bot-approve \\
|
|
145
|
+
for route in refine implement direct triage apply-review merge-gate audit propose bot-approve \\
|
|
138
146
|
audit-close cleanup-artifacts reconcile-bot-pr-runs stale-recovery validate; do
|
|
139
147
|
if grep -q "route == '\${route}'" "\$ROUTER_YML"; then
|
|
140
148
|
PASS=\$((PASS + 1))
|
|
@@ -266,7 +274,7 @@ describe("processRoutes", () => {
|
|
|
266
274
|
expect(classifier).toBe(CLASSIFIER_SH);
|
|
267
275
|
const matrix = result.get(".github/actions/verify-route-matrix/verify-route-matrix.sh");
|
|
268
276
|
expect(matrix).toContain("Route matrix: selected routes valid");
|
|
269
|
-
expect(matrix).toContain("for route in refine implement direct apply-review merge-gate audit propose");
|
|
277
|
+
expect(matrix).toContain("for route in refine implement direct triage apply-review merge-gate audit propose");
|
|
270
278
|
});
|
|
271
279
|
it("strips propose from all three files when unselected", () => {
|
|
272
280
|
const files = new Map([
|
|
@@ -283,7 +291,7 @@ describe("processRoutes", () => {
|
|
|
283
291
|
const classifier = result.get(".github/actions/classify-route/classify-route.sh");
|
|
284
292
|
expect(classifier).toBe(CLASSIFIER_SH);
|
|
285
293
|
const matrix = result.get(".github/actions/verify-route-matrix/verify-route-matrix.sh");
|
|
286
|
-
expect(matrix).toContain("for route in refine implement direct apply-review merge-gate audit");
|
|
294
|
+
expect(matrix).toContain("for route in refine implement direct triage apply-review merge-gate audit");
|
|
287
295
|
expect(matrix).toContain("for route in propose");
|
|
288
296
|
});
|
|
289
297
|
it("strips audit from all three files when unselected", () => {
|
|
@@ -330,7 +338,7 @@ describe("excludedWorkerFiles", () => {
|
|
|
330
338
|
});
|
|
331
339
|
it("returns all worker files when no routes are selected", () => {
|
|
332
340
|
const excluded = excludedWorkerFiles([]);
|
|
333
|
-
expect(excluded.size).toBe(
|
|
341
|
+
expect(excluded.size).toBe(8);
|
|
334
342
|
expect(excluded.has("agent-refine.md")).toBe(true);
|
|
335
343
|
expect(excluded.has("agent-implement.md")).toBe(true);
|
|
336
344
|
expect(excluded.has("agent-direct.md")).toBe(true);
|
package/dist/tui.test.js
CHANGED
|
@@ -10,7 +10,7 @@ function makeEntry(name, kind, installed = false) {
|
|
|
10
10
|
};
|
|
11
11
|
}
|
|
12
12
|
function makeEntries(installed = []) {
|
|
13
|
-
const routes = ["refine", "implement", "direct", "apply-review", "merge-gate", "audit", "propose"];
|
|
13
|
+
const routes = ["refine", "implement", "direct", "triage", "apply-review", "merge-gate", "audit", "propose"];
|
|
14
14
|
const templates = ["agentics-checks", "agentics-maintenance", "app-ci-dotnet-next", "app-ci-node-monorepo", "opencode.ci.json"];
|
|
15
15
|
return [
|
|
16
16
|
...routes.map((name) => makeEntry(name, "route", installed.includes(name))),
|
|
@@ -21,8 +21,8 @@ describe("createSelectionState", () => {
|
|
|
21
21
|
it("returns all items as visible initially", () => {
|
|
22
22
|
const entries = makeEntries();
|
|
23
23
|
const state = createSelectionState(entries);
|
|
24
|
-
expect(state.allItems).toHaveLength(
|
|
25
|
-
expect(state.visibleItems).toHaveLength(
|
|
24
|
+
expect(state.allItems).toHaveLength(13);
|
|
25
|
+
expect(state.visibleItems).toHaveLength(13);
|
|
26
26
|
});
|
|
27
27
|
it("pre-selects installed items", () => {
|
|
28
28
|
const entries = makeEntries(["refine", "agentics-checks"]);
|
|
@@ -73,7 +73,7 @@ describe("fuzzyMatch", () => {
|
|
|
73
73
|
describe("filterItems", () => {
|
|
74
74
|
const entries = makeEntries();
|
|
75
75
|
it("returns all items when filter is empty", () => {
|
|
76
|
-
expect(filterItems(entries, "")).toHaveLength(
|
|
76
|
+
expect(filterItems(entries, "")).toHaveLength(13);
|
|
77
77
|
});
|
|
78
78
|
it("filters by name", () => {
|
|
79
79
|
const result = filterItems(entries, "refine");
|
|
@@ -82,7 +82,7 @@ describe("filterItems", () => {
|
|
|
82
82
|
});
|
|
83
83
|
it("filters by description", () => {
|
|
84
84
|
const result = filterItems(entries, "description");
|
|
85
|
-
expect(result).toHaveLength(
|
|
85
|
+
expect(result).toHaveLength(13);
|
|
86
86
|
});
|
|
87
87
|
it("filters by fuzzy subsequence over name and description", () => {
|
|
88
88
|
const result = filterItems(entries, "acdn");
|
|
@@ -125,7 +125,7 @@ describe("clearFilter", () => {
|
|
|
125
125
|
let state = applyFilter(createSelectionState(makeEntries()), "ref");
|
|
126
126
|
state = clearFilter(state);
|
|
127
127
|
expect(state.filter).toBe("");
|
|
128
|
-
expect(state.visibleItems).toHaveLength(
|
|
128
|
+
expect(state.visibleItems).toHaveLength(13);
|
|
129
129
|
});
|
|
130
130
|
});
|
|
131
131
|
describe("moveCursorUp / moveCursorDown", () => {
|
|
@@ -150,7 +150,7 @@ describe("moveCursorUp / moveCursorDown", () => {
|
|
|
150
150
|
});
|
|
151
151
|
it("wraps cursor to bottom from top", () => {
|
|
152
152
|
const state = moveCursorUp(createSelectionState(makeEntries()));
|
|
153
|
-
expect(state.cursor).toBe(
|
|
153
|
+
expect(state.cursor).toBe(12);
|
|
154
154
|
});
|
|
155
155
|
it("does not move when list is empty", () => {
|
|
156
156
|
const entries = [];
|
|
@@ -236,7 +236,7 @@ describe("getItemsToInstall", () => {
|
|
|
236
236
|
it("includes both routes and templates", () => {
|
|
237
237
|
let state = createSelectionState(makeEntries());
|
|
238
238
|
state = toggleSelection(state);
|
|
239
|
-
state = { ...state, cursor:
|
|
239
|
+
state = { ...state, cursor: 8 };
|
|
240
240
|
state = toggleSelection(state);
|
|
241
241
|
const items = getItemsToInstall(state);
|
|
242
242
|
const kinds = items.map((e) => e.kind);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const routeNames: readonly ["refine", "implement", "direct", "apply-review", "merge-gate", "audit", "propose"];
|
|
1
|
+
export declare const routeNames: readonly ["refine", "implement", "direct", "triage", "apply-review", "merge-gate", "audit", "propose"];
|
|
2
2
|
export type RouteName = (typeof routeNames)[number];
|
|
3
3
|
export interface WorkflowRoute {
|
|
4
4
|
readonly name: RouteName;
|
|
@@ -14,11 +14,12 @@ export interface MandatoryFile {
|
|
|
14
14
|
}
|
|
15
15
|
export declare const mandatoryFiles: readonly MandatoryFile[];
|
|
16
16
|
export declare const generatedConsumerTargets: readonly [".github/workflows/agent-*.lock.yml", ".github/aw/actions-lock.json"];
|
|
17
|
-
export declare const templateNames: readonly ["agentics-checks", "agentics-maintenance", "app-ci-dotnet-next", "app-ci-node-monorepo", "github-release", "opencode.ci.json", "visual-evidence"];
|
|
17
|
+
export declare const templateNames: readonly ["agentics-checks", "agentics-maintenance", "app-ci-dotnet-next", "app-ci-node-monorepo", "bug-report", "feature-request", "github-release", "opencode.ci.json", "visual-evidence"];
|
|
18
18
|
export type TemplateName = (typeof templateNames)[number];
|
|
19
19
|
export interface CatalogTemplate {
|
|
20
20
|
readonly name: TemplateName;
|
|
21
21
|
readonly file: string;
|
|
22
22
|
readonly description: string;
|
|
23
|
+
readonly target?: string;
|
|
23
24
|
}
|
|
24
25
|
export declare const catalogTemplates: readonly CatalogTemplate[];
|
package/dist/workflow-catalog.js
CHANGED
|
@@ -2,6 +2,7 @@ export const routeNames = [
|
|
|
2
2
|
"refine",
|
|
3
3
|
"implement",
|
|
4
4
|
"direct",
|
|
5
|
+
"triage",
|
|
5
6
|
"apply-review",
|
|
6
7
|
"merge-gate",
|
|
7
8
|
"audit",
|
|
@@ -11,6 +12,7 @@ export const workflowRoutes = [
|
|
|
11
12
|
{ name: "refine", worker: "agent-refine.md", description: "Refines an issue into a user story, on a first pass or after the author has answered the bot's questions.", defaultEnabled: true },
|
|
12
13
|
{ name: "implement", worker: "agent-implement.md", description: "Implements an issue and opens a pull request. Stops there: the merge decision belongs to the merge gate.", defaultEnabled: true },
|
|
13
14
|
{ name: "direct", worker: "agent-direct.md", description: "Executes a free-form instruction from an issue body and posts the results back on the same issue.", defaultEnabled: true },
|
|
15
|
+
{ name: "triage", worker: "agent-triage.md", description: "Triages issues opened by outside collaborators: runs 10 checks (template, security, size, danger, duplicates, clarity, reproducibility, acceptance, cross-cutting). Loops up to 3 rounds. Passes to refine or blocks.", defaultEnabled: true },
|
|
14
16
|
{ name: "apply-review", worker: "agent-apply-review.md", description: "Applies reviewer feedback to an open pull request the bot authored, then pushes the fixes to the same branch.", defaultEnabled: true },
|
|
15
17
|
{ name: "merge-gate", worker: "agent-merge-gate.md", description: "Decides what happens to a bot-authored pull request once CI has reported: merge, hand to a human, or fix CI.", defaultEnabled: true },
|
|
16
18
|
{ name: "audit", worker: "agent-audit.md", description: "Read-only repository audit. Finds 5-7 problems, scores each 1-10, files a single issue with the top 3 refined as actionable user stories.", defaultEnabled: true },
|
|
@@ -38,6 +40,8 @@ export const templateNames = [
|
|
|
38
40
|
"agentics-maintenance",
|
|
39
41
|
"app-ci-dotnet-next",
|
|
40
42
|
"app-ci-node-monorepo",
|
|
43
|
+
"bug-report",
|
|
44
|
+
"feature-request",
|
|
41
45
|
"github-release",
|
|
42
46
|
"opencode.ci.json",
|
|
43
47
|
"visual-evidence",
|
|
@@ -47,6 +51,8 @@ export const catalogTemplates = [
|
|
|
47
51
|
{ name: "agentics-maintenance", file: "agentics-maintenance.yml", description: "Agentic maintenance: scheduled daily maintenance workflow for keeping workflows and actions up to date." },
|
|
48
52
|
{ name: "app-ci-dotnet-next", file: "app-ci-dotnet-next.yml", description: "App CI pipeline for a .NET + Next.js monorepo: build, test, and lint on PRs and schedule." },
|
|
49
53
|
{ name: "app-ci-node-monorepo", file: "app-ci-node-monorepo.yml", description: "App CI pipeline for a Node monorepo: build, test, and lint on PRs and schedule." },
|
|
54
|
+
{ name: "bug-report", file: "bug_report.yml", description: "Bug report issue template: what happened, repro steps, expected behavior, acceptance criteria, environment, logs.", target: ".github/ISSUE_TEMPLATE/bug_report.yml" },
|
|
55
|
+
{ name: "feature-request", file: "feature_request.yml", description: "Feature request issue template scoped to small, well-scoped improvements (Small/Medium only; large work belongs in a planning issue).", target: ".github/ISSUE_TEMPLATE/feature_request.yml" },
|
|
50
56
|
{ name: "github-release", file: "github-release.yml", description: "Publishes or updates a GitHub Release with generated notes whenever a v* tag is pushed." },
|
|
51
57
|
{ name: "opencode.ci.json", file: "opencode.ci.json", description: "Standalone OpenCode CI config: plainconcepts provider, GLM model registration, ci-workflow-agent, and LSP defaults for consumer repositories." },
|
|
52
58
|
{ name: "visual-evidence", file: "visual-evidence.yml", description: "Visual evidence: captures screenshots of UI changes on bot-authored PRs by reading the capturePlan left by the agent in evidence.json and executing it on a runner with Docker and Chrome access." },
|
|
@@ -17,7 +17,7 @@ describe("workflow catalog", () => {
|
|
|
17
17
|
}
|
|
18
18
|
});
|
|
19
19
|
it("lists supported optional templates", () => {
|
|
20
|
-
expect(templateNames).toEqual(["agentics-checks", "agentics-maintenance", "app-ci-dotnet-next", "app-ci-node-monorepo", "github-release", "opencode.ci.json", "visual-evidence"]);
|
|
20
|
+
expect(templateNames).toEqual(["agentics-checks", "agentics-maintenance", "app-ci-dotnet-next", "app-ci-node-monorepo", "bug-report", "feature-request", "github-release", "opencode.ci.json", "visual-evidence"]);
|
|
21
21
|
});
|
|
22
22
|
it("gives every catalog template a non-empty description and file", () => {
|
|
23
23
|
expect(catalogTemplates.map((template) => template.name)).toEqual([...templateNames]);
|
|
@@ -30,6 +30,9 @@ outputs:
|
|
|
30
30
|
direct-mode:
|
|
31
31
|
description: first or continue.
|
|
32
32
|
value: ${{ steps.classify.outputs.direct-mode }}
|
|
33
|
+
triage-mode:
|
|
34
|
+
description: first or retriage.
|
|
35
|
+
value: ${{ steps.classify.outputs.triage-mode }}
|
|
33
36
|
trigger-kind:
|
|
34
37
|
description: scheduled or manual.
|
|
35
38
|
value: ${{ steps.classify.outputs.trigger-kind }}
|
|
@@ -11,7 +11,7 @@ set -euo pipefail
|
|
|
11
11
|
readonly AUDIT_CRON="17 1 * * 1"
|
|
12
12
|
readonly AUDIT_CLOSE_CRON="43 3 * * *"
|
|
13
13
|
readonly CLEANUP_ARTIFACTS_CRON="0 6 * * *"
|
|
14
|
-
readonly RECONCILE_BOT_PR_RUNS_CRON="*/
|
|
14
|
+
readonly RECONCILE_BOT_PR_RUNS_CRON="17 */2 * * *"
|
|
15
15
|
# Daily, but it proposes far less often than daily: the worker holds one open
|
|
16
16
|
# proposal at a time and skips while that slot is filled. The cron is a heartbeat,
|
|
17
17
|
# the queue is the pacing.
|
|
@@ -28,11 +28,18 @@ is_issue_number() {
|
|
|
28
28
|
classify_route() {
|
|
29
29
|
local route="none" error=""
|
|
30
30
|
local issue_number="" pr_number="" ci_conclusion="" ci_run_id=""
|
|
31
|
-
local refine_mode="" direct_mode="" trigger_kind=""
|
|
31
|
+
local refine_mode="" direct_mode="" triage_mode="" trigger_kind=""
|
|
32
32
|
|
|
33
33
|
case "${EVENT:-}" in
|
|
34
34
|
issues)
|
|
35
|
-
if [ "${ACTION:-}" = "
|
|
35
|
+
if [ "${ACTION:-}" = "opened" ]; then
|
|
36
|
+
# Issue opened by an outside collaborator → triage. The authorize job
|
|
37
|
+
# gates the caller on is_outside_collaborator; this routes unconditionally
|
|
38
|
+
# so write+ openers classify to triage but the caller job skips them.
|
|
39
|
+
route="triage"
|
|
40
|
+
triage_mode="first"
|
|
41
|
+
issue_number="${EVENT_ISSUE_NUMBER:-}"
|
|
42
|
+
elif [ "${ACTION:-}" = "labeled" ]; then
|
|
36
43
|
case "${LABEL:-}" in
|
|
37
44
|
bot-working)
|
|
38
45
|
# Bot adds bot-working → route based on which work label is present
|
|
@@ -54,6 +61,20 @@ classify_route() {
|
|
|
54
61
|
error="bot-working added but no work label (implement/refine/direct) found"
|
|
55
62
|
fi
|
|
56
63
|
;;
|
|
64
|
+
triage)
|
|
65
|
+
# A maintainer can explicitly re-run triage by adding this label. The
|
|
66
|
+
# triage worker adds it after claiming the issue, so bot label events
|
|
67
|
+
# and an existing claim must not start a second worker.
|
|
68
|
+
if [ "${ACTOR:-}" != "" ] && echo "${ACTOR:-}" | grep -q '\[bot\]$'; then
|
|
69
|
+
error="bot-added triage label does not re-trigger triage"
|
|
70
|
+
elif has_label bot-working; then
|
|
71
|
+
error="issue already has bot-working label; triage already in progress"
|
|
72
|
+
else
|
|
73
|
+
route="triage"
|
|
74
|
+
issue_number="${EVENT_ISSUE_NUMBER:-}"
|
|
75
|
+
triage_mode="first"
|
|
76
|
+
fi
|
|
77
|
+
;;
|
|
57
78
|
refine | implement | direct)
|
|
58
79
|
# If the actor is a bot (e.g. refine→implement transition), route directly.
|
|
59
80
|
# If the actor is a human, authorize-bot-work.yml will add bot-working which triggers the workflow.
|
|
@@ -83,6 +104,10 @@ classify_route() {
|
|
|
83
104
|
pr_number="${EVENT_ISSUE_NUMBER:-}"
|
|
84
105
|
elif [ "${COMMENT_SENDER_TYPE:-}" = "Bot" ]; then
|
|
85
106
|
error="comment authored by a bot"
|
|
107
|
+
elif has_label triage; then
|
|
108
|
+
route="triage"
|
|
109
|
+
triage_mode="retriage"
|
|
110
|
+
issue_number="${EVENT_ISSUE_NUMBER:-}"
|
|
86
111
|
elif has_label implement; then
|
|
87
112
|
error="issue has implement label; comments do not re-trigger implement"
|
|
88
113
|
elif has_label direct; then
|
|
@@ -155,6 +180,15 @@ classify_route() {
|
|
|
155
180
|
error="operation '${OPERATION}' needs a positive issue-number, got '${INPUT_ISSUE_NUMBER:-}'"
|
|
156
181
|
fi
|
|
157
182
|
;;
|
|
183
|
+
triage)
|
|
184
|
+
if is_issue_number "${INPUT_ISSUE_NUMBER:-}"; then
|
|
185
|
+
route="triage"
|
|
186
|
+
issue_number="${INPUT_ISSUE_NUMBER}"
|
|
187
|
+
triage_mode="${INPUT_MODE:-first}"
|
|
188
|
+
else
|
|
189
|
+
error="operation 'triage' needs a positive issue-number, got '${INPUT_ISSUE_NUMBER:-}'"
|
|
190
|
+
fi
|
|
191
|
+
;;
|
|
158
192
|
apply-review)
|
|
159
193
|
if is_issue_number "${INPUT_PR_NUMBER:-}"; then
|
|
160
194
|
route="apply-review"
|
|
@@ -199,6 +233,7 @@ ci-conclusion=${ci_conclusion}
|
|
|
199
233
|
ci-run-id=${ci_run_id}
|
|
200
234
|
refine-mode=${refine_mode}
|
|
201
235
|
direct-mode=${direct_mode}
|
|
236
|
+
triage-mode=${triage_mode}
|
|
202
237
|
trigger-kind=${trigger_kind}
|
|
203
238
|
error=${error}
|
|
204
239
|
EOF
|
|
@@ -34,6 +34,8 @@ runs:
|
|
|
34
34
|
number: issue.data.number,
|
|
35
35
|
title: issue.data.title,
|
|
36
36
|
body: issue.data.body,
|
|
37
|
+
author: issue.data.user.login,
|
|
38
|
+
authorAssociation: issue.data.author_association,
|
|
37
39
|
labels: issue.data.labels.map(({ name }) => name),
|
|
38
40
|
comments: comments.map(({ user, author_association, body }) => ({
|
|
39
41
|
author: user.login,
|