@plainconceptsplatform/workflows 0.4.34 → 0.6.1

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 (61) hide show
  1. package/README.md +88 -88
  2. package/dist/action-validation.test.js +5 -2
  3. package/dist/catalog-installation.js +50 -7
  4. package/dist/catalog-installation.test.js +59 -22
  5. package/dist/catalog-listing.test.js +4 -4
  6. package/dist/index.js +48 -48
  7. package/dist/index.test.js +9 -9
  8. package/dist/repository-inspection.test.js +2 -2
  9. package/dist/route-processing.js +24 -25
  10. package/dist/route-processing.test.js +166 -233
  11. package/dist/stack-defaults.js +14 -14
  12. package/dist/stack-defaults.test.js +79 -79
  13. package/dist/tui.test.js +10 -7
  14. package/dist/workflow-catalog.d.ts +1 -1
  15. package/dist/workflow-catalog.js +1 -5
  16. package/loops/actions/add-issue-labels/action.yml +2 -2
  17. package/loops/actions/agent-output.cjs +17 -17
  18. package/loops/actions/apply-agent-bundle/apply-bundle.sh +14 -1
  19. package/loops/actions/audit-close/action.yml +24 -0
  20. package/loops/actions/classify-route/action.yml +8 -3
  21. package/loops/actions/classify-route/classify-route.sh +58 -38
  22. package/loops/actions/cleanup-artifacts/action.yml +38 -12
  23. package/loops/actions/identify-gate-subject/action.yml +3 -1
  24. package/loops/actions/remove-issue-labels/action.yml +4 -2
  25. package/loops/actions/update-changelog/action.yml +113 -0
  26. package/loops/actions/validate-merge-gate-output/action.yml +40 -40
  27. package/loops/actions/validate-refine-output/validate-refine-output.sh +34 -5
  28. package/loops/actions/validate-review-output/action.yml +35 -35
  29. package/loops/actions/validate-triage-output/action.yml +36 -36
  30. package/loops/actions/verify-refine-output/verify-refine-output.sh +20 -0
  31. package/loops/actions/verify-route-matrix/verify-route-matrix.sh +375 -36
  32. package/loops/scripts/compile-agent-workflows.mjs +290 -6
  33. package/loops/scripts/merge-changelog.mjs +76 -0
  34. package/loops/templates/agentics/actionlint.yaml +13 -0
  35. package/loops/templates/agentics/agentics-checks.yml +125 -8
  36. package/loops/templates/agentics/agentics-maintenance.yml +121 -121
  37. package/loops/templates/ci/app-ci-dotnet-next.yml +330 -171
  38. package/loops/templates/ci/app-ci-node-monorepo.yml +260 -178
  39. package/loops/templates/issues/bug_report.yml +109 -109
  40. package/loops/templates/issues/feature_request.yml +75 -75
  41. package/loops/templates/opencode/opencode.ci.json +49 -47
  42. package/loops/templates/opencode/opencode.ci.json.md +49 -41
  43. package/loops/templates/release/github-release.yml +30 -30
  44. package/loops/workflows/agent-apply-review.md +469 -443
  45. package/loops/workflows/agent-audit.md +213 -197
  46. package/loops/workflows/agent-implement.md +640 -414
  47. package/loops/workflows/agent-merge-gate.md +844 -584
  48. package/loops/workflows/agent-refine.md +633 -418
  49. package/loops/workflows/agent-release.md +258 -0
  50. package/loops/workflows/agent-triage.md +447 -439
  51. package/loops/workflows/authorize-bot-work.yml +85 -82
  52. package/loops/workflows/shared/opencode-ci.md +206 -197
  53. package/loops/workflows/shared/platform-defaults.md +19 -16
  54. package/loops/workflows/work-router.yml +1038 -632
  55. package/package.json +7 -8
  56. package/dist/repository-state.d.ts +0 -18
  57. package/dist/repository-state.js +0 -77
  58. package/dist/repository-state.test.d.ts +0 -1
  59. package/dist/repository-state.test.js +0 -96
  60. package/loops/workflows/agent-direct.md +0 -375
  61. package/loops/workflows/agent-propose.md +0 -342
@@ -65,10 +65,10 @@ describe("generateStackDefaults", () => {
65
65
  });
66
66
  describe("injectStackEnv", () => {
67
67
  it("injects VERIFY_COMMANDS into a worker that has an env block", () => {
68
- const content = `---
69
- env:
70
- REPO_RULES: "some rules"
71
- description: test
68
+ const content = `---
69
+ env:
70
+ REPO_RULES: "some rules"
71
+ description: test
72
72
  ---`;
73
73
  const defaults = generateStackDefaults(makeInspection({
74
74
  solutionFiles: ["app.slnx"],
@@ -77,9 +77,9 @@ description: test
77
77
  expect(result).toContain('VERIFY_COMMANDS: "dotnet restore && dotnet build -c Release --no-restore && dotnet test"');
78
78
  });
79
79
  it("injects pnpm verification when no .slnx is present", () => {
80
- const content = `---
81
- env:
82
- REPO_RULES: "some rules"
80
+ const content = `---
81
+ env:
82
+ REPO_RULES: "some rules"
83
83
  ---`;
84
84
  const defaults = generateStackDefaults(makeInspection({
85
85
  pnpmLockfile: true,
@@ -88,47 +88,47 @@ env:
88
88
  expect(result).toContain('VERIFY_COMMANDS: "pnpm verify"');
89
89
  });
90
90
  });
91
- const OPENCODE_CI_MD = `---
92
- env:
93
- AGENTMEMORY_VERSION: "0.9.28"
94
- CODEGRAPH_VERSION: "1.5.0"
95
- description: Shared CI setup.
96
-
97
- pre-agent-steps:
98
- - name: Install RTK
99
- run: |
100
- rtk --version
101
- rtk init -g --opencode --auto-patch
102
- - name: Install opencode plugin dependencies
103
- run: |
104
- set -euo pipefail
105
- if [ ! -f .opencode/package.json ]; then
106
- echo "No .opencode/package.json, nothing to install"
107
- exit 0
108
- fi
109
- - name: Install workspace dependencies
110
- run: pnpm install --frozen-lockfile
111
- - name: Merge the CI-only OpenCode provider into opencode.jsonc
112
- run: |
113
- jq -e . "$FRAGMENT" > /dev/null
91
+ const OPENCODE_CI_MD = `---
92
+ env:
93
+ AGENTMEMORY_VERSION: "0.9.28"
94
+ CODEGRAPH_VERSION: "1.5.0"
95
+ description: Shared CI setup.
96
+
97
+ pre-agent-steps:
98
+ - name: Install RTK
99
+ run: |
100
+ rtk --version
101
+ rtk init -g --opencode --auto-patch
102
+ - name: Install opencode plugin dependencies
103
+ run: |
104
+ set -euo pipefail
105
+ if [ ! -f .opencode/package.json ]; then
106
+ echo "No .opencode/package.json, nothing to install"
107
+ exit 0
108
+ fi
109
+ - name: Install workspace dependencies
110
+ run: pnpm install --frozen-lockfile
111
+ - name: Merge the CI-only OpenCode provider into opencode.jsonc
112
+ run: |
113
+ jq -e . "$FRAGMENT" > /dev/null
114
114
  ---`;
115
115
  describe("generateOpencodeCi", () => {
116
116
  it("adds NuGet cache and restores detected solution when .slnx is found", () => {
117
117
  const result = generateOpencodeCi(OPENCODE_CI_MD, makeInspection({
118
- solutionFiles: ["apps/api/Numa.slnx"],
118
+ solutionFiles: ["apps/api/Contoso.slnx"],
119
119
  }));
120
120
  expect(result).toContain("Cache NuGet packages");
121
121
  expect(result).toContain("Restore .NET dependencies");
122
- expect(result).toContain("dotnet restore apps/api/Numa.slnx");
122
+ expect(result).toContain("dotnet restore apps/api/Contoso.slnx");
123
123
  expect(result).toContain("\n - name: Restore .NET dependencies\n");
124
124
  expect(result).not.toContain("\n - name: Restore .NET dependencies\n");
125
125
  });
126
126
  it("uses POSIX separators for .NET restore commands", () => {
127
127
  const result = generateOpencodeCi(OPENCODE_CI_MD, makeInspection({
128
- solutionFiles: ["apps\\api\\Numa.slnx"],
128
+ solutionFiles: ["apps\\api\\Contoso.slnx"],
129
129
  }));
130
- expect(result).toContain("dotnet restore apps/api/Numa.slnx");
131
- expect(result).not.toContain("dotnet restore apps\\api\\Numa.slnx");
130
+ expect(result).toContain("dotnet restore apps/api/Contoso.slnx");
131
+ expect(result).not.toContain("dotnet restore apps\\api\\Contoso.slnx");
132
132
  });
133
133
  it("adds the pinned OpenSpec CLI install step when openspec/ directory exists", () => {
134
134
  const result = generateOpencodeCi(OPENCODE_CI_MD, makeInspection({
@@ -160,49 +160,49 @@ describe("generateOpencodeCi", () => {
160
160
  expect(result).toContain("Merge the CI-only OpenCode provider");
161
161
  });
162
162
  });
163
- const OPENCODE_CI_JSON = `{
164
- "$schema": "https://opencode.ai/config.json",
165
- "model": "plainconcepts/glm-5-2",
166
- "plugin": [],
167
- "default_agent": "ci-workflow-agent",
168
- "agent": {
169
- "ci-workflow-agent": {
170
- "description": "Executes GitHub Agentic Workflow tasks in CI.",
171
- "mode": "primary",
172
- "prompt": "You execute the GitHub Agentic Workflow task in the user prompt."
173
- }
174
- },
175
- "permission": {
176
- "read": "allow"
177
- },
178
- "lsp": {
179
- "csharp": {
180
- "disabled": true
181
- },
182
- "fsharp": {
183
- "disabled": true
184
- },
185
- "razor": {
186
- "disabled": true
187
- }
188
- },
189
- "provider": {
190
- "plainconcepts": {
191
- "api": "http://172.30.0.30:10000",
192
- "options": {
193
- "apiKey": "awf-openai-proxy"
194
- },
195
- "models": {
196
- "glm-5-2": {
197
- "name": "GLM 5.2"
198
- },
199
- "glm-5-1": {
200
- "name": "GLM 5.1"
201
- }
202
- }
203
- }
204
- }
205
- }
163
+ const OPENCODE_CI_JSON = `{
164
+ "$schema": "https://opencode.ai/config.json",
165
+ "model": "plainconcepts/glm-5-3",
166
+ "plugin": [],
167
+ "default_agent": "ci-workflow-agent",
168
+ "agent": {
169
+ "ci-workflow-agent": {
170
+ "description": "Executes GitHub Agentic Workflow tasks in CI.",
171
+ "mode": "primary",
172
+ "prompt": "You execute the GitHub Agentic Workflow task in the user prompt."
173
+ }
174
+ },
175
+ "permission": {
176
+ "read": "allow"
177
+ },
178
+ "lsp": {
179
+ "csharp": {
180
+ "disabled": true
181
+ },
182
+ "fsharp": {
183
+ "disabled": true
184
+ },
185
+ "razor": {
186
+ "disabled": true
187
+ }
188
+ },
189
+ "provider": {
190
+ "plainconcepts": {
191
+ "api": "http://172.30.0.30:10000",
192
+ "options": {
193
+ "apiKey": "awf-openai-proxy"
194
+ },
195
+ "models": {
196
+ "glm-5-3": {
197
+ "name": "GLM 5.3"
198
+ },
199
+ "glm-5-2": {
200
+ "name": "GLM 5.2"
201
+ }
202
+ }
203
+ }
204
+ }
205
+ }
206
206
  `;
207
207
  describe("generateOpencodeConfig", () => {
208
208
  it("keeps LSP section when .slnx is present", () => {
@@ -243,8 +243,8 @@ describe("generateOpencodeConfig", () => {
243
243
  solutionFiles: ["app.slnx"],
244
244
  }));
245
245
  const parsed = JSON.parse(result);
246
+ expect(parsed.provider.plainconcepts.models["glm-5-3"]).toBeDefined();
246
247
  expect(parsed.provider.plainconcepts.models["glm-5-2"]).toBeDefined();
247
- expect(parsed.provider.plainconcepts.models["glm-5-1"]).toBeDefined();
248
248
  });
249
249
  it("preserves the plainconcepts provider and its API URL", () => {
250
250
  const result = generateOpencodeConfig(OPENCODE_CI_JSON, makeInspection({
package/dist/tui.test.js CHANGED
@@ -10,19 +10,22 @@ function makeEntry(name, kind, installed = false) {
10
10
  };
11
11
  }
12
12
  function makeEntries(installed = []) {
13
- const routes = ["refine", "implement", "direct", "triage", "apply-review", "merge-gate", "audit", "propose"];
13
+ const routes = ["refine", "implement", "triage", "apply-review", "merge-gate", "audit"];
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))),
17
17
  ...templates.map((name) => makeEntry(name, "template", installed.includes(name))),
18
18
  ];
19
19
  }
20
+ // Counts derive from the fixture, so adding or removing a route above cannot silently
21
+ // desynchronise five assertions again.
22
+ const FIXTURE_COUNT = makeEntries().length;
20
23
  describe("createSelectionState", () => {
21
24
  it("returns all items as visible initially", () => {
22
25
  const entries = makeEntries();
23
26
  const state = createSelectionState(entries);
24
- expect(state.allItems).toHaveLength(13);
25
- expect(state.visibleItems).toHaveLength(13);
27
+ expect(state.allItems).toHaveLength(FIXTURE_COUNT);
28
+ expect(state.visibleItems).toHaveLength(FIXTURE_COUNT);
26
29
  });
27
30
  it("pre-selects installed items", () => {
28
31
  const entries = makeEntries(["refine", "agentics-checks"]);
@@ -73,7 +76,7 @@ describe("fuzzyMatch", () => {
73
76
  describe("filterItems", () => {
74
77
  const entries = makeEntries();
75
78
  it("returns all items when filter is empty", () => {
76
- expect(filterItems(entries, "")).toHaveLength(13);
79
+ expect(filterItems(entries, "")).toHaveLength(FIXTURE_COUNT);
77
80
  });
78
81
  it("filters by name", () => {
79
82
  const result = filterItems(entries, "refine");
@@ -82,7 +85,7 @@ describe("filterItems", () => {
82
85
  });
83
86
  it("filters by description", () => {
84
87
  const result = filterItems(entries, "description");
85
- expect(result).toHaveLength(13);
88
+ expect(result).toHaveLength(FIXTURE_COUNT);
86
89
  });
87
90
  it("filters by fuzzy subsequence over name and description", () => {
88
91
  const result = filterItems(entries, "acdn");
@@ -125,7 +128,7 @@ describe("clearFilter", () => {
125
128
  let state = applyFilter(createSelectionState(makeEntries()), "ref");
126
129
  state = clearFilter(state);
127
130
  expect(state.filter).toBe("");
128
- expect(state.visibleItems).toHaveLength(13);
131
+ expect(state.visibleItems).toHaveLength(FIXTURE_COUNT);
129
132
  });
130
133
  });
131
134
  describe("moveCursorUp / moveCursorDown", () => {
@@ -150,7 +153,7 @@ describe("moveCursorUp / moveCursorDown", () => {
150
153
  });
151
154
  it("wraps cursor to bottom from top", () => {
152
155
  const state = moveCursorUp(createSelectionState(makeEntries()));
153
- expect(state.cursor).toBe(12);
156
+ expect(state.cursor).toBe(FIXTURE_COUNT - 1);
154
157
  });
155
158
  it("does not move when list is empty", () => {
156
159
  const entries = [];
@@ -1,4 +1,4 @@
1
- export declare const routeNames: readonly ["refine", "implement", "direct", "triage", "apply-review", "merge-gate", "audit", "propose"];
1
+ export declare const routeNames: readonly ["refine", "implement", "triage", "apply-review", "merge-gate", "audit"];
2
2
  export type RouteName = (typeof routeNames)[number];
3
3
  export interface WorkflowRoute {
4
4
  readonly name: RouteName;
@@ -1,22 +1,18 @@
1
1
  export const routeNames = [
2
2
  "refine",
3
3
  "implement",
4
- "direct",
5
4
  "triage",
6
5
  "apply-review",
7
6
  "merge-gate",
8
7
  "audit",
9
- "propose",
10
8
  ];
11
9
  export const workflowRoutes = [
12
10
  { 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 },
13
11
  { 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 },
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
12
  { 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 },
16
13
  { 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 },
17
14
  { 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 },
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 },
19
- { name: "propose", worker: "agent-propose.md", description: "Proposes the next feature. Reads the manifesto, recent history, and comparable tools, scores candidates, and files the winner as a single issue.", defaultEnabled: false },
15
+ { name: "audit", worker: "agent-audit.md", description: "Read-only repository audit. Finds 5-7 problems, scores each 1-10, and files a single issue that Refine then sizes and splits.", defaultEnabled: true },
20
16
  ];
21
17
  export const packageOwnedTargets = [
22
18
  ".github/actions",
@@ -9,7 +9,7 @@ inputs:
9
9
  description: Issue or pull request number.
10
10
  required: true
11
11
  labels:
12
- description: Newline-delimited labels to add.
12
+ description: Labels to add, one per line. A comma-separated list is accepted as well.
13
13
  required: true
14
14
  runs:
15
15
  using: composite
@@ -22,7 +22,7 @@ runs:
22
22
  with:
23
23
  github-token: ${{ inputs.token }}
24
24
  script: |
25
- const labels = process.env.LABELS.split(/\r?\n/).filter(Boolean);
25
+ const labels = process.env.LABELS.split(/\r?\n|,/).map((label) => label.trim()).filter(Boolean);
26
26
  await github.rest.issues.addLabels({
27
27
  ...context.repo,
28
28
  issue_number: Number(process.env.ISSUE_NUMBER),
@@ -1,17 +1,17 @@
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('node:fs');
3
-
4
- // Every apply-agent-* action reads the same artifact the same way: a missing file means the
5
- // agent produced nothing, which is a normal outcome rather than a failure.
6
- function readAgentItems(outputFile, type) {
7
- if (!outputFile || !fs.existsSync(outputFile)) {
8
- return [];
9
- }
10
-
11
- const parsed = JSON.parse(fs.readFileSync(outputFile, 'utf8'));
12
- const items = Array.isArray(parsed.items) ? parsed.items : [];
13
-
14
- return type ? items.filter((item) => item.type === type) : items;
15
- }
16
-
17
- module.exports = { readAgentItems };
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('node:fs');
3
+
4
+ // Every apply-agent-* action reads the same artifact the same way: a missing file means the
5
+ // agent produced nothing, which is a normal outcome rather than a failure.
6
+ function readAgentItems(outputFile, type) {
7
+ if (!outputFile || !fs.existsSync(outputFile)) {
8
+ return [];
9
+ }
10
+
11
+ const parsed = JSON.parse(fs.readFileSync(outputFile, 'utf8'));
12
+ const items = Array.isArray(parsed.items) ? parsed.items : [];
13
+
14
+ return type ? items.filter((item) => item.type === type) : items;
15
+ }
16
+
17
+ module.exports = { readAgentItems };
@@ -40,7 +40,20 @@ git fetch --no-tags "$BUNDLE_FILE" "$BUNDLE_REF"
40
40
  [ "$(git cat-file -t "$BUNDLE_TIP")" = commit ] || fail "Git bundle ref must point directly to a commit"
41
41
  [ "$(git rev-parse FETCH_HEAD)" = "$BUNDLE_TIP" ] || fail "Fetched bundle commit did not match listed bundle ref"
42
42
 
43
- git merge-base --is-ancestor "$TARGET_TIP" "$BUNDLE_TIP" || fail "Git bundle cannot fast-forward $TARGET_BRANCH"
43
+ # Fast-forward only, deliberately: a push that is not one would discard somebody's commits.
44
+ #
45
+ # "Cannot fast-forward" said nothing about why, and there are two reasons: the agent
46
+ # rewrote history (a rebase, an amend, a reset), or somebody pushed to the branch while it
47
+ # was working. Both mean the same thing to git, that the branch carries commits the bundle
48
+ # does not, so the applier cannot tell them apart and does not try. It prints the commits
49
+ # that are missing instead: their authors and dates answer the question immediately.
50
+ if ! git merge-base --is-ancestor "$TARGET_TIP" "$BUNDLE_TIP"; then
51
+ echo "::error::Nothing was applied. $TARGET_BRANCH is at $TARGET_TIP and the bundle is at $BUNDLE_TIP, which does not contain it, so pushing would drop the commits below. Either the agent rewrote history, which this push refuses, or the branch moved while it was working, in which case nothing is lost and the gate only needs running again on the current head."
52
+ echo "On $TARGET_BRANCH but missing from the bundle:"
53
+ git --no-pager log --format=" %h %ad %an %s" --date=short "$BUNDLE_TIP..$TARGET_TIP" |
54
+ head -20 || true
55
+ exit 1
56
+ fi
44
57
  git switch --detach "$TARGET_TIP"
45
58
  git merge --ff-only "$BUNDLE_TIP"
46
59
  git push origin "HEAD:refs/heads/$TARGET_BRANCH"
@@ -81,6 +81,30 @@ runs:
81
81
  ? 'This report references no issues.'
82
82
  : `All ${closedRefs} referenced issue(s) are closed.`;
83
83
 
84
+ // Do not close if there are open PRs that close this issue.
85
+ // The audit report may have spawned implement workers whose PRs
86
+ // are still under review — closing the report prematurely is misleading.
87
+ const openPRs = await github.paginate(github.rest.pulls.list, {
88
+ ...context.repo,
89
+ state: 'open',
90
+ per_page: 100,
91
+ });
92
+
93
+ const closesThisReport = openPRs.some((pr) => {
94
+ const body = pr.body ?? '';
95
+ const patterns = [
96
+ new RegExp(`(?:^|[^A-Za-z0-9_/])#(?:closes?|fixes?|resolves?|closed?)\\s+#${report.number}\\b`, 'i'),
97
+ new RegExp(`(?:^|[^A-Za-z0-9_/])#${report.number}\\b`),
98
+ ];
99
+ return patterns.some((p) => p.test(body));
100
+ });
101
+
102
+ if (closesThisReport) {
103
+ core.info(`#${report.number}: kept open, has open PR(s) that close it.`);
104
+ keptOpen += 1;
105
+ continue;
106
+ }
107
+
84
108
  await github.rest.issues.createComment({
85
109
  ...context.repo,
86
110
  issue_number: report.number,
@@ -24,12 +24,15 @@ outputs:
24
24
  ci-run-id:
25
25
  description: Workflow run ID of that CI run.
26
26
  value: ${{ steps.classify.outputs.ci-run-id }}
27
+ merge-gate-attempts:
28
+ description: Failed merge-gate attempts already made against this CI verdict.
29
+ value: ${{ steps.classify.outputs.merge-gate-attempts }}
30
+ implement-attempts:
31
+ description: Implement runs already made for this issue that died before producing an answer.
32
+ value: ${{ steps.classify.outputs.implement-attempts }}
27
33
  refine-mode:
28
34
  description: first or rerefine.
29
35
  value: ${{ steps.classify.outputs.refine-mode }}
30
- direct-mode:
31
- description: first or continue.
32
- value: ${{ steps.classify.outputs.direct-mode }}
33
36
  triage-mode:
34
37
  description: first or retriage.
35
38
  value: ${{ steps.classify.outputs.triage-mode }}
@@ -47,6 +50,7 @@ runs:
47
50
  ACTION: ${{ github.event.action }}
48
51
  LABEL: ${{ github.event.label.name }}
49
52
  ISSUE_LABELS: ${{ toJSON(github.event.issue.labels.*.name) }}
53
+ ISSUE_STATE: ${{ github.event.issue.state }}
50
54
  EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
51
55
  EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
52
56
  COMMENT_ON_PR: ${{ github.event.issue.pull_request != '' }}
@@ -62,6 +66,7 @@ runs:
62
66
  INPUT_MODE: ${{ github.event.inputs.mode }}
63
67
  INPUT_CI_CONCLUSION: ${{ github.event.inputs.ci-conclusion }}
64
68
  INPUT_CI_RUN_ID: ${{ github.event.inputs.ci-run-id }}
69
+ INPUT_ATTEMPTS_SO_FAR: ${{ github.event.inputs.attempts_so_far }}
65
70
  INPUT_TRIGGER_KIND: ${{ github.event.inputs.trigger-kind }}
66
71
  run: |
67
72
  set -euo pipefail
@@ -11,11 +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="17 */2 * * *"
15
- # Daily, but it proposes far less often than daily: the worker holds one open
16
- # proposal at a time and skips while that slot is filled. The cron is a heartbeat,
17
- # the queue is the pacing.
18
- readonly PROPOSE_CRON="29 7 * * *"
14
+ readonly RECONCILE_BOT_PR_RUNS_CRON="17 * * * *"
19
15
 
20
16
  has_label() {
21
17
  jq -e --arg name "$1" 'index($name)' >/dev/null 2>&1 <<<"${ISSUE_LABELS:-[]}"
@@ -27,18 +23,28 @@ is_issue_number() {
27
23
 
28
24
  classify_route() {
29
25
  local route="none" error=""
30
- local issue_number="" pr_number="" ci_conclusion="" ci_run_id=""
31
- local refine_mode="" direct_mode="" triage_mode="" trigger_kind=""
26
+ local issue_number="" pr_number="" ci_conclusion="" ci_run_id="" merge_gate_attempts="0" implement_attempts="0"
27
+ local refine_mode="" triage_mode="" trigger_kind=""
32
28
 
33
29
  case "${EVENT:-}" in
34
30
  issues)
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:-}"
31
+ # A closed issue is finished work, but label edits on one still arrive as events.
32
+ if [ "${ISSUE_STATE:-}" = "closed" ]; then
33
+ error="issue is closed"
34
+ elif [ "${ACTION:-}" = "opened" ]; then
35
+ # Issue opened with a work label → skip triage.
36
+ # The label event will trigger authorize-bot-work → bot-working → the
37
+ # correct worker. Triage would only interfere.
38
+ if has_label refine || has_label implement; then
39
+ error="issue opened with a work label; triage skipped"
40
+ else
41
+ # Issue opened by an outside collaborator → triage. The authorize job
42
+ # gates the caller on is_outside_collaborator; this routes unconditionally
43
+ # so write+ openers classify to triage but the caller job skips them.
44
+ route="triage"
45
+ triage_mode="first"
46
+ issue_number="${EVENT_ISSUE_NUMBER:-}"
47
+ fi
42
48
  elif [ "${ACTION:-}" = "labeled" ]; then
43
49
  case "${LABEL:-}" in
44
50
  bot-working)
@@ -53,12 +59,8 @@ classify_route() {
53
59
  route="refine"
54
60
  refine_mode="first"
55
61
  issue_number="${EVENT_ISSUE_NUMBER:-}"
56
- elif has_label direct; then
57
- route="direct"
58
- direct_mode="first"
59
- issue_number="${EVENT_ISSUE_NUMBER:-}"
60
62
  else
61
- error="bot-working added but no work label (implement/refine/direct) found"
63
+ error="bot-working added but no work label found"
62
64
  fi
63
65
  ;;
64
66
  triage)
@@ -75,7 +77,7 @@ classify_route() {
75
77
  triage_mode="first"
76
78
  fi
77
79
  ;;
78
- refine | implement | direct)
80
+ refine | implement)
79
81
  # If the actor is a bot (e.g. refine→implement transition), route directly.
80
82
  # If the actor is a human, authorize-bot-work.yml will add bot-working which triggers the workflow.
81
83
  if [ "${ACTOR:-}" != "" ] && echo "${ACTOR:-}" | grep -q '\[bot\]$'; then
@@ -83,13 +85,11 @@ classify_route() {
83
85
  issue_number="${EVENT_ISSUE_NUMBER:-}"
84
86
  if [ "${LABEL:-}" = "refine" ]; then
85
87
  refine_mode="first"
86
- elif [ "${LABEL:-}" = "direct" ]; then
87
- direct_mode="first"
88
88
  fi
89
89
  elif has_label bot-working; then
90
90
  # Already has bot-working - the workflow is already running or queued.
91
91
  # Don't re-trigger.
92
- error="issue already has bot-working label; implement/refine/direct already in progress"
92
+ error="issue already has bot-working label; implement/refine already in progress"
93
93
  else
94
94
  error="waiting for bot to add bot-working label"
95
95
  fi
@@ -100,8 +100,18 @@ classify_route() {
100
100
 
101
101
  issue_comment)
102
102
  if [ "${COMMENT_ON_PR:-false}" = "true" ]; then
103
- route="apply-review"
104
- pr_number="${EVENT_ISSUE_NUMBER:-}"
103
+ if [ "${COMMENT_SENDER_TYPE:-}" = "Bot" ]; then
104
+ # Every App-token comment on a pull request is an issue_comment event, and the
105
+ # workers comment on pull requests they own. None of that is reviewer feedback.
106
+ error="comment authored by a bot"
107
+ else
108
+ route="apply-review"
109
+ pr_number="${EVENT_ISSUE_NUMBER:-}"
110
+ fi
111
+ elif [ "${ISSUE_STATE:-}" = "closed" ]; then
112
+ # A closing comment on a refine-labelled issue used to start a full re-refine, which
113
+ # held a runner for 35 minutes and filed split children under an already-shut parent.
114
+ error="issue is closed"
105
115
  elif [ "${COMMENT_SENDER_TYPE:-}" = "Bot" ]; then
106
116
  error="comment authored by a bot"
107
117
  elif has_label triage; then
@@ -110,12 +120,8 @@ classify_route() {
110
120
  issue_number="${EVENT_ISSUE_NUMBER:-}"
111
121
  elif has_label implement; then
112
122
  error="issue has implement label; comments do not re-trigger implement"
113
- elif has_label direct; then
114
- route="direct"
115
- direct_mode="continue"
116
- issue_number="${EVENT_ISSUE_NUMBER:-}"
117
123
  elif ! has_label refine; then
118
- error="issue does not carry the refine or direct label"
124
+ error="issue does not carry the refine label"
119
125
  else
120
126
  route="refine"
121
127
  refine_mode="rerefine"
@@ -143,10 +149,17 @@ classify_route() {
143
149
  ;;
144
150
 
145
151
  workflow_run)
146
- if is_issue_number "${RUN_PR_NUMBER:-}"; then
152
+ # Only a FAILED CI run on an attached pull request auto-dispatches the gate. A green
153
+ # run reaches the gate through the consumer CI's dispatch-merge-gate job and the
154
+ # reconcile belt, so routing success here would double-fire the gate for every passing
155
+ # pull request. GitHub delivers workflow_run only for CI runs whose actor is a human;
156
+ # a bot pull request's CI never arrives here at all, and the same two paths cover it.
157
+ if [ "${RUN_CONCLUSION:-}" != "failure" ]; then
158
+ error="CI concluded '${RUN_CONCLUSION:-}'; the gate auto-triggers only on failure"
159
+ elif is_issue_number "${RUN_PR_NUMBER:-}"; then
147
160
  route="merge-gate"
148
161
  pr_number="${RUN_PR_NUMBER}"
149
- ci_conclusion="${RUN_CONCLUSION:-}"
162
+ ci_conclusion="failure"
150
163
  ci_run_id="${RUN_ID:-}"
151
164
  else
152
165
  error="CI run has no attached pull request"
@@ -167,14 +180,16 @@ classify_route() {
167
180
  workflow_dispatch)
168
181
  trigger_kind="manual"
169
182
  case "${OPERATION:-}" in
170
- refine | implement | direct)
183
+ refine | implement)
171
184
  if is_issue_number "${INPUT_ISSUE_NUMBER:-}"; then
172
185
  route="${OPERATION}"
173
186
  issue_number="${INPUT_ISSUE_NUMBER}"
174
187
  if [ "$OPERATION" = "refine" ]; then
175
188
  refine_mode="${INPUT_MODE:-first}"
176
- elif [ "$OPERATION" = "direct" ]; then
177
- direct_mode="${INPUT_MODE:-first}"
189
+ else
190
+ # The implement worker re-dispatches itself when a run dies before doing any work,
191
+ # and carries the count so the budget is bounded.
192
+ implement_attempts="${INPUT_ATTEMPTS_SO_FAR:-0}"
178
193
  fi
179
194
  else
180
195
  error="operation '${OPERATION}' needs a positive issue-number, got '${INPUT_ISSUE_NUMBER:-}'"
@@ -203,17 +218,21 @@ classify_route() {
203
218
  pr_number="${INPUT_PR_NUMBER}"
204
219
  ci_conclusion="${INPUT_CI_CONCLUSION:-}"
205
220
  ci_run_id="${INPUT_CI_RUN_ID:-}"
221
+ merge_gate_attempts="${INPUT_ATTEMPTS_SO_FAR:-0}"
206
222
  else
207
223
  error="operation 'merge-gate' needs a positive pr-number, got '${INPUT_PR_NUMBER:-}'"
208
224
  fi
209
225
  ;;
210
- audit | propose)
226
+ audit)
211
227
  route="${OPERATION}"
212
228
  trigger_kind="${INPUT_TRIGGER_KIND:-manual}"
213
229
  ;;
214
- audit-close | cleanup-artifacts | reconcile-bot-pr-runs | validate)
230
+ audit-close | cleanup-artifacts | reconcile-bot-pr-runs | validate)
215
231
  route="${OPERATION}"
216
232
  ;;
233
+ release)
234
+ route="release"
235
+ ;;
217
236
  *)
218
237
  error="unknown operation '${OPERATION:-}'"
219
238
  ;;
@@ -231,8 +250,9 @@ issue-number=${issue_number}
231
250
  pr-number=${pr_number}
232
251
  ci-conclusion=${ci_conclusion}
233
252
  ci-run-id=${ci_run_id}
253
+ merge-gate-attempts=${merge_gate_attempts}
254
+ implement-attempts=${implement_attempts}
234
255
  refine-mode=${refine_mode}
235
- direct-mode=${direct_mode}
236
256
  triage-mode=${triage_mode}
237
257
  trigger-kind=${trigger_kind}
238
258
  error=${error}