@plainconceptsplatform/workflows 0.4.28 → 0.4.33

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.
@@ -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,11 +299,12 @@ 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" : template === "visual-evidence" ? "visual-evidence" : "agentics";
302
+ const directory = template.startsWith("opencode") ? "opencode" : template.startsWith("app-ci-") ? "ci" : template === "github-release" ? "release" : template === "visual-evidence" ? "visual-evidence" : template === "bug-report" || template === "feature-request" ? "issues" : "agentics";
281
303
  const isWorkflow = entry.file.endsWith(".yml");
282
- const target = template === "app-ci-dotnet-next"
304
+ const inferredTarget = template === "app-ci-dotnet-next"
283
305
  ? ".github/workflows/app-ci.yml"
284
306
  : isWorkflow ? `.github/workflows/${entry.file}` : entry.file;
307
+ const target = entry.target ?? inferredTarget;
285
308
  return { directory, file: entry.file, target };
286
309
  }
287
310
  async function catalogFiles(sourcePath) {
@@ -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 })));
@@ -384,6 +384,53 @@ describe("catalog installation", () => {
384
384
  installed: [".github/workflows/agentics-checks.yml"],
385
385
  });
386
386
  });
387
+ it("installs issue templates to .github/ISSUE_TEMPLATE/", async () => {
388
+ const sourcePath = await createDirectory({
389
+ "templates/issues/bug_report.yml": "name: Bug report\n",
390
+ "templates/issues/feature_request.yml": "name: Feature request\n",
391
+ });
392
+ const repositoryPath = await createDirectory({});
393
+ await expect(installTemplate(repositoryPath, "bug-report", { sourcePath })).resolves.toEqual({
394
+ installed: [".github/ISSUE_TEMPLATE/bug_report.yml"],
395
+ conflicts: [],
396
+ });
397
+ await expect(installTemplate(repositoryPath, "feature-request", { sourcePath })).resolves.toEqual({
398
+ installed: [".github/ISSUE_TEMPLATE/feature_request.yml"],
399
+ conflicts: [],
400
+ });
401
+ await expect(readFile(join(repositoryPath, ".github/ISSUE_TEMPLATE/bug_report.yml"), "utf8")).resolves.toBe("name: Bug report\n");
402
+ await expect(readFile(join(repositoryPath, ".github/ISSUE_TEMPLATE/feature_request.yml"), "utf8")).resolves.toBe("name: Feature request\n");
403
+ });
404
+ });
405
+ describe("route lifecycle", () => {
406
+ it("detects installed route workers in workflowRoutes order", async () => {
407
+ const repositoryPath = await createDirectory({
408
+ ".github/workflows/agent-implement.md": "# Implement\n",
409
+ ".github/workflows/agent-refine.md": "# Refine\n",
410
+ });
411
+ await expect(installedRoutes(repositoryPath)).resolves.toEqual(["refine", "implement"]);
412
+ });
413
+ it("returns an empty list when no route workers are installed", async () => {
414
+ const repositoryPath = await createDirectory({});
415
+ await expect(installedRoutes(repositoryPath)).resolves.toEqual([]);
416
+ });
417
+ it("removes a route worker and its generated lock, leaving other workers", async () => {
418
+ const repositoryPath = await createDirectory({
419
+ ".github/workflows/agent-refine.md": "# Refine\n",
420
+ ".github/workflows/agent-refine.lock.yml": "generated\n",
421
+ ".github/workflows/agent-implement.md": "# Implement\n",
422
+ });
423
+ await expect(removeRouteFiles(repositoryPath, ["refine"])).resolves.toEqual([
424
+ ".github/workflows/agent-refine.lock.yml",
425
+ ".github/workflows/agent-refine.md",
426
+ ]);
427
+ await expect(readFile(join(repositoryPath, ".github/workflows/agent-refine.md"), "utf8")).rejects.toThrow();
428
+ await expect(readFile(join(repositoryPath, ".github/workflows/agent-implement.md"), "utf8")).resolves.toBe("# Implement\n");
429
+ });
430
+ it("ignores routes that are not installed", async () => {
431
+ const repositoryPath = await createDirectory({});
432
+ await expect(removeRouteFiles(repositoryPath, ["propose"])).resolves.toEqual([]);
433
+ });
387
434
  });
388
435
  async function createDirectory(files) {
389
436
  const directory = await mkdtemp(join(tmpdir(), "workflows-"));
@@ -15,7 +15,7 @@ export async function listCatalog(options = {}) {
15
15
  name: template.name,
16
16
  description: template.description,
17
17
  file: template.file,
18
- installed: await isFileInstalled(basePath, template.file),
18
+ installed: await isFileInstalled(basePath, template.file, template.target),
19
19
  })));
20
20
  return [...routes, ...templates];
21
21
  }
@@ -52,8 +52,8 @@ function formatEntry(entry) {
52
52
  const mark = entry.installed ? "[x]" : "[ ]";
53
53
  return ` ${mark} ${entry.name} — ${entry.description}`;
54
54
  }
55
- async function isFileInstalled(basePath, workerOrTemplateFile) {
56
- const installedPath = templateInstallPath(basePath, workerOrTemplateFile);
55
+ async function isFileInstalled(basePath, workerOrTemplateFile, explicitTarget) {
56
+ const installedPath = templateInstallPath(basePath, workerOrTemplateFile, explicitTarget);
57
57
  try {
58
58
  await access(installedPath, constants.F_OK);
59
59
  return true;
@@ -62,7 +62,9 @@ async function isFileInstalled(basePath, workerOrTemplateFile) {
62
62
  return false;
63
63
  }
64
64
  }
65
- function templateInstallPath(basePath, file) {
65
+ function templateInstallPath(basePath, file, explicitTarget) {
66
+ if (explicitTarget !== undefined)
67
+ return join(basePath, ...explicitTarget.split("/"));
66
68
  const isRootTemplate = file.endsWith(".json");
67
69
  return isRootTemplate ? join(basePath, file) : join(basePath, ".github", "workflows", file);
68
70
  }
@@ -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", "visual-evidence"]);
19
+ expect(templateNames).toEqual(["agentics-checks", "agentics-maintenance", "app-ci-dotnet-next", "app-ci-node-monorepo", "bug-report", "feature-request", "github-release", "opencode.ci.json", "visual-evidence"]);
20
20
  });
21
21
  it("reports all entries as not installed in an empty repository", async () => {
22
22
  const repositoryPath = await createRepository({});
@@ -41,6 +41,19 @@ describe("catalog listing", () => {
41
41
  expect(checksEntry).toBeDefined();
42
42
  expect(checksEntry.installed).toBe(true);
43
43
  });
44
+ it("marks an issue template as installed when its file exists in .github/ISSUE_TEMPLATE/", async () => {
45
+ const repositoryPath = await createRepository({
46
+ ".github/ISSUE_TEMPLATE/bug_report.yml": "name: Bug report",
47
+ ".github/ISSUE_TEMPLATE/feature_request.yml": "name: Feature request",
48
+ });
49
+ const entries = await listCatalog({ installedPath: repositoryPath });
50
+ const bugEntry = entries.find((entry) => entry.name === "bug-report");
51
+ const featureEntry = entries.find((entry) => entry.name === "feature-request");
52
+ expect(bugEntry).toBeDefined();
53
+ expect(bugEntry.installed).toBe(true);
54
+ expect(featureEntry).toBeDefined();
55
+ expect(featureEntry.installed).toBe(true);
56
+ });
44
57
  it("marks the opencode.ci.json template as installed when the file exists at repository root", async () => {
45
58
  const repositoryPath = await createRepository({
46
59
  "opencode.ci.json": "{ \"model\": \"plainconcepts/glm-5-2\" }",
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
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
5
  import { routeNames, templateNames } from "./workflow-catalog.js";
6
6
  import { runInteractive } from "./tui.js";
@@ -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,12 +36,19 @@ 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).
41
48
  --template <name> Install a standalone template alongside or instead of routes.
42
49
  Templates: agentics-checks, agentics-maintenance,
43
50
  app-ci-dotnet-next, app-ci-node-monorepo,
51
+ bug-report, feature-request, github-release,
44
52
  opencode.ci.json.
45
53
  --force Overwrite managed files that differ from the package source.
46
54
  -h, --help Show this help text.
@@ -95,7 +103,8 @@ export async function run(arguments_, repositoryPath = process.cwd()) {
95
103
  const allConflicts = [];
96
104
  const allInstalled = [];
97
105
  if (routes.length > 0) {
98
- const result = await installCatalog(repositoryPath, { force, selectedRoutes: routes, inspection });
106
+ const selectedRoutes = unionRoutes(routes, await installedRoutes(repositoryPath));
107
+ const result = await installCatalog(repositoryPath, { force, selectedRoutes, inspection });
99
108
  allConflicts.push(...result.conflicts);
100
109
  allInstalled.push(...result.installed);
101
110
  }
@@ -116,8 +125,36 @@ export async function run(arguments_, repositoryPath = process.cwd()) {
116
125
  console.log(JSON.stringify({ command, installed: allInstalled.sort(), conflicts: allConflicts }, null, 2));
117
126
  return 0;
118
127
  }
128
+ if (command === "remove") {
129
+ const parsed = parseAddOptions(options);
130
+ if (parsed.kind === "invalid")
131
+ return fail(parsed.message);
132
+ if (parsed.template !== undefined)
133
+ return fail("remove does not accept --template.");
134
+ if (parsed.routes.length === 0)
135
+ return fail("remove requires at least one route.");
136
+ const inspection = await inspectRepository(repositoryPath);
137
+ const installed = await installedRoutes(repositoryPath);
138
+ const desiredRoutes = installed.filter((route) => !parsed.routes.includes(route));
139
+ const result = await installCatalog(repositoryPath, { force: parsed.force, selectedRoutes: desiredRoutes, inspection });
140
+ if (result.conflicts.length > 0 && !parsed.force) {
141
+ console.error(`Catalog conflicts found. Re-run with --force to overwrite package-managed files:\n${result.conflicts.join("\n")}`);
142
+ return 1;
143
+ }
144
+ const removed = await removeRouteFiles(repositoryPath, parsed.routes);
145
+ console.log(JSON.stringify({ command, installed: [...result.installed].sort(), removed, conflicts: result.conflicts }, null, 2));
146
+ return 0;
147
+ }
119
148
  return fail(`Unknown command: ${command}`);
120
149
  }
150
+ function unionRoutes(requested, installed) {
151
+ const result = [...requested];
152
+ for (const route of installed) {
153
+ if (!result.includes(route))
154
+ result.push(route);
155
+ }
156
+ return result;
157
+ }
121
158
  function readVisibilityOption(options) {
122
159
  if (options.length === 0)
123
160
  return undefined;
@@ -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|github-release|opencode.ci.json|visual-evidence.");
37
+ expect(error).toHaveBeenCalledWith("--template must be one of: agentics-checks|agentics-maintenance|app-ci-dotnet-next|app-ci-node-monorepo|bug-report|feature-request|github-release|opencode.ci.json|visual-evidence.");
38
38
  error.mockRestore();
39
39
  });
40
40
  it("rejects an unknown route passed to add", async () => {
@@ -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 + 7 templates = 14 entries
68
+ // 7 routes + 9 templates = 16 entries
69
69
  const uninstalledCount = (output.match(/\[ \]/g) ?? []).length;
70
- expect(uninstalledCount).toBe(14);
70
+ expect(uninstalledCount).toBe(16);
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 routes = items.filter((entry) => entry.kind === "route");
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 selectedRouteNames = routes.length > 0
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 (routes.length > 0) {
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
- console.log(`Installed ${allInstalled.length} item(s):`);
256
- for (const file of allInstalled) {
257
- console.log(` ${file}`);
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,11 +14,12 @@ export interface MandatoryFile {
14
14
  }
15
15
  export declare const mandatoryFiles: readonly MandatoryFile[];
16
16
  export declare const generatedConsumerTargets: readonly [".github/workflows/agent-*.lock.yml", ".github/aw/actions-lock.json"];
17
- export declare const templateNames: readonly ["agentics-checks", "agentics-maintenance", "app-ci-dotnet-next", "app-ci-node-monorepo", "github-release", "opencode.ci.json", "visual-evidence"];
17
+ export declare const templateNames: readonly ["agentics-checks", "agentics-maintenance", "app-ci-dotnet-next", "app-ci-node-monorepo", "bug-report", "feature-request", "github-release", "opencode.ci.json", "visual-evidence"];
18
18
  export type TemplateName = (typeof templateNames)[number];
19
19
  export interface CatalogTemplate {
20
20
  readonly name: TemplateName;
21
21
  readonly file: string;
22
22
  readonly description: string;
23
+ readonly target?: string;
23
24
  }
24
25
  export declare const catalogTemplates: readonly CatalogTemplate[];
@@ -38,6 +38,8 @@ export const templateNames = [
38
38
  "agentics-maintenance",
39
39
  "app-ci-dotnet-next",
40
40
  "app-ci-node-monorepo",
41
+ "bug-report",
42
+ "feature-request",
41
43
  "github-release",
42
44
  "opencode.ci.json",
43
45
  "visual-evidence",
@@ -47,6 +49,8 @@ export const catalogTemplates = [
47
49
  { name: "agentics-maintenance", file: "agentics-maintenance.yml", description: "Agentic maintenance: scheduled daily maintenance workflow for keeping workflows and actions up to date." },
48
50
  { name: "app-ci-dotnet-next", file: "app-ci-dotnet-next.yml", description: "App CI pipeline for a .NET + Next.js monorepo: build, test, and lint on PRs and schedule." },
49
51
  { 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." },
52
+ { name: "bug-report", file: "bug_report.yml", description: "Bug report issue template: what happened, repro steps, expected behavior, acceptance criteria, environment, logs.", target: ".github/ISSUE_TEMPLATE/bug_report.yml" },
53
+ { name: "feature-request", file: "feature_request.yml", description: "Feature request issue template scoped to small, well-scoped improvements (Small/Medium only; large work belongs in a planning issue).", target: ".github/ISSUE_TEMPLATE/feature_request.yml" },
50
54
  { name: "github-release", file: "github-release.yml", description: "Publishes or updates a GitHub Release with generated notes whenever a v* tag is pushed." },
51
55
  { 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
56
  { name: "visual-evidence", file: "visual-evidence.yml", description: "Visual evidence: captures screenshots of UI changes on bot-authored PRs by reading the capturePlan left by the agent in evidence.json and executing it on a runner with Docker and Chrome access." },
@@ -17,7 +17,7 @@ describe("workflow catalog", () => {
17
17
  }
18
18
  });
19
19
  it("lists supported optional templates", () => {
20
- expect(templateNames).toEqual(["agentics-checks", "agentics-maintenance", "app-ci-dotnet-next", "app-ci-node-monorepo", "github-release", "opencode.ci.json", "visual-evidence"]);
20
+ expect(templateNames).toEqual(["agentics-checks", "agentics-maintenance", "app-ci-dotnet-next", "app-ci-node-monorepo", "bug-report", "feature-request", "github-release", "opencode.ci.json", "visual-evidence"]);
21
21
  });
22
22
  it("gives every catalog template a non-empty description and file", () => {
23
23
  expect(catalogTemplates.map((template) => template.name)).toEqual([...templateNames]);
@@ -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 * * * *"
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.
@@ -136,8 +135,6 @@ classify_route() {
136
135
  "$AUDIT_CLOSE_CRON") route="audit-close" ;;
137
136
  "$CLEANUP_ARTIFACTS_CRON") route="cleanup-artifacts" ;;
138
137
  "$RECONCILE_BOT_PR_RUNS_CRON") route="reconcile-bot-pr-runs" ;;
139
- "$STALE_RECOVERY_CRON") route="stale-recovery" ;;
140
- "$PROPOSE_CRON") route="propose" ;;
141
138
  *) error="no route for cron '${SCHEDULE:-}'" ;;
142
139
  esac
143
140
  ;;
@@ -180,7 +177,7 @@ classify_route() {
180
177
  route="${OPERATION}"
181
178
  trigger_kind="${INPUT_TRIGGER_KIND:-manual}"
182
179
  ;;
183
- audit-close | cleanup-artifacts | reconcile-bot-pr-runs | stale-recovery | validate)
180
+ audit-close | cleanup-artifacts | reconcile-bot-pr-runs | validate)
184
181
  route="${OPERATION}"
185
182
  ;;
186
183
  *)
@@ -0,0 +1,109 @@
1
+ name: Bug report
2
+ description: Report a defect
3
+ labels: ["bug"]
4
+ body:
5
+ - type: markdown
6
+ attributes:
7
+ value: |
8
+ Thanks for reporting. Do **not** include real secrets or production data — use placeholders.
9
+
10
+ For security vulnerabilities, contact the team directly instead of filing a public issue.
11
+ - type: dropdown
12
+ id: severity
13
+ attributes:
14
+ label: Severity
15
+ options:
16
+ - Production-down / data loss
17
+ - Major — broken core flow
18
+ - Minor — broken edge case
19
+ - Cosmetic / polish
20
+ validations:
21
+ required: true
22
+ - type: dropdown
23
+ id: frequency
24
+ attributes:
25
+ label: Frequency
26
+ description: How often does this happen?
27
+ options:
28
+ - Always
29
+ - Intermittent
30
+ - Only once
31
+ validations:
32
+ required: true
33
+ - type: textarea
34
+ id: what-happened
35
+ attributes:
36
+ label: What happened?
37
+ description: A clear description of the bug and its impact.
38
+ validations:
39
+ required: true
40
+ - type: textarea
41
+ id: repro
42
+ attributes:
43
+ label: Steps to reproduce
44
+ description: How can a maintainer reproduce it?
45
+ placeholder: |
46
+ 1. Go to ...
47
+ 2. Do ...
48
+ 3. See error
49
+ validations:
50
+ required: true
51
+ - type: textarea
52
+ id: expected
53
+ attributes:
54
+ label: Expected behavior
55
+ validations:
56
+ required: true
57
+ - type: dropdown
58
+ id: area
59
+ attributes:
60
+ label: Where does it occur?
61
+ options:
62
+ - Backend
63
+ - Frontend
64
+ - Both
65
+ - CLI / tooling
66
+ - Configuration / deployment
67
+ - Documentation
68
+ - Other
69
+ validations:
70
+ required: true
71
+ - type: textarea
72
+ id: acceptance
73
+ attributes:
74
+ label: Acceptance criteria
75
+ description: How will we know it's fixed? A checklist a reviewer can verify (include the test that should turn green).
76
+ placeholder: |
77
+ - [ ] ...
78
+ - [ ] A test that fails before the fix and passes after
79
+ validations:
80
+ required: false
81
+ - type: textarea
82
+ id: open-questions
83
+ attributes:
84
+ label: Open questions
85
+ description: Decisions a maintainer must make before this can be planned. Leave blank if none.
86
+ validations:
87
+ required: false
88
+ - type: textarea
89
+ id: environment
90
+ attributes:
91
+ label: Environment
92
+ description: Local vs deployed, branch/commit, OS, runtime versions if relevant.
93
+ validations:
94
+ required: false
95
+ - type: textarea
96
+ id: logs
97
+ attributes:
98
+ label: Logs / screenshots
99
+ description: Paste relevant logs or screenshots. Redact secrets and PII.
100
+ render: shell
101
+ validations:
102
+ required: false
103
+ - type: textarea
104
+ id: related
105
+ attributes:
106
+ label: Related links
107
+ description: Links to related issues, PRs, discussions, or docs. Leave blank if none.
108
+ validations:
109
+ required: false
@@ -0,0 +1,75 @@
1
+ name: Feature request
2
+ description: Propose a small improvement or new capability
3
+ labels: ["enhancement"]
4
+ body:
5
+ - type: markdown
6
+ attributes:
7
+ value: |
8
+ This template is for small, well-scoped improvements — a focused
9
+ enhancement, a missing convenience, a quality-of-life fix.
10
+
11
+ For large features, architecture changes, or cross-cutting work,
12
+ open a planning issue or an ADR instead.
13
+
14
+ Do **not** include real secrets or production data — use placeholders.
15
+ - type: dropdown
16
+ id: scope
17
+ attributes:
18
+ label: Scope
19
+ description: How big is this change? If it doesn't fit Small or Medium, use a planning issue instead.
20
+ options:
21
+ - Small — a single file or component, no refactoring
22
+ - Medium — touches a few files or a feature area, may need minor refactoring
23
+ validations:
24
+ required: true
25
+ - type: textarea
26
+ id: current-behavior
27
+ attributes:
28
+ label: Current behavior
29
+ description: How does it work today? Describe the existing behavior or limitation.
30
+ validations:
31
+ required: true
32
+ - type: textarea
33
+ id: problem
34
+ attributes:
35
+ label: Problem / motivation
36
+ description: What user or business need does this address? Why is the current behavior a problem?
37
+ validations:
38
+ required: true
39
+ - type: textarea
40
+ id: proposal
41
+ attributes:
42
+ label: Proposed solution
43
+ description: What would you like to happen?
44
+ validations:
45
+ required: true
46
+ - type: textarea
47
+ id: alternatives
48
+ attributes:
49
+ label: Alternatives considered
50
+ validations:
51
+ required: false
52
+ - type: textarea
53
+ id: acceptance
54
+ attributes:
55
+ label: Acceptance criteria
56
+ description: A checklist a reviewer can verify when this is done (include the tests).
57
+ placeholder: |
58
+ - [ ] ...
59
+ - [ ] Tests
60
+ validations:
61
+ required: false
62
+ - type: textarea
63
+ id: open-questions
64
+ attributes:
65
+ label: Open questions
66
+ description: Decisions a maintainer must make before this can be planned. Leave blank if none.
67
+ validations:
68
+ required: false
69
+ - type: textarea
70
+ id: related
71
+ attributes:
72
+ label: Related links
73
+ description: Links to related issues, PRs, discussions, or docs. Leave blank if none.
74
+ validations:
75
+ required: false
@@ -277,14 +277,35 @@ timeout-minutes: 90
277
277
  If a check fails, fix the cause and rerun. Do not weaken a test, lower a threshold, or skip
278
278
  a check to make it pass.
279
279
 
280
- 5. Before creating the pull request, update `changelog.json` in the project's
281
- `src/shared/data/` folder (create `src/shared/data/changelog.json` if it does not
282
- exist; in a monorepo use `apps/web/src/shared/data/changelog.json`). The file
283
- has shape `{"version":1,"changes":[...]}`. Use `jq` to prepend a new entry
284
- with `"timestamp"` (ISO 8601), `"issue"` (number), `"title"` (issue title),
285
- `"summary"` (1-2 sentences of what you changed), and `"commit"` (short SHA).
286
- Keep at most 10 entries: if there are already 10, drop the oldest. Commit
287
- this file as part of the same branch before creating the PR.
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.
288
309
 
289
310
  6. You **must** call exactly one safe-output tool before finishing, or the workflow
290
311
  reports a failure. All safe-output tools are on the `safeoutputs` MCP server. Call
@@ -296,11 +317,14 @@ timeout-minutes: 90
296
317
 
297
318
  Choose exactly one:
298
319
 
299
- - **`safeoutputs/create_pull_request`** , propose a pull request against `main` with
300
- the verified changes. Its `body` must close the issue
301
- (`Closes #${{ inputs.issue-number }}`) and summarise what changed and why.
302
- This is the normal path.
303
- - **`safeoutputs/report_incomplete`** , use only when infrastructure or tooling
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
304
328
  prevents you from completing the task (e.g. the codebase cannot build due to a
305
329
  pre-existing error you cannot fix). Provide a specific `reason`.
306
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. Per #167
446
- decision 2, a human decides from there.
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: 300
238
- max-turn-cache-misses: 3000
239
- max-ai-credits: 5000
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: 30
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-story"), then run `/plan-story` for the issue. Ground the story in the actual
274
- codebase by reading the relevant files. Never read outside this repository root. Write it as
275
- a user story in Mike Cohn's As a / I want to / so that form, with
276
- Given/When/Then acceptance criteria, the edge cases, and a Mermaid diagram where one
277
- genuinely helps.
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
- 4. Load `@humanizer` and prepare the complete replacement issue body as valid Markdown.
320
+ 5. Load `@humanizer` and prepare the complete replacement issue body as valid Markdown.
283
321
 
284
- 5. Decide exactly one outcome:
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.** Leave the body unchanged. Call `add_comment` once with:
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 clarification question immediately below it, each answerable in a sentence.
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.** Call `update_issue` with the replacement body and `add_comment`
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") --> refStory
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
@@ -69,8 +69,7 @@ on:
69
69
  - cron: "17 1 * * 1"
70
70
  - cron: "43 3 * * *"
71
71
  - cron: "0 6 * * *"
72
- - cron: "*/15 * * * *"
73
- - cron: "0 */2 * * *"
72
+ - cron: "*/30 * * * *"
74
73
  - cron: "29 7 * * *"
75
74
 
76
75
  workflow_dispatch:
@@ -90,7 +89,6 @@ on:
90
89
  - audit-close
91
90
  - cleanup-artifacts
92
91
  - reconcile-bot-pr-runs
93
- - stale-recovery
94
92
  - validate
95
93
  issue-number:
96
94
  description: "Issue number (required for refine / implement / direct)"
@@ -229,9 +227,41 @@ jobs:
229
227
  BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
230
228
  BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
231
229
 
232
- call-implement:
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:
233
234
  needs: [classify, authorize]
234
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'
235
265
  uses: ./.github/workflows/agent-implement.lock.yml
236
266
  concurrency:
237
267
  group: write-pipeline-${{ needs.classify.outputs.issue-number }}
@@ -505,29 +535,6 @@ jobs:
505
535
  token: ${{ github.token }}
506
536
  artifact-retention-days: ${{ vars.ARTIFACT_RETENTION_DAYS }}
507
537
 
508
- stale-recovery:
509
- needs: classify
510
- if: needs.classify.outputs.route == 'stale-recovery'
511
- runs-on: RunnerLandingZone
512
- timeout-minutes: 15
513
- concurrency:
514
- group: stale-recovery
515
- cancel-in-progress: false
516
- permissions:
517
- contents: read
518
- issues: write
519
- pull-requests: read
520
- actions: write
521
- steps:
522
- - name: Checkout workflow actions
523
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
524
- with:
525
- persist-credentials: false
526
- - uses: ./.github/actions/stale-recovery
527
- with:
528
- token: ${{ github.token }}
529
- stale-threshold-hours: ${{ vars.STALE_THRESHOLD_HOURS }}
530
-
531
538
  validate:
532
539
  needs: classify
533
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.28",
3
+ "version": "0.4.33",
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
+ }