@plainconceptsplatform/workflows 0.4.32 → 0.4.34

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