@plainconceptsplatform/workflows 0.4.20 → 0.4.32
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/catalog-installation.d.ts +2 -0
- package/dist/catalog-installation.js +24 -2
- package/dist/catalog-installation.test.js +31 -1
- package/dist/catalog-listing.test.js +1 -1
- package/dist/index.js +40 -4
- package/dist/index.test.js +43 -3
- package/dist/tui.js +38 -15
- package/dist/workflow-catalog.d.ts +1 -1
- package/dist/workflow-catalog.js +2 -0
- package/dist/workflow-catalog.test.js +1 -1
- package/loops/actions/agent-output.cjs +1 -1
- package/loops/actions/classify-route/classify-route.sh +10 -6
- package/loops/scripts/compile-agent-workflows.mjs +23 -20
- package/loops/workflows/agent-implement.md +63 -25
- package/loops/workflows/agent-merge-gate.md +3 -3
- package/loops/workflows/agent-refine.md +57 -16
- package/loops/workflows/authorize-bot-work.yml +2 -0
- package/loops/workflows/shared/opencode-ci.md +79 -16
- package/loops/workflows/work-router.yml +49 -27
- package/package.json +2 -2
|
@@ -21,6 +21,8 @@ export declare function catalogSourcePath(modulePath?: string): string;
|
|
|
21
21
|
export declare function installCatalog(repositoryPath: string, options?: CatalogInstallOptions): Promise<CatalogInstallResult>;
|
|
22
22
|
export declare function installTemplate(repositoryPath: string, template: TemplateName, options?: CatalogInstallOptions): Promise<CatalogInstallResult>;
|
|
23
23
|
export declare function installMandatoryFiles(repositoryPath: string, options?: CatalogInstallOptions): Promise<CatalogInstallResult>;
|
|
24
|
+
export declare function installedRoutes(repositoryPath: string): Promise<RouteName[]>;
|
|
25
|
+
export declare function removeRouteFiles(repositoryPath: string, routes: readonly RouteName[]): Promise<string[]>;
|
|
24
26
|
export declare function isTemplateName(value: string): value is TemplateName;
|
|
25
27
|
export declare function ensurePreCommitHook(repositoryPath: string): Promise<void>;
|
|
26
28
|
export declare function runCompileIfAvailable(repositoryPath: string): Promise<void>;
|
|
@@ -4,7 +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 { catalogTemplates, mandatoryFiles, routeNames, templateNames } from "./workflow-catalog.js";
|
|
7
|
+
import { catalogTemplates, mandatoryFiles, routeNames, templateNames, workflowRoutes } from "./workflow-catalog.js";
|
|
8
8
|
import { processRoutes, excludedWorkerFiles } from "./route-processing.js";
|
|
9
9
|
import { generateOpencodeCi, generateOpencodeConfig, generateStackDefaults, injectStackEnv } from "./stack-defaults.js";
|
|
10
10
|
const execFileAsync = promisify(execFile);
|
|
@@ -149,6 +149,28 @@ export async function installMandatoryFiles(repositoryPath, options = {}) {
|
|
|
149
149
|
await applyTransaction(repositoryPath, [...updates, await preCommitHookUpdate(repositoryPath)]);
|
|
150
150
|
return { installed: files.map((file) => file.target), conflicts };
|
|
151
151
|
}
|
|
152
|
+
export async function installedRoutes(repositoryPath) {
|
|
153
|
+
const found = await Promise.all(workflowRoutes.map(async (route) => (await exists(join(repositoryPath, ".github", "workflows", route.worker))) ? route.name : undefined));
|
|
154
|
+
return found.filter((name) => name !== undefined);
|
|
155
|
+
}
|
|
156
|
+
export async function removeRouteFiles(repositoryPath, routes) {
|
|
157
|
+
const workerByRoute = new Map(workflowRoutes.map((route) => [route.name, route.worker]));
|
|
158
|
+
const removed = [];
|
|
159
|
+
for (const route of routes) {
|
|
160
|
+
const worker = workerByRoute.get(route);
|
|
161
|
+
if (worker === undefined)
|
|
162
|
+
continue;
|
|
163
|
+
const lock = worker.replace(/\.md$/, ".lock.yml");
|
|
164
|
+
for (const file of [worker, lock]) {
|
|
165
|
+
const destination = join(repositoryPath, ".github", "workflows", file);
|
|
166
|
+
if (await exists(destination)) {
|
|
167
|
+
await rm(destination, { force: true });
|
|
168
|
+
removed.push(`.github/workflows/${file}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return removed.sort();
|
|
173
|
+
}
|
|
152
174
|
export function isTemplateName(value) {
|
|
153
175
|
return templateNames.includes(value);
|
|
154
176
|
}
|
|
@@ -277,7 +299,7 @@ function catalogTemplateMeta(template) {
|
|
|
277
299
|
const entry = catalogTemplates.find((item) => item.name === template);
|
|
278
300
|
if (entry === undefined)
|
|
279
301
|
throw new Error(`Unknown template: ${template}`);
|
|
280
|
-
const directory = template.startsWith("opencode") ? "opencode" : template.startsWith("app-ci-") ? "ci" : template === "github-release" ? "release" : "agentics";
|
|
302
|
+
const directory = template.startsWith("opencode") ? "opencode" : template.startsWith("app-ci-") ? "ci" : template === "github-release" ? "release" : template === "visual-evidence" ? "visual-evidence" : "agentics";
|
|
281
303
|
const isWorkflow = entry.file.endsWith(".yml");
|
|
282
304
|
const target = template === "app-ci-dotnet-next"
|
|
283
305
|
? ".github/workflows/app-ci.yml"
|
|
@@ -3,7 +3,7 @@ import { constants } from "node:fs";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
-
import { catalogSourcePath, ensurePreCommitHook, installCatalog, installMandatoryFiles, installTemplate } from "./catalog-installation.js";
|
|
6
|
+
import { catalogSourcePath, ensurePreCommitHook, installCatalog, installedRoutes, installMandatoryFiles, installTemplate, removeRouteFiles } from "./catalog-installation.js";
|
|
7
7
|
const temporaryDirectories = [];
|
|
8
8
|
afterEach(async () => {
|
|
9
9
|
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { force: true, recursive: true })));
|
|
@@ -385,6 +385,36 @@ describe("catalog installation", () => {
|
|
|
385
385
|
});
|
|
386
386
|
});
|
|
387
387
|
});
|
|
388
|
+
describe("route lifecycle", () => {
|
|
389
|
+
it("detects installed route workers in workflowRoutes order", async () => {
|
|
390
|
+
const repositoryPath = await createDirectory({
|
|
391
|
+
".github/workflows/agent-implement.md": "# Implement\n",
|
|
392
|
+
".github/workflows/agent-refine.md": "# Refine\n",
|
|
393
|
+
});
|
|
394
|
+
await expect(installedRoutes(repositoryPath)).resolves.toEqual(["refine", "implement"]);
|
|
395
|
+
});
|
|
396
|
+
it("returns an empty list when no route workers are installed", async () => {
|
|
397
|
+
const repositoryPath = await createDirectory({});
|
|
398
|
+
await expect(installedRoutes(repositoryPath)).resolves.toEqual([]);
|
|
399
|
+
});
|
|
400
|
+
it("removes a route worker and its generated lock, leaving other workers", async () => {
|
|
401
|
+
const repositoryPath = await createDirectory({
|
|
402
|
+
".github/workflows/agent-refine.md": "# Refine\n",
|
|
403
|
+
".github/workflows/agent-refine.lock.yml": "generated\n",
|
|
404
|
+
".github/workflows/agent-implement.md": "# Implement\n",
|
|
405
|
+
});
|
|
406
|
+
await expect(removeRouteFiles(repositoryPath, ["refine"])).resolves.toEqual([
|
|
407
|
+
".github/workflows/agent-refine.lock.yml",
|
|
408
|
+
".github/workflows/agent-refine.md",
|
|
409
|
+
]);
|
|
410
|
+
await expect(readFile(join(repositoryPath, ".github/workflows/agent-refine.md"), "utf8")).rejects.toThrow();
|
|
411
|
+
await expect(readFile(join(repositoryPath, ".github/workflows/agent-implement.md"), "utf8")).resolves.toBe("# Implement\n");
|
|
412
|
+
});
|
|
413
|
+
it("ignores routes that are not installed", async () => {
|
|
414
|
+
const repositoryPath = await createDirectory({});
|
|
415
|
+
await expect(removeRouteFiles(repositoryPath, ["propose"])).resolves.toEqual([]);
|
|
416
|
+
});
|
|
417
|
+
});
|
|
388
418
|
async function createDirectory(files) {
|
|
389
419
|
const directory = await mkdtemp(join(tmpdir(), "workflows-"));
|
|
390
420
|
temporaryDirectories.push(directory);
|
|
@@ -16,7 +16,7 @@ describe("catalog listing", () => {
|
|
|
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
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"]);
|
|
19
|
+
expect(templateNames).toEqual(["agentics-checks", "agentics-maintenance", "app-ci-dotnet-next", "app-ci-node-monorepo", "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({});
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { inspectRepository, parseVisibility, resolveVisibility } from "./repository-inspection.js";
|
|
3
|
-
import { installCatalog, installTemplate, isTemplateName } from "./catalog-installation.js";
|
|
3
|
+
import { installCatalog, installedRoutes, installTemplate, isTemplateName, removeRouteFiles } from "./catalog-installation.js";
|
|
4
4
|
import { formatCatalog, listCatalog, searchCatalog } from "./catalog-listing.js";
|
|
5
|
-
import { routeNames } from "./workflow-catalog.js";
|
|
5
|
+
import { routeNames, templateNames } from "./workflow-catalog.js";
|
|
6
6
|
import { runInteractive } from "./tui.js";
|
|
7
7
|
import { resolve } from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
@@ -21,6 +21,7 @@ Commands:
|
|
|
21
21
|
(default) Launch the interactive TUI for selecting and installing items.
|
|
22
22
|
init Inspect the repository and report its stack and visibility.
|
|
23
23
|
add [routes] [--template <name>] [--force] Install route workers, a template, or mandatory files.
|
|
24
|
+
remove <routes> [--force] Uninstall route workers and regenerate the router without them.
|
|
24
25
|
update Alias for add.
|
|
25
26
|
status Print repository inspection as JSON.
|
|
26
27
|
list List all available workflows and templates with install status.
|
|
@@ -35,6 +36,12 @@ Route names (positional arguments to add):
|
|
|
35
36
|
add --template agentics-checks Installs the named template only (no mandatory files).
|
|
36
37
|
add refine --template agentics-checks Installs routes + mandatory + the named template.
|
|
37
38
|
add refine implement --force Forces re-install of routes plus mandatory, overwriting.
|
|
39
|
+
remove propose Uninstalls the propose worker and drops it from the router.
|
|
40
|
+
|
|
41
|
+
add and remove keep the router consistent with what is installed: add unions the requested
|
|
42
|
+
routes with the routes already present, and remove drops the requested routes from that set.
|
|
43
|
+
Both regenerate the router, classifier, and route matrix from the resulting set. Changing the
|
|
44
|
+
route set rewrites the package-owned router, so pass --force to overwrite it.
|
|
38
45
|
|
|
39
46
|
Options:
|
|
40
47
|
--visibility public|private Override repository visibility (init only).
|
|
@@ -95,7 +102,8 @@ export async function run(arguments_, repositoryPath = process.cwd()) {
|
|
|
95
102
|
const allConflicts = [];
|
|
96
103
|
const allInstalled = [];
|
|
97
104
|
if (routes.length > 0) {
|
|
98
|
-
const
|
|
105
|
+
const selectedRoutes = unionRoutes(routes, await installedRoutes(repositoryPath));
|
|
106
|
+
const result = await installCatalog(repositoryPath, { force, selectedRoutes, inspection });
|
|
99
107
|
allConflicts.push(...result.conflicts);
|
|
100
108
|
allInstalled.push(...result.installed);
|
|
101
109
|
}
|
|
@@ -116,8 +124,36 @@ export async function run(arguments_, repositoryPath = process.cwd()) {
|
|
|
116
124
|
console.log(JSON.stringify({ command, installed: allInstalled.sort(), conflicts: allConflicts }, null, 2));
|
|
117
125
|
return 0;
|
|
118
126
|
}
|
|
127
|
+
if (command === "remove") {
|
|
128
|
+
const parsed = parseAddOptions(options);
|
|
129
|
+
if (parsed.kind === "invalid")
|
|
130
|
+
return fail(parsed.message);
|
|
131
|
+
if (parsed.template !== undefined)
|
|
132
|
+
return fail("remove does not accept --template.");
|
|
133
|
+
if (parsed.routes.length === 0)
|
|
134
|
+
return fail("remove requires at least one route.");
|
|
135
|
+
const inspection = await inspectRepository(repositoryPath);
|
|
136
|
+
const installed = await installedRoutes(repositoryPath);
|
|
137
|
+
const desiredRoutes = installed.filter((route) => !parsed.routes.includes(route));
|
|
138
|
+
const result = await installCatalog(repositoryPath, { force: parsed.force, selectedRoutes: desiredRoutes, inspection });
|
|
139
|
+
if (result.conflicts.length > 0 && !parsed.force) {
|
|
140
|
+
console.error(`Catalog conflicts found. Re-run with --force to overwrite package-managed files:\n${result.conflicts.join("\n")}`);
|
|
141
|
+
return 1;
|
|
142
|
+
}
|
|
143
|
+
const removed = await removeRouteFiles(repositoryPath, parsed.routes);
|
|
144
|
+
console.log(JSON.stringify({ command, installed: [...result.installed].sort(), removed, conflicts: result.conflicts }, null, 2));
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
119
147
|
return fail(`Unknown command: ${command}`);
|
|
120
148
|
}
|
|
149
|
+
function unionRoutes(requested, installed) {
|
|
150
|
+
const result = [...requested];
|
|
151
|
+
for (const route of installed) {
|
|
152
|
+
if (!result.includes(route))
|
|
153
|
+
result.push(route);
|
|
154
|
+
}
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
121
157
|
function readVisibilityOption(options) {
|
|
122
158
|
if (options.length === 0)
|
|
123
159
|
return undefined;
|
|
@@ -125,7 +161,7 @@ function readVisibilityOption(options) {
|
|
|
125
161
|
return "invalid";
|
|
126
162
|
return parseVisibility(options[1]) ?? "invalid";
|
|
127
163
|
}
|
|
128
|
-
const TEMPLATE_NAMES = "
|
|
164
|
+
const TEMPLATE_NAMES = templateNames.join("|");
|
|
129
165
|
function parseAddOptions(options) {
|
|
130
166
|
const routes = [];
|
|
131
167
|
let template;
|
package/dist/index.test.js
CHANGED
|
@@ -34,7 +34,7 @@ describe("workflows CLI", () => {
|
|
|
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|opencode.ci.json.");
|
|
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.");
|
|
38
38
|
error.mockRestore();
|
|
39
39
|
});
|
|
40
40
|
it("rejects an unknown route passed to add", 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
|
-
// 7 routes +
|
|
68
|
+
// 7 routes + 7 templates = 14 entries
|
|
69
69
|
const uninstalledCount = (output.match(/\[ \]/g) ?? []).length;
|
|
70
|
-
expect(uninstalledCount).toBe(
|
|
70
|
+
expect(uninstalledCount).toBe(14);
|
|
71
71
|
log.mockRestore();
|
|
72
72
|
});
|
|
73
73
|
it("marks installed workflows with [x]", async () => {
|
|
@@ -219,6 +219,46 @@ describe("workflows CLI", () => {
|
|
|
219
219
|
expect(error).toHaveBeenCalledWith("Unknown option: --unknown");
|
|
220
220
|
error.mockRestore();
|
|
221
221
|
});
|
|
222
|
+
it("add unions requested routes with already-installed routes", async () => {
|
|
223
|
+
const { installCatalog } = mockInstallers();
|
|
224
|
+
const repositoryPath = await createRepository({
|
|
225
|
+
".github/workflows/agent-refine.md": "# Refine",
|
|
226
|
+
".github/workflows/agent-implement.md": "# Implement",
|
|
227
|
+
});
|
|
228
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
229
|
+
await expect(run(["add", "direct"], repositoryPath)).resolves.toBe(0);
|
|
230
|
+
expect(installCatalog).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ selectedRoutes: ["direct", "refine", "implement"] }));
|
|
231
|
+
log.mockRestore();
|
|
232
|
+
});
|
|
233
|
+
it("remove requires at least one route", async () => {
|
|
234
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
235
|
+
await expect(run(["remove"])).resolves.toBe(1);
|
|
236
|
+
expect(error).toHaveBeenCalledWith("remove requires at least one route.");
|
|
237
|
+
error.mockRestore();
|
|
238
|
+
});
|
|
239
|
+
it("remove rejects the --template flag", async () => {
|
|
240
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
241
|
+
await expect(run(["remove", "--template", "agentics-checks"])).resolves.toBe(1);
|
|
242
|
+
expect(error).toHaveBeenCalledWith("remove does not accept --template.");
|
|
243
|
+
error.mockRestore();
|
|
244
|
+
});
|
|
245
|
+
it("remove regenerates the router for the remaining routes and deletes the worker", async () => {
|
|
246
|
+
const { installCatalog } = mockInstallers();
|
|
247
|
+
const repositoryPath = await createRepository({
|
|
248
|
+
".github/workflows/agent-refine.md": "# Refine",
|
|
249
|
+
".github/workflows/agent-refine.lock.yml": "generated",
|
|
250
|
+
".github/workflows/agent-implement.md": "# Implement",
|
|
251
|
+
});
|
|
252
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
253
|
+
await expect(run(["remove", "refine", "--force"], repositoryPath)).resolves.toBe(0);
|
|
254
|
+
expect(installCatalog).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ force: true, selectedRoutes: ["implement"] }));
|
|
255
|
+
const { access } = await import("node:fs/promises");
|
|
256
|
+
const { constants } = await import("node:fs");
|
|
257
|
+
await expect(access(join(repositoryPath, ".github/workflows/agent-refine.md"), constants.F_OK)).rejects.toThrow();
|
|
258
|
+
await expect(access(join(repositoryPath, ".github/workflows/agent-refine.lock.yml"), constants.F_OK)).rejects.toThrow();
|
|
259
|
+
await expect(access(join(repositoryPath, ".github/workflows/agent-implement.md"), constants.F_OK)).resolves.toBeUndefined();
|
|
260
|
+
log.mockRestore();
|
|
261
|
+
});
|
|
222
262
|
});
|
|
223
263
|
async function createRepository(files) {
|
|
224
264
|
const repositoryPath = await mkdtemp(join(tmpdir(), "workflows-"));
|
package/dist/tui.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as readline from "node:readline";
|
|
2
2
|
import { formatCatalog, listCatalog } from "./catalog-listing.js";
|
|
3
|
-
import { installCatalog, installMandatoryFiles, installTemplate, isTemplateName } from "./catalog-installation.js";
|
|
3
|
+
import { installCatalog, installMandatoryFiles, installTemplate, isTemplateName, removeRouteFiles } from "./catalog-installation.js";
|
|
4
4
|
import { inspectRepository } from "./repository-inspection.js";
|
|
5
5
|
import { routeNames } from "./workflow-catalog.js";
|
|
6
6
|
const ANSI = {
|
|
@@ -218,22 +218,37 @@ export async function runInteractive(repositoryPath, options = {}) {
|
|
|
218
218
|
}
|
|
219
219
|
async function installSelected(state, repositoryPath, force) {
|
|
220
220
|
const items = getItemsToInstall(state, force);
|
|
221
|
-
const
|
|
221
|
+
const newRoutes = items.filter((entry) => entry.kind === "route");
|
|
222
222
|
const templates = items.filter((entry) => entry.kind === "template");
|
|
223
|
+
const routeEntries = state.allItems.filter((entry) => entry.kind === "route");
|
|
224
|
+
const checkedRoutes = routeEntries
|
|
225
|
+
.filter((entry) => state.selected.has(entry.name))
|
|
226
|
+
.map((entry) => entry.name)
|
|
227
|
+
.filter((name) => routeNames.includes(name));
|
|
228
|
+
const installedRouteNames = routeEntries
|
|
229
|
+
.filter((entry) => entry.installed)
|
|
230
|
+
.map((entry) => entry.name)
|
|
231
|
+
.filter((name) => routeNames.includes(name));
|
|
232
|
+
const removedRoutes = installedRouteNames.filter((name) => !checkedRoutes.includes(name));
|
|
223
233
|
const allConflicts = [];
|
|
224
234
|
const allInstalled = [];
|
|
225
|
-
const
|
|
226
|
-
? routes.map((entry) => entry.name).filter((name) => routeNames.includes(name))
|
|
227
|
-
: [...routeNames];
|
|
235
|
+
const allRemoved = [];
|
|
228
236
|
const inspection = await inspectRepository(repositoryPath);
|
|
229
|
-
if (
|
|
230
|
-
const result = await installCatalog(repositoryPath, {
|
|
231
|
-
force,
|
|
232
|
-
selectedRoutes: selectedRouteNames,
|
|
233
|
-
inspection,
|
|
234
|
-
});
|
|
237
|
+
if (checkedRoutes.length > 0 && (newRoutes.length > 0 || removedRoutes.length > 0 || force)) {
|
|
238
|
+
const result = await installCatalog(repositoryPath, { force, selectedRoutes: checkedRoutes, inspection });
|
|
235
239
|
allConflicts.push(...result.conflicts);
|
|
236
240
|
allInstalled.push(...result.installed);
|
|
241
|
+
if (result.conflicts.length === 0 || force) {
|
|
242
|
+
allRemoved.push(...await removeRouteFiles(repositoryPath, removedRoutes));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
else if (checkedRoutes.length === 0 && removedRoutes.length > 0) {
|
|
246
|
+
const result = await installCatalog(repositoryPath, { force, selectedRoutes: [], inspection });
|
|
247
|
+
allConflicts.push(...result.conflicts);
|
|
248
|
+
allInstalled.push(...result.installed);
|
|
249
|
+
if (result.conflicts.length === 0 || force) {
|
|
250
|
+
allRemoved.push(...await removeRouteFiles(repositoryPath, removedRoutes));
|
|
251
|
+
}
|
|
237
252
|
}
|
|
238
253
|
else {
|
|
239
254
|
const result = await installMandatoryFiles(repositoryPath, { force });
|
|
@@ -251,10 +266,18 @@ async function installSelected(state, repositoryPath, force) {
|
|
|
251
266
|
console.error(`Conflicts found. Re-run with --force to overwrite:\n${allConflicts.join("\n")}`);
|
|
252
267
|
return 1;
|
|
253
268
|
}
|
|
254
|
-
if (allInstalled.length > 0) {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
269
|
+
if (allInstalled.length > 0 || allRemoved.length > 0) {
|
|
270
|
+
if (allInstalled.length > 0) {
|
|
271
|
+
console.log(`Installed ${allInstalled.length} item(s):`);
|
|
272
|
+
for (const file of allInstalled) {
|
|
273
|
+
console.log(` ${file}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (allRemoved.length > 0) {
|
|
277
|
+
console.log(`Removed ${allRemoved.length} item(s):`);
|
|
278
|
+
for (const file of allRemoved) {
|
|
279
|
+
console.log(` ${file}`);
|
|
280
|
+
}
|
|
258
281
|
}
|
|
259
282
|
}
|
|
260
283
|
else {
|
|
@@ -14,7 +14,7 @@ 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"];
|
|
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"];
|
|
18
18
|
export type TemplateName = (typeof templateNames)[number];
|
|
19
19
|
export interface CatalogTemplate {
|
|
20
20
|
readonly name: TemplateName;
|
package/dist/workflow-catalog.js
CHANGED
|
@@ -40,6 +40,7 @@ export const templateNames = [
|
|
|
40
40
|
"app-ci-node-monorepo",
|
|
41
41
|
"github-release",
|
|
42
42
|
"opencode.ci.json",
|
|
43
|
+
"visual-evidence",
|
|
43
44
|
];
|
|
44
45
|
export const catalogTemplates = [
|
|
45
46
|
{ name: "agentics-checks", file: "agentics-checks.yml", description: "Agentics checks: verifies generated agent lockfiles, actionlint, and compile on PRs touching workflow files." },
|
|
@@ -48,4 +49,5 @@ export const catalogTemplates = [
|
|
|
48
49
|
{ 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." },
|
|
49
50
|
{ name: "github-release", file: "github-release.yml", description: "Publishes or updates a GitHub Release with generated notes whenever a v* tag is pushed." },
|
|
50
51
|
{ 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
|
+
{ 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." },
|
|
51
53
|
];
|
|
@@ -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"]);
|
|
20
|
+
expect(templateNames).toEqual(["agentics-checks", "agentics-maintenance", "app-ci-dotnet-next", "app-ci-node-monorepo", "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]);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Managed by @plainconceptsplatform/workflows. Source: loops/actions/agent-output.cjs. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
2
|
-
const fs = require('fs');
|
|
2
|
+
const fs = require('node:fs');
|
|
3
3
|
|
|
4
4
|
// Every apply-agent-* action reads the same artifact the same way: a missing file means the
|
|
5
5
|
// agent produced nothing, which is a normal outcome rather than a failure.
|
|
@@ -11,8 +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="*/
|
|
15
|
-
readonly STALE_RECOVERY_CRON="0 */2 * * *"
|
|
14
|
+
readonly RECONCILE_BOT_PR_RUNS_CRON="*/30 * * * *"
|
|
16
15
|
# Daily, but it proposes far less often than daily: the worker holds one open
|
|
17
16
|
# proposal at a time and skips while that slot is filled. The cron is a heartbeat,
|
|
18
17
|
# the queue is the pacing.
|
|
@@ -37,7 +36,10 @@ classify_route() {
|
|
|
37
36
|
case "${LABEL:-}" in
|
|
38
37
|
bot-working)
|
|
39
38
|
# Bot adds bot-working → route based on which work label is present
|
|
40
|
-
if
|
|
39
|
+
# BUT: if review label is present, do NOT route (human review required)
|
|
40
|
+
if has_label review; then
|
|
41
|
+
error="issue has review label; bot-working does not re-trigger while human review is required"
|
|
42
|
+
elif has_label implement; then
|
|
41
43
|
route="implement"
|
|
42
44
|
issue_number="${EVENT_ISSUE_NUMBER:-}"
|
|
43
45
|
elif has_label refine; then
|
|
@@ -63,6 +65,10 @@ classify_route() {
|
|
|
63
65
|
elif [ "${LABEL:-}" = "direct" ]; then
|
|
64
66
|
direct_mode="first"
|
|
65
67
|
fi
|
|
68
|
+
elif has_label bot-working; then
|
|
69
|
+
# Already has bot-working - the workflow is already running or queued.
|
|
70
|
+
# Don't re-trigger.
|
|
71
|
+
error="issue already has bot-working label; implement/refine/direct already in progress"
|
|
66
72
|
else
|
|
67
73
|
error="waiting for bot to add bot-working label"
|
|
68
74
|
fi
|
|
@@ -129,8 +135,6 @@ classify_route() {
|
|
|
129
135
|
"$AUDIT_CLOSE_CRON") route="audit-close" ;;
|
|
130
136
|
"$CLEANUP_ARTIFACTS_CRON") route="cleanup-artifacts" ;;
|
|
131
137
|
"$RECONCILE_BOT_PR_RUNS_CRON") route="reconcile-bot-pr-runs" ;;
|
|
132
|
-
"$STALE_RECOVERY_CRON") route="stale-recovery" ;;
|
|
133
|
-
"$PROPOSE_CRON") route="propose" ;;
|
|
134
138
|
*) error="no route for cron '${SCHEDULE:-}'" ;;
|
|
135
139
|
esac
|
|
136
140
|
;;
|
|
@@ -173,7 +177,7 @@ classify_route() {
|
|
|
173
177
|
route="${OPERATION}"
|
|
174
178
|
trigger_kind="${INPUT_TRIGGER_KIND:-manual}"
|
|
175
179
|
;;
|
|
176
|
-
|
|
180
|
+
audit-close | cleanup-artifacts | reconcile-bot-pr-runs | validate)
|
|
177
181
|
route="${OPERATION}"
|
|
178
182
|
;;
|
|
179
183
|
*)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Managed by @plainconceptsplatform/workflows. Source: loops/scripts/compile-agent-workflows.mjs. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
2
|
-
import { spawnSync } from "node:child_process";
|
|
3
|
-
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { join } from "node:path";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
5
|
|
|
6
6
|
const workflowDirectory = existsSync("loops/workflows") ? "loops/workflows" : ".github/workflows";
|
|
7
7
|
|
|
@@ -22,23 +22,26 @@ const compile = spawnSync(resolveGhPath(), ["aw", "compile", "--strict", "--dir"
|
|
|
22
22
|
shell: false,
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
-
if (compile.error?.code === "ENOENT" || compile.status === null) {
|
|
25
|
+
if (compile.error?.code === "ENOENT" || compile.status === null) {
|
|
26
26
|
process.stderr.write("Could not run `gh aw compile`. Install githubnext/gh-aw first.\n");
|
|
27
27
|
process.exit(1);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
if (compile.status !== 0) process.exit(compile.status ?? 1);
|
|
31
|
-
|
|
32
|
-
for (const file of readdirSync(workflowDirectory)) {
|
|
33
|
-
if (!file.endsWith(".lock.yml")) continue;
|
|
34
|
-
|
|
35
|
-
const path = join(workflowDirectory, file);
|
|
36
|
-
const content = readFileSync(path, "utf8");
|
|
37
|
-
const patched = content
|
|
38
|
-
.replaceAll("opencode run --print-logs --log-level DEBUG", "opencode run --log-level ERROR")
|
|
39
|
-
.replaceAll("opencode run --print-logs --log-level ERROR", "opencode run --log-level ERROR")
|
|
40
|
-
.replaceAll("--log-level
|
|
41
|
-
.
|
|
42
|
-
|
|
43
|
-
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (compile.status !== 0) process.exit(compile.status ?? 1);
|
|
31
|
+
|
|
32
|
+
for (const file of readdirSync(workflowDirectory)) {
|
|
33
|
+
if (!file.endsWith(".lock.yml")) continue;
|
|
34
|
+
|
|
35
|
+
const path = join(workflowDirectory, file);
|
|
36
|
+
const content = readFileSync(path, "utf8");
|
|
37
|
+
const patched = content
|
|
38
|
+
.replaceAll("opencode run --print-logs --log-level DEBUG", "opencode run --port 4096 --log-level ERROR")
|
|
39
|
+
.replaceAll("opencode run --print-logs --log-level ERROR", "opencode run --port 4096 --log-level ERROR")
|
|
40
|
+
.replaceAll("opencode run --log-level ERROR", "opencode run --port 4096 --log-level ERROR")
|
|
41
|
+
.replaceAll("--log-level DEBUG", "--log-level ERROR")
|
|
42
|
+
.replace(/GH_AW_INFO_MODEL: "[^"]*"/g, 'GH_AW_INFO_MODEL: "per-agent"')
|
|
43
|
+
.replace(/OPENCODE_MODEL: [^\n]+/g, "OPENCODE_MODEL: ''")
|
|
44
|
+
.replace(/GH_AW_INFO_MODEL_COSTS: '[^']*'/g, 'GH_AW_INFO_MODEL_COSTS: \'{"providers":{}}\'');
|
|
45
|
+
|
|
46
|
+
if (patched !== content) writeFileSync(path, patched);
|
|
44
47
|
}
|
|
@@ -97,12 +97,6 @@ jobs:
|
|
|
97
97
|
token: ${{ steps.app-token.outputs.token }}
|
|
98
98
|
issue-number: ${{ inputs.issue-number }}
|
|
99
99
|
labels: ${{ env.REVIEW_LABEL }}
|
|
100
|
-
- name: Ensure implement label is present
|
|
101
|
-
uses: ./.github/actions/add-issue-labels
|
|
102
|
-
with:
|
|
103
|
-
token: ${{ steps.app-token.outputs.token }}
|
|
104
|
-
issue-number: ${{ inputs.issue-number }}
|
|
105
|
-
labels: ${{ env.IMPLEMENT_LABEL }}
|
|
106
100
|
conclude:
|
|
107
101
|
needs: [agent, safe_outputs]
|
|
108
102
|
if: >
|
|
@@ -164,7 +158,7 @@ jobs:
|
|
|
164
158
|
with:
|
|
165
159
|
token: ${{ steps.app-token.outputs.token }}
|
|
166
160
|
issue-number: ${{ inputs.issue-number }}
|
|
167
|
-
labels: ${{ env.WORKING_LABEL }}
|
|
161
|
+
labels: ${{ env.WORKING_LABEL }},implement
|
|
168
162
|
- name: Flag for human review
|
|
169
163
|
uses: ./.github/actions/add-issue-labels
|
|
170
164
|
with:
|
|
@@ -250,18 +244,29 @@ timeout-minutes: 90
|
|
|
250
244
|
`ob-ops-evidence`) and owns its procedure. You must not skip a phase unless the
|
|
251
245
|
pipeline's refined-issue detection says to.
|
|
252
246
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
247
|
+
d. The `apply` phase uses `ob-plan-apply` which delegates implementation to specialist
|
|
248
|
+
subagent waves. Let it own worker resolution, concurrency, and retry , do not
|
|
249
|
+
implement the tasks yourself unless `ob-plan-apply` instructs you to.
|
|
250
|
+
|
|
251
|
+
e. **Evidence phase:** The agent sandbox cannot run Docker or headless Chromium.
|
|
252
|
+
`ob-ops-evidence` writes a `capturePlan` in `evidence.json` instead of capturing
|
|
253
|
+
screenshots. A separate "Visual evidence" CI workflow runs the capturePlan on a
|
|
254
|
+
runner with full access. Do not attempt workarounds — write the capturePlan and move on.
|
|
255
|
+
|
|
256
|
+
f. Implement only what the issue asks for: a vague sentence is not licence to redesign
|
|
257
|
+
a module. Never read outside this repository root. The issue context at
|
|
258
|
+
`${{ env.ISSUE_CONTEXT_PATH }}` defines acceptance criteria that the pipeline must
|
|
259
|
+
satisfy.
|
|
256
260
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
satisfy.
|
|
261
|
+
g. Follow repository documentation and established conventions. Keep changes focused,
|
|
262
|
+
protect secrets, do not bypass checks, and do not modify generated files unless the issue requires it.
|
|
263
|
+
Adhere to ${{ env.REPO_RULES }}.
|
|
261
264
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
+
h. **DECISIVE IMPLEMENTATION.** When a design choice is ambiguous, pick the most
|
|
266
|
+
standard interpretation and implement it immediately. Do not deliberate between
|
|
267
|
+
options for more than one turn. Do not ask clarifying questions — the issue author
|
|
268
|
+
expects you to use good judgment. If two approaches are equally valid, pick one and
|
|
269
|
+
proceed. You can always iterate based on PR feedback.
|
|
265
270
|
|
|
266
271
|
4. Verify before you conclude. From the repository root:
|
|
267
272
|
|
|
@@ -269,10 +274,40 @@ timeout-minutes: 90
|
|
|
269
274
|
${{ env.VERIFY_COMMANDS }}
|
|
270
275
|
```
|
|
271
276
|
|
|
272
|
-
|
|
273
|
-
|
|
277
|
+
If a check fails, fix the cause and rerun. Do not weaken a test, lower a threshold, or skip
|
|
278
|
+
a check to make it pass.
|
|
279
|
+
|
|
280
|
+
5. Before creating the pull request, check whether an open bot pull request already
|
|
281
|
+
exists that closes #${{ inputs.issue-number }}. Run:
|
|
282
|
+
|
|
283
|
+
```
|
|
284
|
+
gh pr list --repo "$GITHUB_REPOSITORY" --state open --search "is:pr linked:issue ${{ inputs.issue-number }}" --json number,headRefName,author --jq '[.[] | select(.author.login | test("[bot]$"))] | if length > 0 then .[0] else empty end'
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
If a PR already exists, do **not** create a new branch or PR. Push your changes to
|
|
288
|
+
the existing PR's branch (`headRefName`) instead, then call
|
|
289
|
+
`safeoutputs/push_to_pull_request_branch` rather than `safeoutputs/create_pull_request`.
|
|
290
|
+
This prevents duplicate PRs when a retry is triggered after a merge-gate failure.
|
|
291
|
+
|
|
292
|
+
If no existing PR is found, proceed to create a new one as described below.
|
|
293
|
+
|
|
294
|
+
Before creating the pull request, update `changelog.json` in the project's
|
|
295
|
+
`src/shared/data/` folder (create `src/shared/data/changelog.json` if it does not
|
|
296
|
+
exist; in a monorepo use `apps/web/src/shared/data/changelog.json`). The file
|
|
297
|
+
has shape `{"version":1,"changes":[...]}`. Use `jq` to prepend a new entry
|
|
298
|
+
with `"timestamp"` (ISO 8601), `"issue"` (number), `"title"` (issue title),
|
|
299
|
+
`"summary"` (1-2 sentences of what you changed), and `"commit"` (short SHA).
|
|
300
|
+
Keep at most 10 entries: if there are already 10, drop the oldest. Commit
|
|
301
|
+
this file as part of the same branch before creating the PR.
|
|
302
|
+
|
|
303
|
+
The changelog is user-facing. Write the summary for a non-technical reader. Never
|
|
304
|
+
expose security, auth, or admin internals: no token/session/JWT details, no
|
|
305
|
+
permission or authorization logic, no audit trail mechanics, no internal method
|
|
306
|
+
names, no database or migration details. If the work touches these areas, describe
|
|
307
|
+
the user-visible outcome only (e.g. "Improved session reliability" or "Fixed a data
|
|
308
|
+
display issue"), not how it was implemented.
|
|
274
309
|
|
|
275
|
-
|
|
310
|
+
6. You **must** call exactly one safe-output tool before finishing, or the workflow
|
|
276
311
|
reports a failure. All safe-output tools are on the `safeoutputs` MCP server. Call
|
|
277
312
|
them using the `safeoutputs/<tool>` convention , for example:
|
|
278
313
|
|
|
@@ -282,11 +317,14 @@ timeout-minutes: 90
|
|
|
282
317
|
|
|
283
318
|
Choose exactly one:
|
|
284
319
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
320
|
+
- **`safeoutputs/create_pull_request`** , propose a pull request against `main` with
|
|
321
|
+
the verified changes. Its `body` must close the issue
|
|
322
|
+
(`Closes #${{ inputs.issue-number }}`) and summarise what changed and why.
|
|
323
|
+
Use this when no open bot PR exists for the issue.
|
|
324
|
+
This is the normal path.
|
|
325
|
+
- **`safeoutputs/push_to_pull_request_branch`** , push to an existing PR's branch
|
|
326
|
+
when step 5 found an open bot PR for this issue. Do not create a duplicate PR.
|
|
327
|
+
- **`safeoutputs/report_incomplete`** , use only when infrastructure or tooling
|
|
290
328
|
prevents you from completing the task (e.g. the codebase cannot build due to a
|
|
291
329
|
pre-existing error you cannot fix). Provide a specific `reason`.
|
|
292
330
|
- **`safeoutputs/noop`** , use only when the issue context shows the work is already
|
|
@@ -270,7 +270,7 @@ jobs:
|
|
|
270
270
|
with:
|
|
271
271
|
token: ${{ steps.app-token.outputs.token }}
|
|
272
272
|
issue-number: ${{ needs.subject.outputs.issue }}
|
|
273
|
-
labels: ${{ env.WORKING_LABEL }}
|
|
273
|
+
labels: ${{ env.WORKING_LABEL }},${{ env.IMPLEMENT_LABEL }}
|
|
274
274
|
- name: Flag for human review
|
|
275
275
|
uses: ./.github/actions/add-issue-labels
|
|
276
276
|
with:
|
|
@@ -442,8 +442,8 @@ timeout-minutes: 60
|
|
|
442
442
|
stop looping: `remove_labels` (item_number: ${{ needs.subject.outputs.issue }}) to remove
|
|
443
443
|
`implement` and `bot-working`, `add_labels` (item_number:
|
|
444
444
|
${{ needs.subject.outputs.issue }}) to add `review`, and `add_comment` (item_number:
|
|
445
|
-
${{ needs.subject.outputs.issue }}) with the failure and what you tried.
|
|
446
|
-
|
|
445
|
+
${{ needs.subject.outputs.issue }}) with the failure and what you tried. The `implement`
|
|
446
|
+
label is removed so retries do not create duplicate PRs. A human decides from there.
|
|
447
447
|
|
|
448
448
|
8. Never merge with administrator privileges and never bypass a required check. If the merge
|
|
449
449
|
is refused, that refusal is the answer: `add_labels` to add `review`
|
|
@@ -10,6 +10,7 @@ env:
|
|
|
10
10
|
REFINE_MARKER: "<!-- agent-refine -->"
|
|
11
11
|
INITIAL_MODE: first
|
|
12
12
|
RESPONSE_MODE: rerefine
|
|
13
|
+
MAX_SELF_QUESTIONS: "5"
|
|
13
14
|
INCOMPLETE_COMMENT: "Automated refinement ended without an outcome. The refine label remains for a retry."
|
|
14
15
|
SAFE_OUTPUT_COMMENT_PREFIX: "Refinement update"
|
|
15
16
|
ISSUE_CONTEXT_PATH: /tmp/gh-aw/agent/issue-context.json
|
|
@@ -24,6 +25,10 @@ description: |
|
|
|
24
25
|
Refines an issue into a user story, on a first pass or after the author has answered the
|
|
25
26
|
bot's questions. Replaces .loops/recipes/refine-loop.yaml.
|
|
26
27
|
|
|
28
|
+
Before writing the story, the agent explores the codebase per work unit (each bullet in a
|
|
29
|
+
bullet-list issue is its own unit), answering its own questions where the code can and
|
|
30
|
+
escalating only genuine business decisions to the author.
|
|
31
|
+
|
|
27
32
|
Each issue refines independently. `bot-working` prevents double-processing: the reserve
|
|
28
33
|
job adds it, the agent or finalization removes it, and a crashed run's leftover marker
|
|
29
34
|
still parks an issue for a person.
|
|
@@ -234,9 +239,9 @@ engine:
|
|
|
234
239
|
- "plainconcepts/glm-5-2"
|
|
235
240
|
|
|
236
241
|
model: openai/glm-5-2
|
|
237
|
-
max-turns:
|
|
238
|
-
max-turn-cache-misses:
|
|
239
|
-
max-ai-credits:
|
|
242
|
+
max-turns: 500
|
|
243
|
+
max-turn-cache-misses: 4000
|
|
244
|
+
max-ai-credits: 8000
|
|
240
245
|
|
|
241
246
|
permissions: read-all
|
|
242
247
|
|
|
@@ -256,7 +261,7 @@ safe-outputs:
|
|
|
256
261
|
add-comment:
|
|
257
262
|
|
|
258
263
|
|
|
259
|
-
timeout-minutes:
|
|
264
|
+
timeout-minutes: 40
|
|
260
265
|
---
|
|
261
266
|
|
|
262
267
|
1. You are refining the triggering issue **#${{ inputs.issue-number }}**. Do not choose
|
|
@@ -270,31 +275,66 @@ timeout-minutes: 30
|
|
|
270
275
|
- On a `${{ env.RESPONSE_MODE }}` pass, incorporate only the supplied answers from the issue author or an
|
|
271
276
|
assignee. Do not use answers from other commenters.
|
|
272
277
|
|
|
273
|
-
3. Call skill("ob-plan-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
+
3. Explore before you write. Call skill("ob-plan-explore") and hold its stance for this step:
|
|
279
|
+
read-only, no plans, no files, no branches. You are only building understanding here, never
|
|
280
|
+
producing artifacts.
|
|
281
|
+
|
|
282
|
+
Split the issue into work units first. If the issue body is a bullet list of distinct tasks
|
|
283
|
+
(for example "- check the button component", "- then check the login", "- then suggest a
|
|
284
|
+
register page"), treat each bullet as its own work unit. Otherwise treat the whole issue as a
|
|
285
|
+
single work unit.
|
|
286
|
+
|
|
287
|
+
Create a todo entry for each work unit before you start exploring. Process them one at a
|
|
288
|
+
time, strictly sequentially: explore unit 1, self-answer its questions, mark the todo
|
|
289
|
+
complete, then move to unit 2. Do not explore multiple work units in the same pass. Do not
|
|
290
|
+
start unit N+1 until unit N is marked complete.
|
|
291
|
+
|
|
292
|
+
For the current work unit only:
|
|
293
|
+
- Explore the relevant code and repository documentation, and raise the concrete questions you
|
|
294
|
+
must answer to refine it well.
|
|
295
|
+
- Keep exploring to answer those questions yourself from the codebase and the docs.
|
|
296
|
+
- Only when a question is a genuine business or product decision that the code cannot answer,
|
|
297
|
+
set it aside as a question for the author.
|
|
298
|
+
- Mark the unit's todo complete only when your findings are concrete enough to write
|
|
299
|
+
acceptance criteria for this unit. If you explored a file but cannot describe what changes
|
|
300
|
+
for this unit, you are not done — keep exploring or set aside a question.
|
|
301
|
+
|
|
302
|
+
Explore more deeply than a single pass, but never without end. Ask yourself at most
|
|
303
|
+
${{ env.MAX_SELF_QUESTIONS }} questions per work unit, and stop once further exploration no
|
|
304
|
+
longer changes your understanding. This exploration is internal working: never write your
|
|
305
|
+
self-asked questions or their answers to the issue.
|
|
306
|
+
|
|
307
|
+
4. Before writing the story, verify coverage: list every work unit and confirm each one has
|
|
308
|
+
exploration findings concrete enough for acceptance criteria. If any unit is missing, go back
|
|
309
|
+
and explore it now. Then call skill("ob-plan-story") and run `/plan-story` for the issue,
|
|
310
|
+
passing everything you learned while exploring as the exploration findings. Ground the story
|
|
311
|
+
in the actual codebase by reading the relevant files. Never read outside this repository root.
|
|
312
|
+
When the issue held several work units, combine them into a single user story that covers all
|
|
313
|
+
of them. Write at least one Given/When/Then acceptance scenario per work unit. Write it as a
|
|
314
|
+
user story in Mike Cohn's As a / I want to / so that form, with Given/When/Then acceptance
|
|
315
|
+
criteria, the edge cases, and a Mermaid diagram where one genuinely helps.
|
|
278
316
|
|
|
279
317
|
Apply repository documentation and established conventions before finalizing the story.
|
|
280
318
|
Adhere to ${{ env.REPO_RULES }}.
|
|
281
319
|
|
|
282
|
-
|
|
320
|
+
5. Load `@humanizer` and prepare the complete replacement issue body as valid Markdown.
|
|
283
321
|
|
|
284
|
-
|
|
322
|
+
6. Decide exactly one outcome:
|
|
285
323
|
|
|
286
324
|
Labels are workflow-owned state. Do not call `add_labels` or `remove_labels`.
|
|
287
325
|
|
|
288
|
-
**Questions remain.**
|
|
326
|
+
**Questions remain.** You set aside one or more questions for the author that the codebase
|
|
327
|
+
could not answer. Leave the body unchanged. Call `add_comment` once with:
|
|
289
328
|
1. `${{ env.REFINE_MARKER }}`
|
|
290
329
|
2. `${{ env.SAFE_OUTPUT_COMMENT_PREFIX }}`
|
|
291
330
|
3. `I have some questions about this issue. Please reply in one comment and I'll process your answers.`
|
|
292
|
-
4. Every
|
|
331
|
+
4. Every set-aside question, gathered from all work units, immediately below it, each answerable in a sentence.
|
|
293
332
|
|
|
294
333
|
Write the questions in **plain business language, not technical jargon**. The person reading
|
|
295
334
|
them is a domain expert, not an engineer.
|
|
296
335
|
|
|
297
|
-
**The story is complete.**
|
|
336
|
+
**The story is complete.** You answered every exploration question yourself and none remain
|
|
337
|
+
for the author. Call `update_issue` with the replacement body and `add_comment`
|
|
298
338
|
with `${{ env.REFINE_MARKER }}`, then `${{ env.SAFE_OUTPUT_COMMENT_PREFIX }}`,
|
|
299
339
|
then exactly one of these messages, based only on the `labels` array in the supplied issue
|
|
300
340
|
context:
|
|
@@ -310,7 +350,8 @@ flowchart TD
|
|
|
310
350
|
refPick{"Issue eligible?"} -->|yes| refReserve
|
|
311
351
|
refPick -.->|no| refIdle
|
|
312
352
|
refReserve("Reserve<br/>bot-working") --> refFacts
|
|
313
|
-
refFacts("Facts<br/>Issue and comments to disk") -->
|
|
353
|
+
refFacts("Facts<br/>Issue and comments to disk") --> refExplore
|
|
354
|
+
refExplore("Explore<br/>ob-plan-explore per work unit,<br/>self-answer, bounded") --> refStory
|
|
314
355
|
refStory("Story<br/>/plan-story, grounded in the code") -->|✓| refProse
|
|
315
356
|
refStory -.->|✗| refFail
|
|
316
357
|
refProse("Prose<br/>@humanizer over the final text") -->|✓| refOutcome
|
|
@@ -330,7 +371,7 @@ flowchart TD
|
|
|
330
371
|
classDef success fill:#e8f8ec,stroke:#18883c,stroke-width:2px,color:#145a32
|
|
331
372
|
|
|
332
373
|
class refStart start
|
|
333
|
-
class refReserve,refFacts,refStory,refProse action
|
|
374
|
+
class refReserve,refFacts,refExplore,refStory,refProse action
|
|
334
375
|
class refPick,refOutcome decision
|
|
335
376
|
class refIdle idle
|
|
336
377
|
class refFail failure
|
|
@@ -11,7 +11,7 @@ description: |
|
|
|
11
11
|
|
|
12
12
|
Consumer-specific steps (NuGet, .NET restore, OpenSpec, etc.) should be added after the
|
|
13
13
|
shared baseline in the consumer copy. The merge step below is package-owned and required
|
|
14
|
-
for the agent to resolve
|
|
14
|
+
for the agent to resolve its provider and its models.
|
|
15
15
|
|
|
16
16
|
# Consumer repositories should add stack-specific steps (NuGet cache, dotnet restore,
|
|
17
17
|
# OpenSpec, Playwright, etc.) after the shared baseline. The merge step at the end is
|
|
@@ -21,15 +21,51 @@ pre-agent-steps:
|
|
|
21
21
|
- name: Create agent scratch directory
|
|
22
22
|
run: mkdir -p .opencode/.tmp
|
|
23
23
|
|
|
24
|
+
- name: Start OpenCode server (persistent warm server for faster agent runs)
|
|
25
|
+
run: |
|
|
26
|
+
set -euo pipefail
|
|
27
|
+
|
|
28
|
+
OPENCODE_PORT=4096
|
|
29
|
+
mkdir -p /tmp/opencode-data /tmp/gh-aw
|
|
30
|
+
|
|
31
|
+
# Check if server is already running
|
|
32
|
+
if curl -sf "http://127.0.0.1:${OPENCODE_PORT}/health" >/dev/null 2>&1; then
|
|
33
|
+
echo "OpenCode server already running on port ${OPENCODE_PORT}"
|
|
34
|
+
exit 0
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
echo "Starting OpenCode server on port ${OPENCODE_PORT}..."
|
|
38
|
+
export XDG_DATA_HOME=/tmp/opencode-data
|
|
39
|
+
nohup opencode serve --port "${OPENCODE_PORT}" --hostname 127.0.0.1 \
|
|
40
|
+
> /tmp/gh-aw/opencode-server.log 2>&1 &
|
|
41
|
+
|
|
42
|
+
SERVER_PID=$!
|
|
43
|
+
echo "Server PID: ${SERVER_PID}"
|
|
44
|
+
|
|
45
|
+
# Wait for server to be ready (max 30 seconds)
|
|
46
|
+
for i in $(seq 1 30); do
|
|
47
|
+
if curl -sf "http://127.0.0.1:${OPENCODE_PORT}/health" >/dev/null 2>&1; then
|
|
48
|
+
echo "OpenCode server is ready on port ${OPENCODE_PORT}"
|
|
49
|
+
exit 0
|
|
50
|
+
fi
|
|
51
|
+
sleep 1
|
|
52
|
+
done
|
|
53
|
+
|
|
54
|
+
# Server didn't start in time - continue without it (agent will cold-start)
|
|
55
|
+
echo "::warning::OpenCode server did not start in 30s, agent will run without warm server"
|
|
56
|
+
kill "${SERVER_PID}" 2>/dev/null || true
|
|
57
|
+
|
|
24
58
|
- name: Install ripgrep
|
|
25
59
|
run: |
|
|
26
60
|
set -euo pipefail
|
|
27
61
|
|
|
28
|
-
if
|
|
29
|
-
|
|
30
|
-
|
|
62
|
+
if command -v rg > /dev/null 2>&1; then
|
|
63
|
+
echo "ripgrep already installed: $(rg --version | head -1)"
|
|
64
|
+
exit 0
|
|
31
65
|
fi
|
|
32
66
|
|
|
67
|
+
sudo apt-get update
|
|
68
|
+
sudo apt-get install --yes ripgrep
|
|
33
69
|
rg --version
|
|
34
70
|
|
|
35
71
|
- name: Activate the pnpm version package.json pins
|
|
@@ -50,6 +86,11 @@ pre-agent-steps:
|
|
|
50
86
|
run: |
|
|
51
87
|
set -euo pipefail
|
|
52
88
|
|
|
89
|
+
if command -v rtk > /dev/null 2>&1 && rtk --version 2>/dev/null | grep -q "${RTK_VERSION}"; then
|
|
90
|
+
echo "RTK ${RTK_VERSION} already installed"
|
|
91
|
+
exit 0
|
|
92
|
+
fi
|
|
93
|
+
|
|
53
94
|
tarball="$RUNNER_TEMP/rtk.tar.gz"
|
|
54
95
|
curl -fsSL -o "$tarball" \
|
|
55
96
|
"https://github.com/rtk-ai/rtk/releases/download/v${RTK_VERSION}/rtk-x86_64-unknown-linux-musl.tar.gz"
|
|
@@ -64,6 +105,12 @@ pre-agent-steps:
|
|
|
64
105
|
- name: Install agentmemory
|
|
65
106
|
run: |
|
|
66
107
|
set -euo pipefail
|
|
108
|
+
|
|
109
|
+
if command -v agentmemory > /dev/null 2>&1 && agentmemory --version 2>/dev/null | grep -q "${AGENTMEMORY_VERSION}"; then
|
|
110
|
+
echo "agentmemory ${AGENTMEMORY_VERSION} already installed"
|
|
111
|
+
exit 0
|
|
112
|
+
fi
|
|
113
|
+
|
|
67
114
|
npm install -g "@agentmemory/agentmemory@${AGENTMEMORY_VERSION}"
|
|
68
115
|
agentmemory --version
|
|
69
116
|
|
|
@@ -71,26 +118,42 @@ pre-agent-steps:
|
|
|
71
118
|
continue-on-error: true
|
|
72
119
|
run: |
|
|
73
120
|
set -euo pipefail
|
|
74
|
-
|
|
121
|
+
|
|
122
|
+
if command -v codegraph > /dev/null 2>&1 && codegraph --version 2>/dev/null | grep -q "${CODEGRAPH_VERSION}"; then
|
|
123
|
+
echo "codegraph ${CODEGRAPH_VERSION} already installed"
|
|
124
|
+
else
|
|
125
|
+
npm install -g "@colbymchenry/codegraph@${CODEGRAPH_VERSION}"
|
|
126
|
+
fi
|
|
127
|
+
|
|
75
128
|
codegraph init
|
|
76
129
|
|
|
77
|
-
- name: Install
|
|
130
|
+
- name: Install OpenSpec CLI
|
|
78
131
|
run: |
|
|
79
132
|
set -euo pipefail
|
|
80
133
|
|
|
81
|
-
if
|
|
82
|
-
echo "
|
|
134
|
+
if command -v openspec > /dev/null 2>&1 && openspec --version 2>/dev/null | grep -q "1.8.0"; then
|
|
135
|
+
echo "openspec 1.8.0 already installed"
|
|
83
136
|
exit 0
|
|
84
137
|
fi
|
|
85
138
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
139
|
+
npm install -g "@fission-ai/openspec@1.8.0"
|
|
140
|
+
openspec --version
|
|
141
|
+
|
|
142
|
+
# NOTE: playwright-cli and SQL Server startup steps have been removed from the
|
|
143
|
+
# shared CI baseline. Visual evidence capture now runs in a separate "Visual
|
|
144
|
+
# evidence" workflow that executes on the raw runner (not inside the awf
|
|
145
|
+
# sandbox), where Docker and headless Chromium are available. The agent's
|
|
146
|
+
# /ops-evidence skill writes a capturePlan in evidence.json when blocked;
|
|
147
|
+
# the Visual Evidence workflow reads and executes it. If you need playwright-cli
|
|
148
|
+
# or SQL Server inside the agent sandbox for other reasons, add them as
|
|
149
|
+
# consumer-specific steps after the shared baseline.
|
|
150
|
+
|
|
151
|
+
- name: Cache NuGet packages
|
|
152
|
+
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
|
153
|
+
with:
|
|
154
|
+
path: ~/.nuget/packages
|
|
155
|
+
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.slnx', '**/Directory.Packages.props') }}
|
|
156
|
+
restore-keys: nuget-${{ runner.os }}-
|
|
94
157
|
|
|
95
158
|
- name: Install workspace dependencies
|
|
96
159
|
run: pnpm install --frozen-lockfile
|
|
@@ -9,6 +9,21 @@ name: "All Work Router"
|
|
|
9
9
|
# describe the trigger but not the route the classifier picks.
|
|
10
10
|
run-name: >-
|
|
11
11
|
${{ github.event_name == 'issues'
|
|
12
|
+
&& github.event.label.name == 'bot-working'
|
|
13
|
+
&& contains(github.event.issue.labels.*.name, 'implement')
|
|
14
|
+
&& format('Working (Implement): {0} (#{1})', github.event.issue.title, github.event.issue.number)
|
|
15
|
+
|| github.event_name == 'issues'
|
|
16
|
+
&& github.event.label.name == 'bot-working'
|
|
17
|
+
&& contains(github.event.issue.labels.*.name, 'refine')
|
|
18
|
+
&& format('Working (Refine): {0} (#{1})', github.event.issue.title, github.event.issue.number)
|
|
19
|
+
|| github.event_name == 'issues'
|
|
20
|
+
&& github.event.label.name == 'bot-working'
|
|
21
|
+
&& contains(github.event.issue.labels.*.name, 'direct')
|
|
22
|
+
&& format('Working (Direct): {0} (#{1})', github.event.issue.title, github.event.issue.number)
|
|
23
|
+
|| github.event_name == 'issues'
|
|
24
|
+
&& github.event.label.name == 'bot-working'
|
|
25
|
+
&& format('Working: {0} (#{1})', github.event.issue.title, github.event.issue.number)
|
|
26
|
+
|| github.event_name == 'issues'
|
|
12
27
|
&& format('{0} label on #{1} by {2}', github.event.label.name, github.event.issue.number, github.actor)
|
|
13
28
|
|| github.event_name == 'issue_comment'
|
|
14
29
|
&& format('comment on #{0} by {1}', github.event.issue.number, github.actor)
|
|
@@ -54,8 +69,7 @@ on:
|
|
|
54
69
|
- cron: "17 1 * * 1"
|
|
55
70
|
- cron: "43 3 * * *"
|
|
56
71
|
- cron: "0 6 * * *"
|
|
57
|
-
- cron: "*/
|
|
58
|
-
- cron: "0 */2 * * *"
|
|
72
|
+
- cron: "*/30 * * * *"
|
|
59
73
|
- cron: "29 7 * * *"
|
|
60
74
|
|
|
61
75
|
workflow_dispatch:
|
|
@@ -75,7 +89,6 @@ on:
|
|
|
75
89
|
- audit-close
|
|
76
90
|
- cleanup-artifacts
|
|
77
91
|
- reconcile-bot-pr-runs
|
|
78
|
-
- stale-recovery
|
|
79
92
|
- validate
|
|
80
93
|
issue-number:
|
|
81
94
|
description: "Issue number (required for refine / implement / direct)"
|
|
@@ -214,9 +227,41 @@ jobs:
|
|
|
214
227
|
BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
|
|
215
228
|
BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
216
229
|
|
|
217
|
-
|
|
230
|
+
# Checks whether an open bot PR already exists for the issue. If it does, implement is
|
|
231
|
+
# skipped — the merge-gate will handle fixing the existing PR. This prevents duplicate
|
|
232
|
+
# PRs when retries are triggered after a merge-gate failure.
|
|
233
|
+
check-implement-pr:
|
|
218
234
|
needs: [classify, authorize]
|
|
219
235
|
if: needs.classify.outputs.route == 'implement' && needs.authorize.outputs.trusted == 'true'
|
|
236
|
+
runs-on: RunnerLandingZone
|
|
237
|
+
timeout-minutes: 3
|
|
238
|
+
permissions:
|
|
239
|
+
contents: read
|
|
240
|
+
pull-requests: read
|
|
241
|
+
outputs:
|
|
242
|
+
has-open-pr: ${{ steps.check.outputs.has-open-pr }}
|
|
243
|
+
steps:
|
|
244
|
+
- name: Check for existing open bot PR
|
|
245
|
+
id: check
|
|
246
|
+
env:
|
|
247
|
+
GH_TOKEN: ${{ github.token }}
|
|
248
|
+
REPO: ${{ github.repository }}
|
|
249
|
+
ISSUE_NUMBER: ${{ needs.classify.outputs.issue-number }}
|
|
250
|
+
run: |
|
|
251
|
+
set -euo pipefail
|
|
252
|
+
existing=$(gh pr list --repo "$REPO" --state open \
|
|
253
|
+
--search "is:pr linked:issue $ISSUE_NUMBER" \
|
|
254
|
+
--json number,author --jq '[.[] | select(.author.login | test("[bot]$"))] | length')
|
|
255
|
+
if [ "$existing" -gt 0 ]; then
|
|
256
|
+
echo "has-open-pr=true" >> "$GITHUB_OUTPUT"
|
|
257
|
+
echo "::notice::Issue #$ISSUE_NUMBER already has an open bot PR. Skipping implement to prevent duplicates."
|
|
258
|
+
else
|
|
259
|
+
echo "has-open-pr=false" >> "$GITHUB_OUTPUT"
|
|
260
|
+
fi
|
|
261
|
+
|
|
262
|
+
call-implement:
|
|
263
|
+
needs: [classify, authorize, check-implement-pr]
|
|
264
|
+
if: needs.classify.outputs.route == 'implement' && needs.authorize.outputs.trusted == 'true' && needs.check-implement-pr.outputs.has-open-pr != 'true'
|
|
220
265
|
uses: ./.github/workflows/agent-implement.lock.yml
|
|
221
266
|
concurrency:
|
|
222
267
|
group: write-pipeline-${{ needs.classify.outputs.issue-number }}
|
|
@@ -490,29 +535,6 @@ jobs:
|
|
|
490
535
|
token: ${{ github.token }}
|
|
491
536
|
artifact-retention-days: ${{ vars.ARTIFACT_RETENTION_DAYS }}
|
|
492
537
|
|
|
493
|
-
stale-recovery:
|
|
494
|
-
needs: classify
|
|
495
|
-
if: needs.classify.outputs.route == 'stale-recovery'
|
|
496
|
-
runs-on: RunnerLandingZone
|
|
497
|
-
timeout-minutes: 15
|
|
498
|
-
concurrency:
|
|
499
|
-
group: stale-recovery
|
|
500
|
-
cancel-in-progress: false
|
|
501
|
-
permissions:
|
|
502
|
-
contents: read
|
|
503
|
-
issues: write
|
|
504
|
-
pull-requests: read
|
|
505
|
-
actions: write
|
|
506
|
-
steps:
|
|
507
|
-
- name: Checkout workflow actions
|
|
508
|
-
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
509
|
-
with:
|
|
510
|
-
persist-credentials: false
|
|
511
|
-
- uses: ./.github/actions/stale-recovery
|
|
512
|
-
with:
|
|
513
|
-
token: ${{ github.token }}
|
|
514
|
-
stale-threshold-hours: ${{ vars.STALE_THRESHOLD_HOURS }}
|
|
515
|
-
|
|
516
538
|
validate:
|
|
517
539
|
needs: classify
|
|
518
540
|
if: needs.classify.outputs.route == 'validate'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plainconceptsplatform/workflows",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.32",
|
|
4
4
|
"description": "Install and update Platform GitHub agentic workflows.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"github-actions",
|
|
@@ -40,4 +40,4 @@
|
|
|
40
40
|
"typescript": "^5.8.0",
|
|
41
41
|
"vitest": "^3.0.0"
|
|
42
42
|
}
|
|
43
|
-
}
|
|
43
|
+
}
|