@plainconceptsplatform/workflows 1.11.0 → 1.13.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.
package/README.md CHANGED
@@ -81,7 +81,7 @@ npx --yes --package @plainconceptsplatform/workflows@latest workflows search "ci
81
81
 
82
82
  ## Templates
83
83
 
84
- Install optional standalone templates with `add --template`. Available templates are `agentics-checks`, `agentics-error-report`, `agentics-maintenance`, `app-ci-dotnet-next`, `app-ci-node-monorepo`, `bug-report`, `feature-request`, `github-release`, `opencode.ci.json`, `platform-template` and `quality-gates`. CI templates are stack-specific copies, not a combined template. `quality-gates` and `platform-template` are bundles for the platform family: they stay package-managed and `update` refreshes them where they are installed; see `docs/family-templates.md` in the repository. `github-release` publishes generated release notes when a `v*` tag is pushed. Edit their top-level `env:` values for repository paths, package names, and commands.
84
+ Install optional standalone templates with `add --template`. Available templates are `agentics-checks`, `agentics-error-report`, `agentics-maintenance`, `app-ci-dotnet-next`, `app-ci-node-monorepo`, `bug-report`, `feature-request`, `github-release`, `opencode.ci.json`, `platform-template` and `quality-gates`. CI templates are stack-specific copies, not a combined template. `quality-gates` and `platform-template` are bundles: they stay package-managed and `update` refreshes them where they are installed. `platform-template` reads the platform contract, `.platform/contract.yml`; see `docs/platform-contracts.md` and `docs/family-templates.md` in the repository. `github-release` publishes generated release notes when a `v*` tag is pushed. Edit their top-level `env:` values for repository paths, package names, and commands.
85
85
 
86
86
  `agentics-error-report` is worth installing everywhere. Once a day it classifies how *this package's* workflows behaved in the repository and files what broke upstream, so the package gets fixed instead of every repository working around the same bug. It is deterministic no model runs, because a model asked to summarise a failure paraphrases whatever the log held and it sends only the names this package gives its own workflows, jobs and steps, a conclusion, a bucketed runner label, a count and a classification id. No log line, branch, title, path or issue number leaves the repository, the repository's own workflows are counted and never inspected, and a leak scanner withholds any report mentioning the repository or its owner and fails the run rather than filing it. Clear `UPSTREAM_NAME` in its `env:` to compute the report into the job summary and file nothing.
87
87
 
package/dist/index.js CHANGED
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { inspectRepository, parseVisibility, resolveVisibility } from "./repository-inspection.js";
3
- import { catalogSourcePath, installCatalog, installedRoutes, installTemplate, isTemplateName, removeRouteFiles } from "./catalog-installation.js";
3
+ import { catalogSourcePath, exists, installCatalog, installedRoutes, installTemplate, isTemplateName, removeRouteFiles } from "./catalog-installation.js";
4
4
  import { installBundle, installedBundles, isBundleTemplate } from "./template-bundles.js";
5
5
  import { formatCatalog, listCatalog, searchCatalog } from "./catalog-listing.js";
6
6
  import { packageVersion } from "./package-baseline.js";
7
7
  import { routeNames, templateNames } from "./workflow-catalog.js";
8
8
  import { runInteractive } from "./tui.js";
9
9
  import { realpathSync } from "node:fs";
10
- import { resolve } from "node:path";
10
+ import { join, resolve } from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
12
  const HELP_TEXT = `Workflows CLI install and update Plain Concepts Platform agentic workflows.
13
13
 
@@ -125,9 +125,19 @@ export async function run(arguments_, repositoryPath = process.cwd()) {
125
125
  const sourcePath = catalogSourcePath();
126
126
  // The installed set is always part of the target set: adding a route never drops another,
127
127
  // and a plain update refreshes exactly what is there, bundle templates included. A bundle
128
- // nobody installed is never added by an update.
129
- if (routes.length > 0 || template === undefined) {
130
- const selectedRoutes = unionRoutes(routes, await installedRoutes(repositoryPath));
128
+ // nobody installed is never added by an update, and a repository holding only bundles gets
129
+ // no router from one.
130
+ const installedRouteSet = await installedRoutes(repositoryPath);
131
+ const routerInstalled = await exists(join(repositoryPath, ".github", "workflows", "work-router.yml"));
132
+ const bundlesOnly = command === "update" && routes.length === 0 && template === undefined &&
133
+ installedRouteSet.length === 0 && !routerInstalled && (await installedBundles(repositoryPath, sourcePath)).length > 0;
134
+ if (bundlesOnly) {
135
+ for (const name of await installedBundles(repositoryPath, sourcePath)) {
136
+ bundles.push(await installBundle(repositoryPath, name, sourcePath, { force, dryRun }));
137
+ }
138
+ }
139
+ else if (routes.length > 0 || template === undefined) {
140
+ const selectedRoutes = unionRoutes(routes, installedRouteSet);
131
141
  catalog = await installCatalog(repositoryPath, { force, dryRun, selectedRoutes, inspection, baseline });
132
142
  allConflicts.push(...catalog.conflicts);
133
143
  allInstalled.push(...catalog.installed);
@@ -137,6 +147,9 @@ export async function run(arguments_, repositoryPath = process.cwd()) {
137
147
  }
138
148
  }
139
149
  if (template !== undefined && isBundleTemplate(template)) {
150
+ if (template === "dependabot-merge" && routerInstalled) {
151
+ console.error("warning: the work router already merges Dependabot pull requests in its maintenance run; this workflow does the same work twice.");
152
+ }
140
153
  bundles.push(await installBundle(repositoryPath, template, sourcePath, { force, dryRun }));
141
154
  }
142
155
  else if (template !== undefined) {
@@ -30,13 +30,13 @@ export async function installedBundles(repositoryPath, sourcePath) {
30
30
  }
31
31
  export async function installBundle(repositoryPath, template, sourcePath, options = {}) {
32
32
  const version = options.packageVersion ?? await packageVersion();
33
- const root = bundleRoot(sourcePath, template);
34
- const targets = await bundleTargets(sourcePath, template);
33
+ const files = await bundleFiles(sourcePath, template);
34
+ const targets = files.map((file) => file.target);
35
35
  const changes = [];
36
36
  const writes = [];
37
- for (const target of targets) {
37
+ for (const { source, target } of files) {
38
38
  const current = await readOptional(join(repositoryPath, target));
39
- const text = stampVersion(normalizeEol(await readFile(join(root, target), "utf8")), version);
39
+ const text = stampVersion(normalizeEol(await readFile(source, "utf8")), version);
40
40
  const recorded = current === undefined ? undefined : installedVersion(current);
41
41
  const base = recorded === undefined ? { target } : { target, installedVersion: recorded };
42
42
  if (current === text) {
@@ -89,6 +89,20 @@ function bundleRoot(sourcePath, template) {
89
89
  throw new Error(`${template} is not a bundle template`);
90
90
  return join(sourcePath, "templates", entry.directory);
91
91
  }
92
+ // The bundle's own files, then the actions it borrows from the catalog. Only the former decide whether
93
+ // it is installed: a router consumer carries the same actions without having asked for the bundle.
94
+ async function bundleFiles(sourcePath, template) {
95
+ const root = bundleRoot(sourcePath, template);
96
+ const own = (await bundleTargets(sourcePath, template)).map((target) => ({ source: join(root, target), target }));
97
+ const borrowed = [];
98
+ for (const action of catalogTemplates.find((item) => item.name === template)?.actions ?? []) {
99
+ const directory = join(sourcePath, "actions", action);
100
+ for (const file of (await readdir(directory)).sort()) {
101
+ borrowed.push({ source: join(directory, file), target: `.github/actions/${action}/${file}` });
102
+ }
103
+ }
104
+ return [...own, ...borrowed].sort((left, right) => (left.target < right.target ? -1 : left.target > right.target ? 1 : 0));
105
+ }
92
106
  async function bundleTargets(sourcePath, template) {
93
107
  const root = bundleRoot(sourcePath, template);
94
108
  const entries = await readdir(root, { recursive: true, withFileTypes: true });
@@ -19,7 +19,7 @@ export interface MandatoryFile {
19
19
  }
20
20
  export declare const mandatoryFiles: readonly MandatoryFile[];
21
21
  export declare const generatedConsumerTargets: readonly [".github/workflows/agent-*.lock.yml", ".github/aw/actions-lock.json"];
22
- export declare const templateNames: readonly ["agentics-app-errors", "agentics-checks", "agentics-error-report", "agentics-maintenance", "app-ci-dotnet-next", "app-ci-node-monorepo", "bug-report", "feature-request", "github-release", "opencode.ci.json", "platform-template", "quality-gates"];
22
+ export declare const templateNames: readonly ["agentics-app-errors", "agentics-checks", "agentics-error-report", "agentics-maintenance", "app-ci-dotnet-next", "app-ci-node-monorepo", "bug-report", "dependabot-merge", "feature-request", "github-release", "opencode.ci.json", "platform-template", "quality-gates"];
23
23
  export type TemplateName = (typeof templateNames)[number];
24
24
  export interface CatalogTemplate {
25
25
  readonly name: TemplateName;
@@ -30,6 +30,8 @@ export interface CatalogTemplate {
30
30
  readonly directory?: string;
31
31
  /** Several files under `templates/<directory>/`, package-managed and refreshed by `update` where installed. */
32
32
  readonly bundle?: boolean;
33
+ /** Actions from `actions/` a bundle installs under `.github/actions/`, for a repository without the router. */
34
+ readonly actions?: readonly string[];
33
35
  }
34
36
  export declare const catalogTemplates: readonly CatalogTemplate[];
35
37
  /** Bundle templates: several package-managed files, refreshed by `update` where installed. */
@@ -14,7 +14,7 @@ export const workflowRoutes = [
14
14
  { name: "triage", worker: "agent-triage.md", description: "Triages issues opened from outside the organisation: runs 10 checks (template, security, size, danger, duplicates, clarity, reproducibility, acceptance, cross-cutting, product scope). Loops up to 3 rounds. Passes to refine, asks the author for more, parks out-of-scope work open for a maintainer, or closes what is genuinely rejected.", defaultEnabled: true },
15
15
  { 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 },
16
16
  { name: "merge-gate", worker: "agent-merge-gate.md", description: "Decides what happens to the pull request the merge train hands it, the bot's or a person's: repair it, merge it, or hand it to a human.", defaultEnabled: true },
17
- { 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 },
17
+ { name: "audit", worker: "agent-audit.md", description: "Read-only repository audit. Finds 5-7 problems, scores each 1-10, and files a tracking report plus one story issue per top finding, each sized for Refine.", defaultEnabled: true },
18
18
  { name: "docs-audit", worker: "agent-docs-audit.md", description: "Read-only documentation audit, every fortnight. Checks the documentation against the code and the Platform standard, and files one issue that Refine then sizes and splits, only when something is stale.", defaultEnabled: false },
19
19
  { name: "release", worker: "agent-release.md", description: "Writes release notes from the commit log, then a deterministic job opens a version-bump pull request with auto-merge; once it merges, release-tag.yml tags the commit and publishes the GitHub Release. Manual dispatch only (operation=release).", defaultEnabled: true, companions: ["release-tag.yml"] },
20
20
  ];
@@ -57,6 +57,7 @@ export const templateNames = [
57
57
  "app-ci-dotnet-next",
58
58
  "app-ci-node-monorepo",
59
59
  "bug-report",
60
+ "dependabot-merge",
60
61
  "feature-request",
61
62
  "github-release",
62
63
  "opencode.ci.json",
@@ -71,10 +72,11 @@ export const catalogTemplates = [
71
72
  { 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." },
72
73
  { 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." },
73
74
  { 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" },
75
+ { name: "dependabot-merge", file: ".github/workflows/dependabot-merge.yml", directory: "dependabot-merge", bundle: true, actions: ["merge-dependabot"], description: "Dependabot auto-merge for a repository without the work router: every six hours, squash-merges the Dependabot pull requests whose checks are all green and asks Dependabot to rebase the stale ones. Deterministic, no model, never runs pull request code. Works with GITHUB_TOKEN; the Platform App token is used when its secrets exist. The router does the same in its maintenance run." },
74
76
  { 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" },
75
77
  { name: "github-release", file: "github-release.yml", description: "Publishes or updates a GitHub Release with generated notes whenever a v* tag is pushed." },
76
78
  { 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." },
77
- { name: "platform-template", file: "scripts/check-platform-conformance.mjs", directory: "platform-template", bundle: true, description: "Platform family template sync: a weekly workflow that runs copier update against the Kitchen-Sink template and opens a pull request per new tag, and a CI guard that keeps .platform/manifest paths unchanged outside a sync. Does nothing without .copier-answers.yml." },
79
+ { name: "platform-template", file: "scripts/check-platform-conformance.mjs", directory: "platform-template", bundle: true, description: "Template sync driven by the platform contract (.platform/contract.yml): a weekly workflow that runs its sync.update command and opens or updates one pull request, and a CI guard that keeps its owned paths equal to the template outside a sync. Does nothing without a contract." },
78
80
  { name: "quality-gates", file: "scripts/check-coverage.mjs", directory: "quality-gates", bundle: true, description: "Platform family quality gates: coverage, complexity, duplication and mutation gates, the Engineering Excellence facets, the Semgrep hygiene rules, jscpd and ZAP triage. Floors come from the app's quality.config.json; baselines stay app files." },
79
81
  ];
80
82
  /** Bundle templates: several package-managed files, refreshed by `update` where installed. */
@@ -0,0 +1,193 @@
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/file-audit-issues/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
+ name: File audit issues
3
+ description: >-
4
+ File an audit as a tracking report plus one issue per story. Every body is measured with refine's
5
+ size rule first and trimmed to fit, the stories get the work labels and link back to the report,
6
+ and the report lists them. Re-running it reuses what an earlier attempt already filed.
7
+ inputs:
8
+ output-file:
9
+ description: Path to agent_output.json.
10
+ required: true
11
+ token:
12
+ description: GitHub token with issues:write. An App token, so the story labels reach the router.
13
+ required: true
14
+ report-labels:
15
+ description: Labels for the report, one per line. Never a work label.
16
+ required: false
17
+ default: |-
18
+ audit
19
+ bug
20
+ story-labels:
21
+ description: Labels for each story, one per line. The work label goes last.
22
+ required: false
23
+ default: |-
24
+ bug
25
+ refine
26
+ max-body-chars:
27
+ description: Largest body any filed issue may have, measured as refine measures it.
28
+ required: true
29
+ max-work-units:
30
+ description: Most bullet lines any filed issue may have, measured as refine measures it.
31
+ required: true
32
+ max-stories:
33
+ description: Most stories filed from one audit.
34
+ required: false
35
+ default: '3'
36
+ run-url:
37
+ description: This run, cited in a body that had to be trimmed.
38
+ required: false
39
+ default: ''
40
+ report-title:
41
+ description: Pattern, case-insensitive, of the report's title. Every other issue the agent filed is a story.
42
+ required: false
43
+ default: '^audit\b'
44
+ outputs:
45
+ report-number:
46
+ description: The report's issue number, or empty when the agent filed nothing.
47
+ value: ${{ steps.file.outputs.report-number }}
48
+ story-numbers:
49
+ description: Space-separated numbers of the stories filed or reused.
50
+ value: ${{ steps.file.outputs.story-numbers }}
51
+ runs:
52
+ using: composite
53
+ steps:
54
+ - name: File the report and its stories
55
+ id: file
56
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
57
+ env:
58
+ AGENT_OUTPUT_LIB: ${{ github.action_path }}/../agent-output.cjs
59
+ ISSUE_SIZE_LIB: ${{ github.action_path }}/../issue-size.cjs
60
+ AUDIT_ISSUES_LIB: ${{ github.action_path }}/audit-issues.cjs
61
+ OUTPUT_FILE: ${{ inputs.output-file }}
62
+ REPORT_LABELS: ${{ inputs.report-labels }}
63
+ STORY_LABELS: ${{ inputs.story-labels }}
64
+ MAX_BODY_CHARS: ${{ inputs.max-body-chars }}
65
+ MAX_WORK_UNITS: ${{ inputs.max-work-units }}
66
+ MAX_STORIES: ${{ inputs.max-stories }}
67
+ RUN_URL: ${{ inputs.run-url }}
68
+ REPORT_TITLE: ${{ inputs.report-title }}
69
+ with:
70
+ github-token: ${{ inputs.token }}
71
+ script: |
72
+ const { readAgentItems } = require(process.env.AGENT_OUTPUT_LIB);
73
+ const { measureIssue, sizeVerdict } = require(process.env.ISSUE_SIZE_LIB);
74
+ const audit = require(process.env.AUDIT_ISSUES_LIB);
75
+
76
+ const list = (value) => String(value ?? '').split(/\r?\n|,/).map((label) => label.trim()).filter(Boolean);
77
+ const reportLabels = list(process.env.REPORT_LABELS);
78
+ const storyLabels = list(process.env.STORY_LABELS);
79
+ const limits = { maxBodyChars: Number(process.env.MAX_BODY_CHARS), maxWorkUnits: Number(process.env.MAX_WORK_UNITS) };
80
+ if (!(limits.maxBodyChars > 0 && limits.maxWorkUnits > 0)) {
81
+ core.setFailed(`Invalid size limits: ${JSON.stringify(limits)}`);
82
+ return;
83
+ }
84
+
85
+ const plan = audit.planAuditIssues(readAgentItems(process.env.OUTPUT_FILE, 'create_issue'), {
86
+ limits,
87
+ maxStories: Number(process.env.MAX_STORIES) || 3,
88
+ runUrl: process.env.RUN_URL,
89
+ reportPattern: new RegExp(process.env.REPORT_TITLE || '^audit\\b', 'i'),
90
+ });
91
+ if (!plan) {
92
+ core.info('The agent filed no issue.');
93
+ core.setOutput('report-number', '');
94
+ core.setOutput('story-numbers', '');
95
+ return;
96
+ }
97
+
98
+ // The pre-flight: nothing is filed unless every body, rendered with the widest issue
99
+ // number, passes the same rule the refine guard applies.
100
+ const widest = audit.PLACEHOLDER_NUMBER;
101
+ const checks = [
102
+ { title: plan.report.title, body: audit.renderReport(plan.report.body, plan.stories.map((s) => ({ number: widest, title: s.title, tracked: true }))) },
103
+ ...plan.stories.map((s) => ({ title: s.title, body: audit.renderStory(s.body, widest) })),
104
+ ];
105
+ for (const check of checks) {
106
+ const verdict = sizeVerdict(measureIssue(check.title, check.body), limits);
107
+ if (verdict.tooBig) {
108
+ core.setFailed(`"${check.title}" would be refused by refine (${verdict.reason}); nothing was filed.`);
109
+ return;
110
+ }
111
+ }
112
+
113
+ const open = (await github.paginate(github.rest.issues.listForRepo, { ...context.repo, state: 'open', per_page: 100 }))
114
+ .filter((issue) => !issue.pull_request);
115
+ const byTitle = new Map(open.map((issue) => [audit.titleKey(issue.title), issue]));
116
+ const labelNames = (issue) => issue.labels.map((label) => (typeof label === 'string' ? label : label.name));
117
+
118
+ for (const name of new Set([...reportLabels, ...storyLabels])) {
119
+ await github.rest.issues.createLabel({ ...context.repo, name, color: 'ededed' })
120
+ .catch((error) => { if (error.status !== 422) throw error; });
121
+ }
122
+
123
+ // A re-run finds the report it filed before: same title, carrying the report labels.
124
+ let report = byTitle.get(audit.titleKey(plan.report.title));
125
+ if (report && reportLabels.every((label) => labelNames(report).includes(label))) {
126
+ core.info(`Reusing the report #${report.number}.`);
127
+ } else {
128
+ ({ data: report } = await github.rest.issues.create({
129
+ ...context.repo,
130
+ title: plan.report.title,
131
+ body: audit.renderReport(plan.report.body, []),
132
+ }));
133
+ core.info(`Filed the report #${report.number}.`);
134
+ }
135
+ const missing = reportLabels.filter((label) => !labelNames(report).includes(label));
136
+ if (missing.length > 0) {
137
+ await github.rest.issues.addLabels({ ...context.repo, issue_number: report.number, labels: missing });
138
+ }
139
+
140
+ const links = [];
141
+ const marker = audit.reportMarker(report.number);
142
+ for (const story of plan.stories) {
143
+ const existing = byTitle.get(audit.titleKey(story.title));
144
+ if (existing && !(existing.body ?? '').includes(marker)) {
145
+ // Already tracked by an issue this audit did not file: point at it, file nothing.
146
+ core.info(`"${story.title}" is already tracked by #${existing.number}.`);
147
+ links.push({ number: existing.number, title: story.title, tracked: true });
148
+ continue;
149
+ }
150
+ let issue = existing;
151
+ if (!issue) {
152
+ ({ data: issue } = await github.rest.issues.create({
153
+ ...context.repo,
154
+ title: story.title,
155
+ body: audit.renderStory(story.body, report.number),
156
+ }));
157
+ core.info(`Filed #${issue.number}: ${story.title}${story.trimmed ? ' (trimmed)' : ''}`);
158
+ }
159
+ try {
160
+ await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/sub_issues', {
161
+ ...context.repo,
162
+ issue_number: report.number,
163
+ sub_issue_id: issue.id,
164
+ });
165
+ } catch (error) {
166
+ // The report's checklist below links it either way; 422 is "already a sub-issue".
167
+ if (error.status !== 422) core.warning(`Could not link #${issue.number} under #${report.number}: ${error.message}`);
168
+ }
169
+ // Last, so refine starts on a story whose body and link are already in place. A story an
170
+ // earlier attempt labelled is left alone: adding `refine` again would start refine again.
171
+ if (storyLabels.length > 0 && labelNames(issue).length === 0) {
172
+ await github.rest.issues.addLabels({ ...context.repo, issue_number: issue.number, labels: storyLabels });
173
+ }
174
+ links.push({ number: issue.number, title: story.title, tracked: false });
175
+ }
176
+
177
+ await github.rest.issues.update({
178
+ ...context.repo,
179
+ issue_number: report.number,
180
+ body: audit.renderReport(plan.report.body, links),
181
+ });
182
+
183
+ for (const title of plan.dropped) core.warning(`Not filed, over the ${process.env.MAX_STORIES} story limit: ${title}`);
184
+ core.setOutput('report-number', String(report.number));
185
+ core.setOutput('story-numbers', links.filter((link) => !link.tracked).map((link) => link.number).join(' '));
186
+ await core.summary
187
+ .addHeading('Audit filed', 2)
188
+ .addList([
189
+ `Report #${report.number}${plan.report.trimmed ? ' (trimmed to fit)' : ''}`,
190
+ ...links.map((link) => `#${link.number} ${link.title}${link.tracked ? ' (already tracked)' : ''}`),
191
+ ...plan.dropped.map((title) => `Not filed: ${title}`),
192
+ ])
193
+ .write();
@@ -0,0 +1,245 @@
1
+ // Managed by @plainconceptsplatform/workflows. Source: loops/actions/file-audit-issues/audit-issues.cjs. Update with `workflows update --force`; consumer edits may be overwritten.
2
+ 'use strict';
3
+
4
+ // What the audit files, decided apart from the API calls so it can be tested on its own. Pure: no
5
+ // network, no clock. The audit used to file one issue holding every finding and three stories,
6
+ // labelled `refine`; refine's size gate refused every one. Now the report is a tracking issue that
7
+ // refine never sees, each story is its own issue, and every body is measured with refine's own
8
+ // rule before anything is filed: a body over the limits is trimmed here, never filed to be refused.
9
+
10
+ const { measureIssue, sizeVerdict } = require('../issue-size.cjs');
11
+
12
+ // Wide enough for any issue number, so a body measured with it only shrinks once the real one is in.
13
+ const PLACEHOLDER_NUMBER = 9999999;
14
+ const MAX_TITLE = 200;
15
+ const STORY_HEADING = /^(#{1,6})\s+(.*\S)\s*$/;
16
+ // The story section of the old single-issue shape: "## Top 3 — To Implement", "## Section 2 ...".
17
+ const STORY_SECTION = /^(#{1,6})\s+(?:.*\bto implement\b|section 2\b)/i;
18
+ const FENCE = /^\s*(```|~~~)/;
19
+
20
+ const bytes = (text) => Buffer.byteLength(text, 'utf8');
21
+ const units = (text) => measureIssue('', text).units;
22
+ const titleKey = (title) => String(title ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
23
+ const cleanTitle = (title) => {
24
+ const flat = String(title ?? '').replace(/\s+/g, ' ').trim();
25
+ return flat.length > MAX_TITLE ? `${flat.slice(0, MAX_TITLE - 1)}…` : flat;
26
+ };
27
+
28
+ function reportMarker(number) {
29
+ return `<!-- audit-report: ${number} -->`;
30
+ }
31
+
32
+ function renderStory(body, reportNumber) {
33
+ return `${body.replace(/\s+$/, '')}\n\nFound by the audit report #${reportNumber}.\n${reportMarker(reportNumber)}`;
34
+ }
35
+
36
+ function renderReport(body, links) {
37
+ const trimmed = body.replace(/\s+$/, '');
38
+ if (links.length === 0) return trimmed;
39
+ const lines = links.map((link) => `- [ ] #${link.number} ${link.title}${link.tracked ? ' (already tracked)' : ''}`);
40
+ return `${trimmed}\n\n## To implement\n\n${lines.join('\n')}`;
41
+ }
42
+
43
+ // The longest prefix of `text`, whole lines, that fits the budget. An open code fence is closed, so
44
+ // a cut never turns the rest of the issue into code. A first line too long on its own is cut short.
45
+ function trimLines(text, budget) {
46
+ const lines = text.split('\n');
47
+ const kept = [];
48
+ let inFence = false;
49
+ for (const line of lines) {
50
+ const opensOrCloses = FENCE.test(line);
51
+ const nextInFence = opensOrCloses ? !inFence : inFence;
52
+ const candidate = [...kept, line, ...(nextInFence ? ['```'] : [])].join('\n');
53
+ if (bytes(candidate) > budget.bytes || units(candidate) > budget.units) {
54
+ // A bullet cut short is still a bullet, so it is only kept while a unit is left for it.
55
+ if (kept.length === 0 && units(line) <= budget.units) {
56
+ const chars = [...line];
57
+ while (chars.length > 0 && bytes(`${chars.join('')}…`) > budget.bytes) chars.pop();
58
+ if (chars.length > 0) kept.push(`${chars.join('')}…`);
59
+ }
60
+ if (inFence) kept.push('```');
61
+ return { text: kept.join('\n'), trimmed: true };
62
+ }
63
+ kept.push(line);
64
+ inFence = nextInFence;
65
+ }
66
+ return { text: kept.join('\n'), trimmed: false };
67
+ }
68
+
69
+ function trimNote(runUrl) {
70
+ return runUrl
71
+ ? `_Trimmed to fit refine's size limits. The full text is in the audit run: ${runUrl}_`
72
+ : "_Trimmed to fit refine's size limits._";
73
+ }
74
+
75
+ // Fits `body` so that title + body + suffix passes the limits, trimming from the end.
76
+ function fitBody(title, body, suffix, limits, runUrl) {
77
+ const fits = (text) => !sizeVerdict(measureIssue(title, `${text}${suffix}`), limits).tooBig;
78
+ if (fits(body)) return { body, trimmed: false };
79
+ const note = `\n\n${trimNote(runUrl)}`;
80
+ const overhead = measureIssue(title, `${note}${suffix}`);
81
+ const budget = { bytes: limits.maxBodyChars - overhead.chars - 1, units: limits.maxWorkUnits - overhead.units };
82
+ const { text } = trimLines(body, budget);
83
+ const trimmedBody = `${text}${note}`;
84
+ return { body: fits(trimmedBody) ? trimmedBody : trimNote(runUrl), trimmed: true };
85
+ }
86
+
87
+ // Splits markdown at headings (only those `depth` deep, when given) into [preamble, ...sections].
88
+ function splitAtHeadings(body, depth = 0) {
89
+ const blocks = [];
90
+ let current = [];
91
+ let inFence = false;
92
+ for (const line of body.split('\n')) {
93
+ if (FENCE.test(line)) inFence = !inFence;
94
+ const heading = !inFence && STORY_HEADING.exec(line);
95
+ if (heading && (!depth || heading[1].length === depth) && current.length > 0) {
96
+ blocks.push(current.join('\n'));
97
+ current = [];
98
+ }
99
+ current.push(line);
100
+ }
101
+ blocks.push(current.join('\n'));
102
+ return blocks;
103
+ }
104
+
105
+ // The old single-issue shape carried its stories under a "To implement" heading. Lifts that section
106
+ // out of the report and returns its subsections as stories, so a report written the old way still
107
+ // becomes a report plus stories rather than one issue refine refuses.
108
+ function extractStorySection(body) {
109
+ const lines = body.split('\n');
110
+ let start = -1;
111
+ let level = 0;
112
+ let inFence = false;
113
+ for (const [index, line] of lines.entries()) {
114
+ if (FENCE.test(line)) inFence = !inFence;
115
+ if (inFence) continue;
116
+ const match = STORY_SECTION.exec(line);
117
+ if (match) {
118
+ start = index;
119
+ level = match[1].length;
120
+ break;
121
+ }
122
+ }
123
+ if (start === -1) return { report: body, stories: [] };
124
+
125
+ let end = lines.length;
126
+ inFence = false;
127
+ for (let index = start + 1; index < lines.length; index += 1) {
128
+ if (FENCE.test(lines[index])) inFence = !inFence;
129
+ const heading = !inFence && STORY_HEADING.exec(lines[index]);
130
+ if (heading && heading[1].length <= level) {
131
+ end = index;
132
+ break;
133
+ }
134
+ }
135
+
136
+ // Stories are the shallowest headings inside the section; deeper ones belong to a story.
137
+ const section = lines.slice(start + 1, end).join('\n');
138
+ let fenced = false;
139
+ const depths = section.split('\n')
140
+ .map((line) => ((fenced = FENCE.test(line) ? !fenced : fenced) ? null : STORY_HEADING.exec(line)))
141
+ .filter(Boolean)
142
+ .map((match) => match[1].length);
143
+ const depth = depths.length > 0 ? Math.min(...depths) : 0;
144
+ const stories = splitAtHeadings(section, depth)
145
+ .map((block) => {
146
+ const [first, ...rest] = block.split('\n');
147
+ const heading = STORY_HEADING.exec(first);
148
+ if (!heading || heading[1].length !== depth) return null;
149
+ const title = heading[2].replace(/^(story\s+\w+|\d+[.)])\s*[—–:-]\s*/i, '');
150
+ return { title, body: rest.join('\n').trim() };
151
+ })
152
+ .filter((story) => story && story.title && story.body);
153
+
154
+ const report = [...lines.slice(0, start), ...lines.slice(end)].join('\n').replace(/\s+$/, '');
155
+ return { report, stories };
156
+ }
157
+
158
+ // Shares a budget among blocks so short ones keep everything and long ones split what is left.
159
+ function shareBudget(sizes, total) {
160
+ const shares = new Array(sizes.length).fill(0);
161
+ let remaining = Math.max(0, total);
162
+ const order = sizes.map((size, index) => ({ size, index })).sort((a, b) => a.size - b.size);
163
+ order.forEach(({ size, index }, position) => {
164
+ const fair = Math.floor(remaining / (order.length - position));
165
+ shares[index] = Math.min(size, fair);
166
+ remaining -= shares[index];
167
+ });
168
+ return shares;
169
+ }
170
+
171
+ // Fits the report by trimming every finding to a share of the budget, so all of them stay listed
172
+ // with their heading and first lines rather than the last ones vanishing.
173
+ function fitReport(title, body, suffix, limits, runUrl) {
174
+ const fits = (text) => !sizeVerdict(measureIssue(title, `${text}${suffix}`), limits).tooBig;
175
+ if (fits(body)) return { body, trimmed: false };
176
+ const note = `\n\n${trimNote(runUrl)}`;
177
+ const blocks = splitAtHeadings(body);
178
+ const overhead = measureIssue(title, `${note}${suffix}`);
179
+ const separators = 2 * Math.max(0, blocks.length - 1);
180
+ const byteShares = shareBudget(blocks.map(bytes), limits.maxBodyChars - overhead.chars - separators - 1);
181
+ const unitShares = shareBudget(blocks.map(units), limits.maxWorkUnits - overhead.units);
182
+ const compact = blocks.map((block, index) => trimLines(block, { bytes: byteShares[index], units: unitShares[index] }).text.trimEnd())
183
+ .filter((block) => block.length > 0)
184
+ .join('\n\n');
185
+ const trimmedBody = `${compact}${note}`;
186
+ if (fits(trimmedBody)) return { body: trimmedBody, trimmed: true };
187
+ return fitBody(title, compact, suffix, limits, runUrl);
188
+ }
189
+
190
+ // `items` are the agent's create_issue items. The report is the one whose title matches
191
+ // `reportPattern` ("Audit: ..." by default, "Docs audit: ..." for docs-audit), or the first; the
192
+ // others are stories. Returns bodies that pass `limits` once rendered with any issue
193
+ // number, which is the refine guard's check run before filing instead of after.
194
+ function planAuditIssues(items, options) {
195
+ const { limits, maxStories = 3, runUrl = '', reportPattern = /^audit\b/i } = options;
196
+ const usable = items.filter((item) => item && String(item.title ?? '').trim());
197
+ if (usable.length === 0) return null;
198
+
199
+ const reportIndex = Math.max(0, usable.findIndex((item) => reportPattern.test(String(item.title).trim())));
200
+ const reportItem = usable[reportIndex];
201
+ const lifted = extractStorySection(String(reportItem.body ?? ''));
202
+ const separate = usable.filter((_, index) => index !== reportIndex)
203
+ .map((item) => ({ title: item.title, body: String(item.body ?? '').trim() }));
204
+
205
+ // Separate stories win; the lifted section is only used when the agent filed the old shape.
206
+ const candidates = (separate.length > 0 ? separate : lifted.stories)
207
+ .map((story) => ({ title: cleanTitle(story.title), body: story.body }))
208
+ .filter((story) => story.title && story.body);
209
+ const seen = new Set();
210
+ const unique = candidates.filter((story) => {
211
+ const key = titleKey(story.title);
212
+ if (seen.has(key)) return false;
213
+ seen.add(key);
214
+ return true;
215
+ });
216
+ const dropped = unique.slice(maxStories).map((story) => story.title);
217
+
218
+ const stories = unique.slice(0, maxStories).map((story) => {
219
+ const suffix = renderStory('', PLACEHOLDER_NUMBER);
220
+ const fitted = fitBody(story.title, story.body, suffix, limits, runUrl);
221
+ return { title: story.title, body: fitted.body, trimmed: fitted.trimmed };
222
+ });
223
+
224
+ const reportTitle = cleanTitle(reportItem.title);
225
+ const links = stories.map((story) => ({ number: PLACEHOLDER_NUMBER, title: story.title, tracked: true }));
226
+ const suffix = renderReport('', links);
227
+ const fitted = fitReport(reportTitle, lifted.report.trim(), suffix, limits, runUrl);
228
+
229
+ return {
230
+ report: { title: reportTitle, body: fitted.body, trimmed: fitted.trimmed, lifted: lifted.stories.length },
231
+ stories,
232
+ dropped,
233
+ };
234
+ }
235
+
236
+ module.exports = {
237
+ PLACEHOLDER_NUMBER,
238
+ extractStorySection,
239
+ planAuditIssues,
240
+ renderReport,
241
+ renderStory,
242
+ reportMarker,
243
+ titleKey,
244
+ trimLines,
245
+ };
@@ -0,0 +1,36 @@
1
+ // Managed by @plainconceptsplatform/workflows. Source: loops/actions/issue-size.cjs. Update with `workflows update --force`; consumer edits may be overwritten.
2
+ 'use strict';
3
+
4
+ // Refine's size gate, as one rule both sides read: the refine guard measures an issue with it,
5
+ // and the audit measures every issue it files with it before filing. While the audit had no way to
6
+ // ask, every report it filed from 2026-09-21 on was over the gate and was refused.
7
+ //
8
+ // The numbers match what the guard's shell counted: `wc -c` (UTF-8 bytes) of the title, a blank
9
+ // line and the body with trailing newlines dropped, and one work unit per `-` or `*` bullet line.
10
+
11
+ const BULLET = /^[ \t\r\v\f]*[-*] /;
12
+
13
+ function issueText(title, body) {
14
+ return `${title ?? ''}\n\n${body ?? ''}`.replace(/\n+$/, '');
15
+ }
16
+
17
+ function measureIssue(title, body) {
18
+ const text = issueText(title, body);
19
+ return {
20
+ chars: Buffer.byteLength(text, 'utf8'),
21
+ units: text.split('\n').filter((line) => BULLET.test(line)).length,
22
+ };
23
+ }
24
+
25
+ function sizeVerdict(size, limits) {
26
+ const reasons = [];
27
+ if (size.chars > limits.maxBodyChars) reasons.push(`body ${size.chars} chars > ${limits.maxBodyChars}`);
28
+ if (size.units > limits.maxWorkUnits) reasons.push(`work units ${size.units} > ${limits.maxWorkUnits}`);
29
+ return { tooBig: reasons.length > 0, reason: reasons.join('; ') };
30
+ }
31
+
32
+ function fitsLimits(title, body, limits) {
33
+ return !sizeVerdict(measureIssue(title, body), limits).tooBig;
34
+ }
35
+
36
+ module.exports = { BULLET, measureIssue, sizeVerdict, fitsLimits };