@plainconceptsplatform/workflows 0.5.1 → 0.16.0

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 (60) hide show
  1. package/README.md +105 -88
  2. package/dist/catalog-installation.d.ts +67 -0
  3. package/dist/catalog-installation.js +485 -0
  4. package/dist/catalog-listing.d.ts +13 -0
  5. package/dist/catalog-listing.js +70 -0
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.js +273 -0
  8. package/dist/package-baseline.d.ts +25 -0
  9. package/dist/package-baseline.js +138 -0
  10. package/dist/repository-inspection.d.ts +23 -0
  11. package/dist/repository-inspection.js +113 -0
  12. package/dist/route-processing.d.ts +4 -0
  13. package/dist/route-processing.js +80 -0
  14. package/dist/stack-defaults.d.ts +11 -0
  15. package/dist/stack-defaults.js +110 -0
  16. package/dist/tui.d.ts +25 -0
  17. package/dist/tui.js +271 -0
  18. package/dist/worker-env.d.ts +46 -0
  19. package/dist/worker-env.js +179 -0
  20. package/dist/workflow-catalog.d.ts +27 -0
  21. package/dist/workflow-catalog.js +57 -0
  22. package/loops/actions/add-issue-labels/action.yml +22 -2
  23. package/loops/actions/apply-agent-bundle/apply-bundle.sh +14 -1
  24. package/loops/actions/audit-close/action.yml +180 -128
  25. package/loops/actions/classify-route/action.yml +7 -0
  26. package/loops/actions/classify-route/classify-route.sh +28 -7
  27. package/loops/actions/cleanup-artifacts/action.yml +38 -12
  28. package/loops/actions/housekeeping/action.yml +251 -0
  29. package/loops/actions/identify-gate-subject/action.yml +3 -1
  30. package/loops/actions/merge-agent-pr/action.yml +13 -0
  31. package/loops/actions/remove-issue-labels/action.yml +4 -2
  32. package/loops/actions/report-workflow-errors/action.yml +385 -0
  33. package/loops/actions/validate-merge-gate-output/validate-merge-gate-output.sh +13 -1
  34. package/loops/actions/validate-refine-output/validate-refine-output.sh +18 -2
  35. package/loops/actions/validate-triage-output/action.yml +1 -1
  36. package/loops/actions/validate-triage-output/validate-triage-output.sh +9 -5
  37. package/loops/actions/verify-composite-actions/verify-composite-actions.sh +53 -0
  38. package/loops/actions/verify-refine-output/verify-refine-output.sh +20 -0
  39. package/loops/actions/verify-route-matrix/verify-route-matrix.sh +1017 -56
  40. package/loops/scripts/compile-agent-workflows.mjs +96 -1
  41. package/loops/scripts/merge-changelog.mjs +76 -0
  42. package/loops/templates/agentics/actionlint.yaml +13 -0
  43. package/loops/templates/agentics/agentics-checks.yml +203 -86
  44. package/loops/templates/agentics/agentics-error-report.yml +97 -0
  45. package/loops/templates/ci/app-ci-dotnet-next.yml +64 -2
  46. package/loops/templates/ci/app-ci-node-monorepo.yml +52 -0
  47. package/loops/templates/opencode/opencode.ci.json +3 -3
  48. package/loops/templates/opencode/opencode.ci.json.md +4 -1
  49. package/loops/workflows/agent-apply-review.md +452 -465
  50. package/loops/workflows/agent-audit.md +201 -213
  51. package/loops/workflows/agent-implement.md +616 -538
  52. package/loops/workflows/agent-merge-gate.md +830 -730
  53. package/loops/workflows/agent-refine.md +599 -609
  54. package/loops/workflows/agent-release.md +244 -258
  55. package/loops/workflows/agent-triage.md +476 -447
  56. package/loops/workflows/authorize-bot-work.yml +26 -6
  57. package/loops/workflows/work-router.yml +1185 -862
  58. package/package.json +9 -8
  59. package/loops/actions/stale-recovery/action.yml +0 -288
  60. package/loops/actions/update-changelog/action.yml +0 -113
@@ -0,0 +1,251 @@
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/housekeeping/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
+ name: Housekeeping
3
+ description: >-
4
+ Retry work the machine stalled on, tidy the state it strands behind, and report what genuinely
5
+ still needs a person. Deterministic: no model runs here.
6
+
7
+ inputs:
8
+ token:
9
+ description: Token with issues, pull-requests and contents write access.
10
+ required: true
11
+ retry-after-hours:
12
+ description: How long an issue may sit stalled before it is retried.
13
+ required: false
14
+ default: '6'
15
+ max-retries:
16
+ description: How many times the janitor retries one issue before leaving it to a person.
17
+ required: false
18
+ default: '3'
19
+ stale-pr-days:
20
+ description: A bot pull request older than this with no CI verdict is reported, never closed.
21
+ required: false
22
+ default: '3'
23
+ digest-title:
24
+ description: Title of the single issue that reports what needs a human. Empty disables it.
25
+ required: false
26
+ default: 'Needs a human'
27
+ dry-run:
28
+ description: Compute everything and write nothing. Every intended change is printed.
29
+ required: false
30
+ default: 'false'
31
+
32
+ runs:
33
+ using: composite
34
+ steps:
35
+ - name: Sweep
36
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
37
+ env:
38
+ RETRY_AFTER_HOURS: ${{ inputs.retry-after-hours }}
39
+ MAX_RETRIES: ${{ inputs.max-retries }}
40
+ STALE_PR_DAYS: ${{ inputs.stale-pr-days }}
41
+ DIGEST_TITLE: ${{ inputs.digest-title }}
42
+ DRY_RUN: ${{ inputs.dry-run }}
43
+ with:
44
+ github-token: ${{ inputs.token }}
45
+ script: |
46
+ const dryRun = process.env.DRY_RUN === 'true';
47
+ const retryAfterMs = Number(process.env.RETRY_AFTER_HOURS) * 3600 * 1000;
48
+ const maxRetries = Number(process.env.MAX_RETRIES);
49
+ const stalePrMs = Number(process.env.STALE_PR_DAYS) * 86400 * 1000;
50
+ const digestTitle = process.env.DIGEST_TITLE;
51
+
52
+ // Every write goes through this, so --dry-run is honest rather than hopeful: there is
53
+ // no second path that forgets to check it.
54
+ const planned = [];
55
+ const act = async (what, run) => {
56
+ planned.push(what);
57
+ if (dryRun) { core.info(`[dry-run] ${what}`); return; }
58
+ core.info(what);
59
+ await run();
60
+ };
61
+
62
+ const RETRY_MARKER = '<!-- housekeeping-retry -->';
63
+ const DIGEST_MARKER = '<!-- housekeeping-digest -->';
64
+ const WORK_LABELS = ['implement', 'refine', 'triage'];
65
+ const isBot = (login) => login.endsWith('[bot]') || login.startsWith('app/');
66
+ const age = (iso) => Date.now() - new Date(iso).getTime();
67
+ const days = (ms) => Math.floor(ms / 86400000);
68
+
69
+ const issues = await github.paginate(github.rest.issues.listForRepo, {
70
+ ...context.repo, state: 'open', per_page: 100,
71
+ });
72
+ const pulls = await github.paginate(github.rest.pulls.list, {
73
+ ...context.repo, state: 'open', per_page: 100,
74
+ });
75
+ const names = (issue) => issue.labels.map((l) => (typeof l === 'string' ? l : l.name));
76
+
77
+ const needsHuman = [];
78
+ let retried = 0;
79
+
80
+ // 1. Retry what the machine stalled on. `stalled` marks a park caused by a crash, a
81
+ // timeout or an empty output; `review` alone is a decision the agent made, and
82
+ // re-running a decision only reproduces it, so those are reported and never retried.
83
+ for (const issue of issues) {
84
+ if (issue.pull_request) continue;
85
+ const labels = names(issue);
86
+ if (!labels.includes('review')) continue;
87
+
88
+ const comments = await github.paginate(github.rest.issues.listComments, {
89
+ ...context.repo, issue_number: issue.number, per_page: 100,
90
+ });
91
+ const attempts = comments.filter((c) => (c.body ?? '').includes(RETRY_MARKER)).length;
92
+ const work = WORK_LABELS.find((l) => labels.includes(l));
93
+ const waited = age(issue.updated_at);
94
+
95
+ if (!labels.includes('stalled')) {
96
+ needsHuman.push({ kind: 'decided', issue, labels, waited });
97
+ continue;
98
+ }
99
+ if (waited < retryAfterMs) continue;
100
+ if (attempts >= maxRetries || !work) {
101
+ needsHuman.push({ kind: attempts >= maxRetries ? 'retries-exhausted' : 'no-work-label', issue, labels, waited, attempts });
102
+ continue;
103
+ }
104
+
105
+ await act(`#${issue.number}: retrying ${work} (attempt ${attempts + 1} of ${maxRetries}, stalled ${Math.floor(waited / 3600000)}h)`, async () => {
106
+ await github.rest.issues.createComment({
107
+ ...context.repo, issue_number: issue.number,
108
+ body: `${RETRY_MARKER}\nThis issue stalled rather than reaching a decision, so it is being retried automatically: attempt ${attempts + 1} of ${maxRetries}. If it stalls ${maxRetries} times it stays parked for a person.`,
109
+ });
110
+ // Removing the labels is what lets it route again; the work label is already there.
111
+ for (const label of ['review', 'stalled']) {
112
+ await github.rest.issues.removeLabel({ ...context.repo, issue_number: issue.number, name: label })
113
+ .catch((error) => { if (error.status !== 404) throw error; });
114
+ }
115
+ await github.rest.actions.createWorkflowDispatch({
116
+ ...context.repo, workflow_id: 'work-router.yml',
117
+ ref: context.payload.repository.default_branch,
118
+ inputs: { operation: work, 'issue-number': String(issue.number) },
119
+ });
120
+ });
121
+ retried += 1;
122
+ }
123
+
124
+ // 2. Strands. pr-pending says a pull request is open and waiting; when that pull request
125
+ // closed without merging, nothing ever took the label off.
126
+ for (const issue of issues) {
127
+ if (issue.pull_request || !names(issue).includes('pr-pending')) continue;
128
+ const linked = pulls.some((p) => new RegExp(`(clos(e|es|ed)|fix(es|ed)?|resolves?)\\s+#${issue.number}\\b`, 'i').test(p.body ?? ''));
129
+ if (linked) continue;
130
+ await act(`#${issue.number}: removing pr-pending, no open pull request closes it`, () =>
131
+ github.rest.issues.removeLabel({ ...context.repo, issue_number: issue.number, name: 'pr-pending' })
132
+ .catch((error) => { if (error.status !== 404) throw error; }));
133
+ }
134
+
135
+ // 3. Split parents. Refine leaves the parent open as a tracker and nothing has ever
136
+ // closed one: once every child is done the parent sits there for ever with an unticked
137
+ // checklist. Children carry `<!-- split-parent: N -->`, which is the only machine-
138
+ // readable link between them.
139
+ const childrenOf = new Map();
140
+ for (const issue of await github.paginate(github.rest.issues.listForRepo, { ...context.repo, state: 'all', per_page: 100 })) {
141
+ const match = /<!-- split-parent: (\d+) -->/.exec(issue.body ?? '');
142
+ if (!match || issue.pull_request) continue;
143
+ const parent = Number(match[1]);
144
+ if (!childrenOf.has(parent)) childrenOf.set(parent, []);
145
+ childrenOf.get(parent).push(issue);
146
+ }
147
+ for (const [parent, children] of childrenOf) {
148
+ const open = children.filter((c) => c.state === 'open');
149
+ if (open.length > 0 || !issues.some((i) => i.number === parent)) continue;
150
+ await act(`#${parent}: closing split parent, all ${children.length} children are done`, async () => {
151
+ await github.rest.issues.createComment({
152
+ ...context.repo, issue_number: parent,
153
+ body: `${DIGEST_MARKER}\nClosed automatically: every child of this split is closed (${children.map((c) => `#${c.number}`).join(', ')}).`,
154
+ });
155
+ await github.rest.issues.update({ ...context.repo, issue_number: parent, state: 'closed', state_reason: 'completed' });
156
+ });
157
+ }
158
+
159
+ // 4. Branches. Nothing in the fleet has ever deleted one, so they accumulate for ever
160
+ // after a merge and after a close alike. Only ever touch a branch whose pull request is
161
+ // finished; never the default branch, and never one with a pull request still open.
162
+ //
163
+ // Deleting the branch of a pull request that closed WITHOUT merging does not throw the
164
+ // work away: GitHub keeps `refs/pull/<n>/head` for the life of the pull request, which
165
+ // is what its own "Restore branch" button restores from. Verified on this fleet --
166
+ // after the first sweep removed six branches, every one of their pull requests still
167
+ // resolved through that ref. So `git fetch origin pull/<n>/head` brings any of this
168
+ // back, and the pull request page still shows the diff.
169
+ const branches = await github.paginate(github.rest.repos.listBranches, { ...context.repo, per_page: 100 });
170
+ const defaultBranch = context.payload.repository.default_branch;
171
+ const allPulls = await github.paginate(github.rest.pulls.list, { ...context.repo, state: 'all', per_page: 100 });
172
+ const byHead = new Map();
173
+ for (const pull of allPulls) {
174
+ if (!byHead.has(pull.head.ref)) byHead.set(pull.head.ref, []);
175
+ byHead.get(pull.head.ref).push(pull);
176
+ }
177
+ for (const branch of branches) {
178
+ if (branch.name === defaultBranch) continue;
179
+ const forBranch = byHead.get(branch.name) ?? [];
180
+ if (forBranch.length === 0) continue; // never opened a PR: not ours to judge
181
+ if (forBranch.some((p) => p.state === 'open')) continue; // still in use
182
+ if (!forBranch.every((p) => isBot(p.user?.login ?? ''))) continue;
183
+ const merged = forBranch.some((p) => p.merged_at);
184
+ await act(`branch ${branch.name}: deleting (${merged ? 'merged' : 'closed unmerged'})`, () =>
185
+ github.rest.git.deleteRef({ ...context.repo, ref: `heads/${branch.name}` })
186
+ .catch((error) => { if (error.status !== 404 && error.status !== 422) throw error; }));
187
+ }
188
+
189
+ // 5. Pull requests that need a person: parked by a gate verdict, or old with no CI.
190
+ for (const pull of pulls) {
191
+ if (!isBot(pull.user?.login ?? '')) continue;
192
+ const comments = await github.paginate(github.rest.issues.listComments, {
193
+ ...context.repo, issue_number: pull.number, per_page: 100,
194
+ });
195
+ const verdict = [...comments].reverse().find((c) => (c.body ?? '').includes('<!-- agent-merge-gate -->') && (c.body ?? '').includes('**Verdict:**'));
196
+ const parsed = verdict ? /\*\*Verdict:\*\*\s*(\w+)/.exec(verdict.body)?.[1] : undefined;
197
+ if (parsed && parsed !== 'merge') {
198
+ needsHuman.push({ kind: 'pr-parked', pull, verdict: parsed, waited: age(verdict.created_at) });
199
+ } else if (!verdict && age(pull.created_at) > stalePrMs) {
200
+ needsHuman.push({ kind: 'pr-stale', pull, waited: age(pull.created_at) });
201
+ }
202
+ }
203
+
204
+ // 6. One digest. Everything above either fixed itself or is listed here, so there is a
205
+ // single place to look instead of four repositories and three label queries.
206
+ const line = (entry) => {
207
+ if (entry.kind === 'pr-parked') return `- PR #${entry.pull.number} · parked ${days(entry.waited)}d · gate verdict \`${entry.verdict}\` · ${entry.pull.title}`;
208
+ if (entry.kind === 'pr-stale') return `- PR #${entry.pull.number} · ${days(entry.waited)}d old · no gate verdict yet · ${entry.pull.title}`;
209
+ if (entry.kind === 'retries-exhausted') return `- #${entry.issue.number} · stalled ${days(entry.waited)}d · retried ${entry.attempts}× and still stalling · ${entry.issue.title}`;
210
+ if (entry.kind === 'no-work-label') return `- #${entry.issue.number} · stalled ${days(entry.waited)}d · no work label to retry with · ${entry.issue.title}`;
211
+ return `- #${entry.issue.number} · waiting ${days(entry.waited)}d · ${entry.labels.filter((l) => l !== 'review').join(', ') || 'no other labels'} · ${entry.issue.title}`;
212
+ };
213
+ const section = (heading, kinds) => {
214
+ const rows = needsHuman.filter((e) => kinds.includes(e.kind));
215
+ return rows.length === 0 ? '' : `\n### ${heading} (${rows.length})\n\n${rows.map(line).join('\n')}\n`;
216
+ };
217
+ const body = [
218
+ DIGEST_MARKER,
219
+ `_Updated ${new Date().toISOString().slice(0, 16).replace('T', ' ')} UTC. This issue is rewritten by the housekeeping job; edits to it are lost._`,
220
+ section('Waiting on a decision', ['decided']),
221
+ section('Pull requests parked by the gate', ['pr-parked', 'pr-stale']),
222
+ section('Stalled beyond the retry budget', ['retries-exhausted', 'no-work-label']),
223
+ needsHuman.length === 0 ? '\nNothing is waiting on a person right now.\n' : '',
224
+ `\n---\n\nThis run retried ${retried} stalled issue(s) and made ${planned.length} change(s) in total.`,
225
+ ].filter(Boolean).join('\n');
226
+
227
+ if (digestTitle) {
228
+ const existing = issues.find((i) => !i.pull_request && i.title === digestTitle && (i.body ?? '').includes(DIGEST_MARKER));
229
+ if (existing) {
230
+ if (needsHuman.length === 0) {
231
+ await act(`closing the digest #${existing.number}: nothing is waiting`, () =>
232
+ github.rest.issues.update({ ...context.repo, issue_number: existing.number, body, state: 'closed', state_reason: 'completed' }));
233
+ } else {
234
+ await act(`updating the digest #${existing.number}: ${needsHuman.length} item(s)`, () =>
235
+ github.rest.issues.update({ ...context.repo, issue_number: existing.number, body }));
236
+ }
237
+ } else if (needsHuman.length > 0) {
238
+ await act(`opening the digest: ${needsHuman.length} item(s)`, () =>
239
+ github.rest.issues.create({ ...context.repo, title: digestTitle, body }));
240
+ }
241
+ }
242
+
243
+ await core.summary
244
+ .addHeading('Housekeeping', 2)
245
+ .addList([
246
+ `Retried: ${retried}`,
247
+ `Waiting on a person: ${needsHuman.length}`,
248
+ `Changes ${dryRun ? 'planned' : 'made'}: ${planned.length}`,
249
+ ])
250
+ .addRaw(planned.length ? `\n\n${planned.map((p) => `- ${p}`).join('\n')}` : '')
251
+ .write();
@@ -96,8 +96,10 @@ runs:
96
96
  ci_run_id=""
97
97
  if [ -z "$ci_conclusion" ]; then
98
98
  head_sha="$(gh pr view "$PR_NUMBER" --repo "$REPO" --json headRefOid --jq '.headRefOid')"
99
+ # A run keeps the name it had when it ran, so a repository whose CI was renamed still
100
+ # carries runs under the old casing. Fold the name before comparing.
99
101
  ci_run="$(gh api "repos/$REPO/actions/runs?head_sha=$head_sha&per_page=100" \
100
- --jq '[.workflow_runs[] | select(.name == "App: CI" or .name == "CI")] | sort_by(.created_at) | last')"
102
+ --jq '[.workflow_runs[] | select((.name | ascii_downcase) == "app: ci" or (.name | ascii_downcase) == "ci")] | sort_by(.created_at) | last')"
101
103
 
102
104
  if [ -n "$ci_run" ] && [ "$ci_run" != "null" ]; then
103
105
  ci_conclusion="$(jq -r '.conclusion // "pending"' <<<"$ci_run")"
@@ -32,5 +32,18 @@ runs:
32
32
  HEAD_SHA=$(gh pr view "$PR_NUM" --repo "$REPO" --json headRefOid --jq '.headRefOid')
33
33
  [[ "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]] || { echo "::error::Invalid pull request head SHA for #$PR_NUM"; exit 1; }
34
34
 
35
+ HEAD_REF=$(gh pr view "$PR_NUM" --repo "$REPO" --json headRefName --jq '.headRefName')
36
+
35
37
  gh pr merge "$PR_NUM" --repo "$REPO" --squash --match-head-commit "$HEAD_SHA"
38
+
39
+ # Delete the branch, because nothing else ever has: four repositories had accumulated
40
+ # 47 non-main branches, every one the head of a pull request merged or closed months
41
+ # ago. Done after the merge and with its failure swallowed rather than through
42
+ # `gh pr merge --delete-branch`, which also tries to delete the local branch and can
43
+ # turn a completed merge into a red step. `delete_branch_on_merge` on the repository
44
+ # does the same job, but only where someone remembered to switch it on.
45
+ if [ -n "$HEAD_REF" ] && [ "$HEAD_REF" != "null" ]; then
46
+ gh api -X DELETE "repos/$REPO/git/refs/heads/$HEAD_REF" >/dev/null 2>&1 ||
47
+ echo "::notice::branch $HEAD_REF was already gone or is protected"
48
+ fi
36
49
  done
@@ -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 remove.
12
+ description: Labels to remove, 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,9 @@ runs:
22
22
  with:
23
23
  github-token: ${{ inputs.token }}
24
24
  script: |
25
- for (const label of process.env.LABELS.split(/\r?\n/).filter(Boolean)) {
25
+ // Callers wrote `labels: a,b` while this split on newlines, so one label named "a,b"
26
+ // was removed: a 404, swallowed below, and neither label ever came off.
27
+ for (const label of process.env.LABELS.split(/\r?\n|,/).map((label) => label.trim()).filter(Boolean)) {
26
28
  try {
27
29
  await github.rest.issues.removeLabel({
28
30
  ...context.repo,