@plainconceptsplatform/workflows 0.4.33 → 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 +27 -0
- package/dist/catalog-listing.test.js +1 -1
- package/dist/index.js +1 -1
- package/dist/index.test.js +6 -6
- package/dist/route-processing.test.js +12 -4
- package/dist/tui.test.js +8 -8
- package/dist/workflow-catalog.d.ts +1 -1
- package/dist/workflow-catalog.js +2 -0
- 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/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 +43 -42
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 }) => {
|
|
@@ -15,7 +15,7 @@ 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"]);
|
|
18
|
+
expect(routeNames).toEqual(["refine", "implement", "direct", "triage", "apply-review", "merge-gate", "audit", "propose"]);
|
|
19
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 () => {
|
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).
|
package/dist/index.test.js
CHANGED
|
@@ -27,7 +27,7 @@ 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
|
});
|
|
@@ -40,7 +40,7 @@ describe("workflows CLI", () => {
|
|
|
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;
|
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 },
|
|
@@ -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,
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Managed by @plainconceptsplatform/workflows. Source: loops/actions/validate-merge-gate-output/action.yml. Update with workflows update --force; consumer edits may be overwritten.
|
|
2
|
+
name: Validate merge-gate output
|
|
3
|
+
description: Verify agent_output.json contains a usable merge-gate verdict before any state changes.
|
|
4
|
+
inputs:
|
|
5
|
+
output-file:
|
|
6
|
+
description: Path to agent_output.json.
|
|
7
|
+
required: true
|
|
8
|
+
issue-number:
|
|
9
|
+
description: Issue number that the merge-gate outcome must target.
|
|
10
|
+
required: true
|
|
11
|
+
ci-conclusion:
|
|
12
|
+
description: CI conclusion supplied to the merge gate.
|
|
13
|
+
required: true
|
|
14
|
+
outputs:
|
|
15
|
+
valid:
|
|
16
|
+
description: Whether the output contains a usable merge-gate comment with a verdict.
|
|
17
|
+
value: ${{ steps.validate.outputs.valid }}
|
|
18
|
+
outcome:
|
|
19
|
+
description: "Deterministic outcome: merge, review, remediated, or invalid."
|
|
20
|
+
value: ${{ steps.validate.outputs.outcome }}
|
|
21
|
+
runs:
|
|
22
|
+
using: composite
|
|
23
|
+
steps:
|
|
24
|
+
- name: Validate merge-gate outcome
|
|
25
|
+
id: validate
|
|
26
|
+
shell: bash
|
|
27
|
+
env:
|
|
28
|
+
ISSUE_NUMBER: ${{ inputs.issue-number }}
|
|
29
|
+
CI_CONCLUSION: ${{ inputs.ci-conclusion }}
|
|
30
|
+
OUTPUT_FILE: ${{ inputs.output-file }}
|
|
31
|
+
run: |
|
|
32
|
+
set -euo pipefail
|
|
33
|
+
|
|
34
|
+
outcome="$(bash "${{ github.action_path }}/validate-merge-gate-output.sh" "$OUTPUT_FILE" "$ISSUE_NUMBER" "$CI_CONCLUSION")"
|
|
35
|
+
echo "outcome=$outcome" >> "$GITHUB_OUTPUT"
|
|
36
|
+
echo "valid=$([ "$outcome" != 'invalid' ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
|
|
37
|
+
|
|
38
|
+
if [ "$outcome" = 'invalid' ]; then
|
|
39
|
+
echo "::warning::Agent output has no usable merge-gate outcome. It will not be applied."
|
|
40
|
+
fi
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Managed by @plainconceptsplatform/workflows. Source: loops/actions/validate-merge-gate-output/validate-merge-gate-output.sh. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
3
|
+
# Print the deterministic merge-gate outcome: merge, review, remediated, or invalid.
|
|
4
|
+
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
output_file="$1"
|
|
8
|
+
issue_number="$2"
|
|
9
|
+
ci_conclusion="$3"
|
|
10
|
+
|
|
11
|
+
if [ ! -f "$output_file" ] || ! jq -e '.items | arrays' "$output_file" >/dev/null 2>&1; then
|
|
12
|
+
echo invalid
|
|
13
|
+
exit 0
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
# The agent emits exactly one comment on the source issue. Its verdict tells the
|
|
17
|
+
# workflow which App-token state transition to perform.
|
|
18
|
+
jq -r --arg issue "$issue_number" --arg conclusion "$ci_conclusion" '
|
|
19
|
+
.items as $items
|
|
20
|
+
| ($items
|
|
21
|
+
| [.[] | select(.type == "add_comment" and (.item_number | tostring) == $issue and (.body | type == "string"))]
|
|
22
|
+
| map(.body |
|
|
23
|
+
if test("\\*\\*Verdict:\\*\\*\\s*(merge|review|remediated)"; "i") then
|
|
24
|
+
capture("\\*\\*Verdict:\\*\\*\\s*(?<v>merge|review|remediated)"; "i").v | ascii_downcase
|
|
25
|
+
else empty end
|
|
26
|
+
)
|
|
27
|
+
| .[0] // "invalid") as $outcome
|
|
28
|
+
| ([$items[] | select(.type == "push_to_pull_request_branch")] | length) as $pushes
|
|
29
|
+
| if $outcome == "merge" and $conclusion == "success" and $pushes == 0 then "merge"
|
|
30
|
+
elif $outcome == "remediated" and $conclusion == "failure" and $pushes == 1 then "remediated"
|
|
31
|
+
elif $outcome == "review" and $pushes == 0 then "review"
|
|
32
|
+
else "invalid"
|
|
33
|
+
end
|
|
34
|
+
' "$output_file"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Managed by @plainconceptsplatform/workflows. Source: loops/actions/validate-review-output/action.yml. Update with workflows update --force; consumer edits may be overwritten.
|
|
2
|
+
name: Validate review output
|
|
3
|
+
description: Verify an apply-review outcome accounts for every unresolved review thread.
|
|
4
|
+
inputs:
|
|
5
|
+
output-file:
|
|
6
|
+
description: Path to agent_output.json.
|
|
7
|
+
required: true
|
|
8
|
+
pr-number:
|
|
9
|
+
description: Pull request receiving the review feedback.
|
|
10
|
+
required: true
|
|
11
|
+
review-threads-file:
|
|
12
|
+
description: JSON file containing unresolved review threads.
|
|
13
|
+
required: true
|
|
14
|
+
outputs:
|
|
15
|
+
valid:
|
|
16
|
+
description: Whether the agent output is a valid review outcome.
|
|
17
|
+
value: ${{ steps.validate.outputs.valid }}
|
|
18
|
+
outcome:
|
|
19
|
+
description: "Deterministic review outcome: implemented, already-satisfied, needs-human, or invalid."
|
|
20
|
+
value: ${{ steps.validate.outputs.outcome }}
|
|
21
|
+
runs:
|
|
22
|
+
using: composite
|
|
23
|
+
steps:
|
|
24
|
+
- name: Validate review outcome
|
|
25
|
+
id: validate
|
|
26
|
+
shell: bash
|
|
27
|
+
env:
|
|
28
|
+
OUTPUT_FILE: ${{ inputs.output-file }}
|
|
29
|
+
PR_NUMBER: ${{ inputs.pr-number }}
|
|
30
|
+
REVIEW_THREADS_FILE: ${{ inputs.review-threads-file }}
|
|
31
|
+
run: |
|
|
32
|
+
set -euo pipefail
|
|
33
|
+
outcome="$(bash "${{ github.action_path }}/validate-review-output.sh" "$OUTPUT_FILE" "$PR_NUMBER" "$REVIEW_THREADS_FILE")"
|
|
34
|
+
echo "outcome=$outcome" >> "$GITHUB_OUTPUT"
|
|
35
|
+
echo "valid=$([ "$outcome" != 'invalid' ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Managed by @plainconceptsplatform/workflows. Source: loops/actions/validate-review-output/validate-review-output.sh. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
3
|
+
# Print implemented, already-satisfied, needs-human, or invalid.
|
|
4
|
+
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
output_file="$1"
|
|
8
|
+
pr_number="$2"
|
|
9
|
+
threads_file="$3"
|
|
10
|
+
|
|
11
|
+
if [ ! -f "$output_file" ] || [ ! -f "$threads_file" ] \
|
|
12
|
+
|| ! jq -e '.items | arrays' "$output_file" >/dev/null 2>&1 \
|
|
13
|
+
|| ! jq -e 'type == "array"' "$threads_file" >/dev/null 2>&1; then
|
|
14
|
+
echo invalid
|
|
15
|
+
exit 0
|
|
16
|
+
fi
|
|
17
|
+
|
|
18
|
+
jq -r --arg pr "$pr_number" --slurpfile threads "$threads_file" '
|
|
19
|
+
.items as $items
|
|
20
|
+
| ($threads[0] | map(select(.isResolved == false and .isOutdated == false) | .id) | sort) as $expected
|
|
21
|
+
| ($items | [.[] | select(.type == "add_comment" and (.item_number | tostring) == $pr and (.body | type == "string"))]) as $comments
|
|
22
|
+
| ($comments | map(.body | if test("\\*\\*Review outcome:\\*\\*\\s*(implemented|already-satisfied|needs-human)"; "i") then capture("\\*\\*Review outcome:\\*\\*\\s*(?<v>implemented|already-satisfied|needs-human)"; "i").v | ascii_downcase else empty end) | .[0] // "invalid") as $outcome
|
|
23
|
+
| ($comments | map(.body | scan("PRRT_[A-Za-z0-9_=-]+")) | add | unique | sort) as $reported
|
|
24
|
+
| ([$items[] | select(.type == "push_to_pull_request_branch")] | length) as $pushes
|
|
25
|
+
| if $expected != $reported then "invalid"
|
|
26
|
+
elif $outcome == "implemented" and $pushes == 1 then "implemented"
|
|
27
|
+
elif ($outcome == "already-satisfied" or $outcome == "needs-human") and $pushes == 0 then $outcome
|
|
28
|
+
else "invalid"
|
|
29
|
+
end
|
|
30
|
+
' "$output_file"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Managed by @plainconceptsplatform/workflows. Source: loops/actions/validate-triage-output/action.yml. Update with workflows update --force; consumer edits may be overwritten.
|
|
2
|
+
name: Validate triage output
|
|
3
|
+
description: Verify agent_output.json contains a usable triage verdict before any GitHub writes.
|
|
4
|
+
inputs:
|
|
5
|
+
output-file:
|
|
6
|
+
description: Path to agent_output.json.
|
|
7
|
+
required: true
|
|
8
|
+
issue-number:
|
|
9
|
+
description: Issue number that every triage outcome must target.
|
|
10
|
+
required: true
|
|
11
|
+
outputs:
|
|
12
|
+
valid:
|
|
13
|
+
description: Whether the output contains a usable triage comment with a verdict.
|
|
14
|
+
value: ${{ steps.validate.outputs.valid }}
|
|
15
|
+
outcome:
|
|
16
|
+
description: "Deterministic triage outcome: pass, needs-info, block, or invalid."
|
|
17
|
+
value: ${{ steps.validate.outputs.outcome }}
|
|
18
|
+
runs:
|
|
19
|
+
using: composite
|
|
20
|
+
steps:
|
|
21
|
+
- name: Validate triage outcome
|
|
22
|
+
id: validate
|
|
23
|
+
shell: bash
|
|
24
|
+
env:
|
|
25
|
+
ISSUE_NUMBER: ${{ inputs.issue-number }}
|
|
26
|
+
OUTPUT_FILE: ${{ inputs.output-file }}
|
|
27
|
+
run: |
|
|
28
|
+
set -euo pipefail
|
|
29
|
+
|
|
30
|
+
outcome="$(bash "${{ github.action_path }}/validate-triage-output.sh" "$OUTPUT_FILE" "$ISSUE_NUMBER")"
|
|
31
|
+
echo "outcome=$outcome" >> "$GITHUB_OUTPUT"
|
|
32
|
+
echo "valid=$([ "$outcome" != 'invalid' ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
|
|
33
|
+
|
|
34
|
+
if [ "$outcome" = 'invalid' ]; then
|
|
35
|
+
echo "::warning::Agent output has no usable triage outcome. It will not be applied."
|
|
36
|
+
fi
|