@plainconceptsplatform/workflows 1.9.0 → 1.9.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.
- package/README.md +1 -1
- package/dist/catalog-installation.js +2 -2
- package/dist/route-processing.js +4 -1
- package/dist/worker-env.d.ts +1 -1
- package/dist/worker-env.js +6 -2
- package/dist/workflow-catalog.d.ts +3 -1
- package/dist/workflow-catalog.js +6 -0
- package/loops/actions/audit-close/action.yml +13 -6
- package/loops/actions/classify-route/classify-route.sh +3 -1
- package/loops/actions/collect-doc-drift/action.yml +29 -0
- package/loops/actions/collect-doc-drift/collect-doc-drift.mjs +129 -0
- package/loops/actions/land-pull-request/action.yml +10 -0
- package/loops/actions/land-pull-request/land-pull-request.sh +14 -0
- package/loops/actions/report-workflow-errors/action.yml +1 -1
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +36 -5
- package/loops/docs/diagrams.md +45 -2
- package/loops/workflows/agent-docs-audit.md +266 -0
- package/loops/workflows/work-router.yml +43 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -46,7 +46,7 @@ workflows add refine --template agentics-checks --force # routes + template + m
|
|
|
46
46
|
workflows remove audit # uninstall the audit worker and drop it from the router
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
Route names: refine, implement, triage, apply-review, merge-gate, audit, release. Unknown arguments produce an error.
|
|
49
|
+
Route names: refine, implement, triage, apply-review, merge-gate, audit, docs-audit, release. `docs-audit` is optional: it installs only when named. Unknown arguments produce an error.
|
|
50
50
|
|
|
51
51
|
## Update
|
|
52
52
|
|
|
@@ -5,7 +5,7 @@ import { dirname, join, relative, resolve } from "node:path";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { promisify } from "node:util";
|
|
7
7
|
import { parse as parseYaml } from "yaml";
|
|
8
|
-
import { catalogTemplates, mandatoryFiles, retiredWorkflowFiles,
|
|
8
|
+
import { catalogTemplates, defaultRouteNames, mandatoryFiles, retiredWorkflowFiles, templateNames, workflowRoutes } from "./workflow-catalog.js";
|
|
9
9
|
import { processRoutes, excludedWorkerFiles } from "./route-processing.js";
|
|
10
10
|
import { generateOpencodeCi, generateOpencodeConfig, generateStackDefaults, injectStackEnv, shippedVerifyDefaults } from "./stack-defaults.js";
|
|
11
11
|
import { mergeRouter, mergeWorker } from "./worker-env.js";
|
|
@@ -40,7 +40,7 @@ const normalizeEol = (text) => text.replaceAll("\r\n", "\n");
|
|
|
40
40
|
export async function installCatalog(repositoryPath, options = {}) {
|
|
41
41
|
const version = options.packageVersion ?? await packageVersion();
|
|
42
42
|
const sourcePath = options.sourcePath ?? catalogSourcePath();
|
|
43
|
-
const selectedRoutes = options.selectedRoutes ??
|
|
43
|
+
const selectedRoutes = options.selectedRoutes ?? defaultRouteNames;
|
|
44
44
|
const allFiles = [...await catalogFiles(sourcePath), ...mandatoryFileSpecs(sourcePath)];
|
|
45
45
|
const deduplicated = allFiles.filter((file, index) => allFiles.findIndex((f) => f.target === file.target) === index).sort((left, right) => left.target.localeCompare(right.target));
|
|
46
46
|
const excluded = excludedWorkerFiles(selectedRoutes);
|
package/dist/route-processing.js
CHANGED
|
@@ -4,6 +4,7 @@ import { routeNames, workflowRoutes } from "./workflow-catalog.js";
|
|
|
4
4
|
// route matrix reads the installed worker files, so neither is rewritten here.
|
|
5
5
|
const routeCrons = {
|
|
6
6
|
audit: "17 1 * * 1",
|
|
7
|
+
"docs-audit": "47 2 1,15 * *",
|
|
7
8
|
};
|
|
8
9
|
// Deterministic router jobs that exist only to serve one worker route. They leave with it, or
|
|
9
10
|
// the route matrix finds a `route == 'implement'` guard in a router with no implement worker.
|
|
@@ -19,9 +20,11 @@ const routeHelperOperations = {
|
|
|
19
20
|
};
|
|
20
21
|
export function stripRouteFromRouter(yaml, route) {
|
|
21
22
|
let result = yaml;
|
|
23
|
+
// By the slot comment as well as the value: a repository picks its own slot, and the shipped
|
|
24
|
+
// line carries that comment, so matching the bare default alone left the cron behind.
|
|
22
25
|
const cron = routeCrons[route];
|
|
23
26
|
if (cron !== undefined) {
|
|
24
|
-
result = result.replace(new RegExp(`^ - cron: "
|
|
27
|
+
result = result.replace(new RegExp(`^ - cron: "(?:${escapeRegex(cron)}"|[^"]*" # ${escapeRegex(route)} slot.*)\\n`, "gm"), "");
|
|
25
28
|
}
|
|
26
29
|
for (const job of [`call-${route}`, ...(routeHelperJobs[route] ?? [])]) {
|
|
27
30
|
result = removeJobBlock(result, job);
|
package/dist/worker-env.d.ts
CHANGED
|
@@ -32,7 +32,7 @@ export declare function mergeWorkerEnv(packageContent: string, consumerContent:
|
|
|
32
32
|
content: string;
|
|
33
33
|
report: WorkerMergeReport;
|
|
34
34
|
};
|
|
35
|
-
/** Copy the router's own `env:` values into the
|
|
35
|
+
/** Copy the router's own `env:` values into the literal lines that cannot read them. */
|
|
36
36
|
export declare function mirrorRouterLiterals(router: string): string;
|
|
37
37
|
/** The package's router with the consumer's `env:` values put back and mirrored into place. */
|
|
38
38
|
export declare function mergeRouter(packageContent: string, consumerContent: string, baselineContent?: string): {
|
package/dist/worker-env.js
CHANGED
|
@@ -142,13 +142,14 @@ function engineEndpoint(content) {
|
|
|
142
142
|
// now: every job, router and release included, belongs on the Azure fleet (agents-arc, runner
|
|
143
143
|
// group `agentic`). The route matrix asserts it, so a repo cannot drift back without a red run. Per the standing rule for this package, anything a
|
|
144
144
|
// consumer really must vary belongs in `env:` at the top of the file, not in `runs-on`.
|
|
145
|
-
// GitHub evaluates no expression in a `workflow_run.workflows:` list or in a `cron:`, so the
|
|
145
|
+
// GitHub evaluates no expression in a `workflow_run.workflows:` list or in a `cron:`, so the
|
|
146
146
|
// router values that are also needed there cannot be read from `env:` at those two lines. The
|
|
147
147
|
// installer copies them in instead, which is what keeps the pair in step across an update; the
|
|
148
148
|
// route matrix asserts the copies still agree.
|
|
149
149
|
const CI_NAME_TRIGGER = /^(\s*workflows: \[")[^"]*("\]\s*)$/m;
|
|
150
150
|
const AUDIT_CRON_LINE = /^(\s*- cron: ")[^"]*(" # audit slot.*)$/m;
|
|
151
|
-
|
|
151
|
+
const DOCS_AUDIT_CRON_LINE = /^(\s*- cron: ")[^"]*(" # docs-audit slot.*)$/m;
|
|
152
|
+
/** Copy the router's own `env:` values into the literal lines that cannot read them. */
|
|
152
153
|
export function mirrorRouterLiterals(router) {
|
|
153
154
|
const values = new Map(parseYamlEnv(router).entries.map((entry) => [entry.key, unquote(entryValue(entry))]));
|
|
154
155
|
const ciName = values.get("CI_WORKFLOW_NAME");
|
|
@@ -158,6 +159,9 @@ export function mirrorRouterLiterals(router) {
|
|
|
158
159
|
result = result.replace(CI_NAME_TRIGGER, `$1${ciName}$2`);
|
|
159
160
|
if (auditCron !== undefined)
|
|
160
161
|
result = result.replace(AUDIT_CRON_LINE, `$1${auditCron}$2`);
|
|
162
|
+
const docsAuditCron = values.get("DOCS_AUDIT_CRON");
|
|
163
|
+
if (docsAuditCron !== undefined)
|
|
164
|
+
result = result.replace(DOCS_AUDIT_CRON_LINE, `$1${docsAuditCron}$2`);
|
|
161
165
|
return result;
|
|
162
166
|
}
|
|
163
167
|
function unquote(value) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const routeNames: readonly ["refine", "implement", "triage", "apply-review", "merge-gate", "audit", "release"];
|
|
1
|
+
export declare const routeNames: readonly ["refine", "implement", "triage", "apply-review", "merge-gate", "audit", "docs-audit", "release"];
|
|
2
2
|
export type RouteName = (typeof routeNames)[number];
|
|
3
3
|
export interface WorkflowRoute {
|
|
4
4
|
readonly name: RouteName;
|
|
@@ -9,6 +9,8 @@ export interface WorkflowRoute {
|
|
|
9
9
|
readonly companions?: readonly string[];
|
|
10
10
|
}
|
|
11
11
|
export declare const workflowRoutes: readonly WorkflowRoute[];
|
|
12
|
+
/** What an install with no explicit selection gets. An optional worker is added by name. */
|
|
13
|
+
export declare const defaultRouteNames: readonly RouteName[];
|
|
12
14
|
export declare const retiredWorkflowFiles: readonly ["agent-visual-verify.md", "agent-visual-verify.lock.yml"];
|
|
13
15
|
export declare const packageOwnedTargets: readonly [".github/actions", ".github/workflows/agent-*.md", ".github/workflows/shared/platform-defaults.md", ".github/workflows/shared/opencode-ci.md", ".github/workflows/work-router.yml", ".github/workflows/evidence.yml", "scripts/compile-agent-workflows.mjs", "opencode.ci.json", "docs/diagrams.md"];
|
|
14
16
|
export interface MandatoryFile {
|
package/dist/workflow-catalog.js
CHANGED
|
@@ -5,6 +5,7 @@ export const routeNames = [
|
|
|
5
5
|
"apply-review",
|
|
6
6
|
"merge-gate",
|
|
7
7
|
"audit",
|
|
8
|
+
"docs-audit",
|
|
8
9
|
"release",
|
|
9
10
|
];
|
|
10
11
|
export const workflowRoutes = [
|
|
@@ -14,8 +15,13 @@ export const workflowRoutes = [
|
|
|
14
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 },
|
|
15
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 },
|
|
16
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 },
|
|
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 },
|
|
17
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"] },
|
|
18
20
|
];
|
|
21
|
+
/** What an install with no explicit selection gets. An optional worker is added by name. */
|
|
22
|
+
export const defaultRouteNames = workflowRoutes
|
|
23
|
+
.filter((route) => route.defaultEnabled)
|
|
24
|
+
.map((route) => route.name);
|
|
19
25
|
// Workflow files the package used to ship and no longer does. `.github/workflows/` is not pruned
|
|
20
26
|
// wholesale, because a missing worker there means a route the consumer left out, so a retired one
|
|
21
27
|
// is named here or it sits in every consumer forever with a lock that still compiles.
|
|
@@ -21,12 +21,19 @@ runs:
|
|
|
21
21
|
with:
|
|
22
22
|
github-token: ${{ inputs.token }}
|
|
23
23
|
script: |
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
24
|
+
// A documentation audit is the same chain: a report Refine splits into children. A
|
|
25
|
+
// report carrying both labels must not be processed twice.
|
|
26
|
+
const reportsById = new Map();
|
|
27
|
+
for (const labels of ['audit', 'docs-audit']) {
|
|
28
|
+
const found = await github.paginate(github.rest.issues.listForRepo, {
|
|
29
|
+
...context.repo,
|
|
30
|
+
state: 'open',
|
|
31
|
+
labels,
|
|
32
|
+
per_page: 100,
|
|
33
|
+
});
|
|
34
|
+
for (const report of found) reportsById.set(report.number, report);
|
|
35
|
+
}
|
|
36
|
+
const reports = [...reportsById.values()];
|
|
30
37
|
|
|
31
38
|
if (reports.length === 0) {
|
|
32
39
|
core.info('No open audit reports.');
|
|
@@ -13,6 +13,7 @@ set -euo pipefail
|
|
|
13
13
|
# fired on a cron this file does not know classifies to no route and dies silently, so the
|
|
14
14
|
# route matrix asserts the two still agree.
|
|
15
15
|
readonly AUDIT_CRON="${AUDIT_CRON:-17 1 * * 1}"
|
|
16
|
+
readonly DOCS_AUDIT_CRON="${DOCS_AUDIT_CRON:-47 2 1,15 * *}"
|
|
16
17
|
readonly AUDIT_CLOSE_CRON="43 3 * * *"
|
|
17
18
|
readonly CLEANUP_ARTIFACTS_CRON="0 6 * * *"
|
|
18
19
|
readonly RECONCILE_BOT_PR_RUNS_CRON="17 * * * *"
|
|
@@ -192,6 +193,7 @@ classify_route() {
|
|
|
192
193
|
trigger_kind="scheduled"
|
|
193
194
|
case "${SCHEDULE:-}" in
|
|
194
195
|
"$AUDIT_CRON") route="audit" ;;
|
|
196
|
+
"$DOCS_AUDIT_CRON") route="docs-audit" ;;
|
|
195
197
|
"$AUDIT_CLOSE_CRON") route="audit-close" ;;
|
|
196
198
|
"$CLEANUP_ARTIFACTS_CRON") route="cleanup-artifacts" ;;
|
|
197
199
|
"$RECONCILE_BOT_PR_RUNS_CRON") route="reconcile-bot-pr-runs" ;;
|
|
@@ -266,7 +268,7 @@ classify_route() {
|
|
|
266
268
|
error="operation 'evidence' needs a positive pr-number, got '${INPUT_PR_NUMBER:-}'"
|
|
267
269
|
fi
|
|
268
270
|
;;
|
|
269
|
-
audit)
|
|
271
|
+
audit | docs-audit)
|
|
270
272
|
route="${OPERATION}"
|
|
271
273
|
trigger_kind="${INPUT_TRIGGER_KIND:-manual}"
|
|
272
274
|
;;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Managed by @plainconceptsplatform/workflows. Source: loops/actions/collect-doc-drift/action.yml. Update with workflows update --force; consumer edits may be overwritten.
|
|
2
|
+
name: Collect documentation drift
|
|
3
|
+
description: |
|
|
4
|
+
Writes the documentation facts a script can establish on its own to
|
|
5
|
+
`/tmp/gh-aw/agent/doc-drift.json`: relative Markdown links that lead nowhere, and paths the
|
|
6
|
+
entry documents cite that no longer exist. Free, deterministic, no model.
|
|
7
|
+
inputs:
|
|
8
|
+
entry-docs:
|
|
9
|
+
description: Comma-separated entry documents, whose cited paths are checked.
|
|
10
|
+
required: false
|
|
11
|
+
default: README.md,ARCHITECTURE.md,AGENTS.md,CLAUDE.md,CONTRIBUTING.md,docs/README.md
|
|
12
|
+
exclude:
|
|
13
|
+
description: Comma-separated path prefixes left out of the scan, such as archives.
|
|
14
|
+
required: false
|
|
15
|
+
default: .github/workflows/,.agents/,.claude/,.opencode/,node_modules/
|
|
16
|
+
output-path:
|
|
17
|
+
description: Path to write the JSON output.
|
|
18
|
+
required: false
|
|
19
|
+
default: /tmp/gh-aw/agent/doc-drift.json
|
|
20
|
+
runs:
|
|
21
|
+
using: composite
|
|
22
|
+
steps:
|
|
23
|
+
- name: Collect documentation drift
|
|
24
|
+
shell: bash
|
|
25
|
+
env:
|
|
26
|
+
ENTRY_DOCS: ${{ inputs.entry-docs }}
|
|
27
|
+
EXCLUDE: ${{ inputs.exclude }}
|
|
28
|
+
OUTPUT_PATH: ${{ inputs.output-path }}
|
|
29
|
+
run: node "${GITHUB_ACTION_PATH}/collect-doc-drift.mjs"
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Managed by @plainconceptsplatform/workflows. Source: loops/actions/collect-doc-drift/collect-doc-drift.mjs. Update with workflows update --force; consumer edits may be overwritten.
|
|
2
|
+
//
|
|
3
|
+
// Everything here is something a model would otherwise spend turns establishing, and could get
|
|
4
|
+
// wrong. Existence only: whether a link's anchor resolves, or whether a sentence is still true,
|
|
5
|
+
// is left to the agent.
|
|
6
|
+
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { dirname, join, normalize } from "node:path";
|
|
10
|
+
import { pathToFileURL } from "node:url";
|
|
11
|
+
|
|
12
|
+
// A report nobody can finish reading is a report nobody acts on, and it costs input tokens.
|
|
13
|
+
const MAX_LISTED = 200;
|
|
14
|
+
|
|
15
|
+
const FENCE = /^\s*(```|~~~)/;
|
|
16
|
+
const INLINE_LINK = /(?<!!)\[[^\]]*\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)/g;
|
|
17
|
+
const IMAGE_LINK = /!\[[^\]]*\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)/g;
|
|
18
|
+
const CODE_SPAN = /`([^`\n]+)`/g;
|
|
19
|
+
const CITED_PATH = /^\.?\/?[\w.-]+(\/[\w.-]+)*\/?$/;
|
|
20
|
+
const EXTERNAL = /^[a-z][a-z0-9+.-]*:|^\/\//i;
|
|
21
|
+
|
|
22
|
+
const splitList = (value) => (value ?? "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
23
|
+
|
|
24
|
+
export const isExcluded = (path, prefixes) => prefixes.some((prefix) => path.startsWith(prefix));
|
|
25
|
+
|
|
26
|
+
/** The file a link points at, or null when it does not point at a file in this repository. */
|
|
27
|
+
export function linkTarget(fromFile, href) {
|
|
28
|
+
if (href.startsWith("#") || EXTERNAL.test(href)) return null;
|
|
29
|
+
const path = href.split(/[#?]/)[0];
|
|
30
|
+
if (path === "") return null;
|
|
31
|
+
let decoded = path;
|
|
32
|
+
try {
|
|
33
|
+
decoded = decodeURIComponent(path);
|
|
34
|
+
} catch {
|
|
35
|
+
// A literal % that is not an escape: the undecoded path is the one to check.
|
|
36
|
+
}
|
|
37
|
+
return normalize(decoded.startsWith("/") ? decoded.slice(1) : join(dirname(fromFile), decoded));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A code span in prose often is a path, but `owner/repo` and `application/json` have the same
|
|
41
|
+
// shape. Requiring an existing first segment keeps paths into this repository and drops those.
|
|
42
|
+
export function citedPath(span, topLevelEntries) {
|
|
43
|
+
if (!span.includes("/") || !CITED_PATH.test(span)) return null;
|
|
44
|
+
const path = span.replace(/^\.?\//, "");
|
|
45
|
+
return topLevelEntries.has(path.split("/")[0]) ? path : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Walk a Markdown document line by line, skipping fenced code, where a link is an example. */
|
|
49
|
+
export function* proseLines(markdown) {
|
|
50
|
+
let fenced = false;
|
|
51
|
+
let number = 0;
|
|
52
|
+
for (const line of markdown.split(/\r?\n/)) {
|
|
53
|
+
number += 1;
|
|
54
|
+
if (FENCE.test(line)) {
|
|
55
|
+
fenced = !fenced;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (!fenced) yield { number, line };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function scan({ files, entryDocs, read, exists, topLevelEntries }) {
|
|
63
|
+
const brokenLinks = [];
|
|
64
|
+
const missingPaths = [];
|
|
65
|
+
const seenCitations = new Set();
|
|
66
|
+
|
|
67
|
+
for (const file of files) {
|
|
68
|
+
const isEntry = entryDocs.includes(file);
|
|
69
|
+
for (const { number, line } of proseLines(read(file))) {
|
|
70
|
+
const withoutCode = line.replace(CODE_SPAN, "");
|
|
71
|
+
for (const pattern of [INLINE_LINK, IMAGE_LINK]) {
|
|
72
|
+
for (const [, href] of withoutCode.matchAll(pattern)) {
|
|
73
|
+
const target = linkTarget(file, href);
|
|
74
|
+
if (target !== null && !exists(target)) brokenLinks.push({ file, line: number, target: href });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (!isEntry) continue;
|
|
78
|
+
for (const [, span] of line.matchAll(CODE_SPAN)) {
|
|
79
|
+
const path = citedPath(span, topLevelEntries);
|
|
80
|
+
const key = `${file}:${path}`;
|
|
81
|
+
if (path === null || seenCitations.has(key) || exists(path)) continue;
|
|
82
|
+
seenCitations.add(key);
|
|
83
|
+
missingPaths.push({ file, line: number, path });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { brokenLinks, missingPaths };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function main() {
|
|
92
|
+
const entryDocs = splitList(process.env.ENTRY_DOCS);
|
|
93
|
+
const exclude = splitList(process.env.EXCLUDE);
|
|
94
|
+
const outputPath = process.env.OUTPUT_PATH ?? "/tmp/gh-aw/agent/doc-drift.json";
|
|
95
|
+
|
|
96
|
+
const tracked = execFileSync("git", ["ls-files", "-z"], { encoding: "utf8", maxBuffer: 1 << 28 })
|
|
97
|
+
.split("\0").filter(Boolean);
|
|
98
|
+
const files = tracked.filter((path) => path.endsWith(".md") && !isExcluded(path, exclude));
|
|
99
|
+
const trackedSet = new Set(tracked);
|
|
100
|
+
const exists = (path) => {
|
|
101
|
+
if (trackedSet.has(path)) return true;
|
|
102
|
+
// A directory is tracked only through its files, and a generated file not at all.
|
|
103
|
+
return existsSync(path) && (statSync(path).isDirectory() || statSync(path).isFile());
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const { brokenLinks, missingPaths } = scan({
|
|
107
|
+
files,
|
|
108
|
+
entryDocs,
|
|
109
|
+
read: (path) => readFileSync(path, "utf8"),
|
|
110
|
+
exists,
|
|
111
|
+
topLevelEntries: new Set(tracked.map((path) => path.split("/")[0])),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const report = {
|
|
115
|
+
scannedDocuments: files.length,
|
|
116
|
+
entryDocuments: entryDocs.filter((path) => trackedSet.has(path)),
|
|
117
|
+
missingEntryDocuments: entryDocs.filter((path) => !trackedSet.has(path)),
|
|
118
|
+
brokenLinkCount: brokenLinks.length,
|
|
119
|
+
missingPathCount: missingPaths.length,
|
|
120
|
+
brokenLinks: brokenLinks.slice(0, MAX_LISTED),
|
|
121
|
+
missingPaths: missingPaths.slice(0, MAX_LISTED),
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
125
|
+
writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`);
|
|
126
|
+
console.log(`Scanned ${files.length} documents: ${brokenLinks.length} broken links, ${missingPaths.length} missing cited paths.`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();
|
|
@@ -23,6 +23,14 @@ inputs:
|
|
|
23
23
|
required: false
|
|
24
24
|
default: Approved by the merge gate.
|
|
25
25
|
|
|
26
|
+
post-merge-event:
|
|
27
|
+
description: >-
|
|
28
|
+
repository_dispatch event type posted after a merge lands on the default branch. GitHub does
|
|
29
|
+
not always create a push run for a merge the App performs, so a consumer that deploys on push
|
|
30
|
+
opts into this event instead of relying on that run.
|
|
31
|
+
required: false
|
|
32
|
+
default: agentic-post-merge
|
|
33
|
+
|
|
26
34
|
outputs:
|
|
27
35
|
landed:
|
|
28
36
|
description: "'merged', 'enqueued', or empty when nothing happened."
|
|
@@ -40,4 +48,6 @@ runs:
|
|
|
40
48
|
HEAD_SHA: ${{ inputs.head-sha }}
|
|
41
49
|
APPROVE: ${{ inputs.approve }}
|
|
42
50
|
APPROVAL_BODY: ${{ inputs.approval-body }}
|
|
51
|
+
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
|
52
|
+
POST_MERGE_EVENT: ${{ inputs.post-merge-event }}
|
|
43
53
|
run: bash "${GITHUB_ACTION_PATH}/land-pull-request.sh"
|
|
@@ -13,6 +13,18 @@ set -euo pipefail
|
|
|
13
13
|
|
|
14
14
|
out() { echo "landed=$1" >> "${GITHUB_OUTPUT:-/dev/null}"; }
|
|
15
15
|
|
|
16
|
+
# GitHub does not always create a push run for a merge the App performs. A consumer that deploys on
|
|
17
|
+
# push listens for this dispatch instead, so the merge still deploys.
|
|
18
|
+
dispatch_post_merge() {
|
|
19
|
+
[ "$base" = "${DEFAULT_BRANCH:-}" ] || return 0
|
|
20
|
+
gh api -X POST "repos/$REPO/dispatches" \
|
|
21
|
+
-f event_type="${POST_MERGE_EVENT:-agentic-post-merge}" \
|
|
22
|
+
-f "client_payload[sha]=$HEAD_SHA" \
|
|
23
|
+
-f "client_payload[pr]=$PR" \
|
|
24
|
+
-f "client_payload[base]=$base" >/dev/null ||
|
|
25
|
+
echo "::warning::The merge landed, but the ${POST_MERGE_EVENT:-agentic-post-merge} dispatch failed; a consumer that deploys on it did not start."
|
|
26
|
+
}
|
|
27
|
+
|
|
16
28
|
pr_json=$(gh pr view "$PR" --repo "$REPO" --json headRefOid,baseRefName,headRefName,state)
|
|
17
29
|
state=$(jq -r .state <<<"$pr_json")
|
|
18
30
|
[ "$state" = OPEN ] || { echo "#$PR is $state; nothing to land."; out ""; exit 0; }
|
|
@@ -43,6 +55,7 @@ if [ -z "$queue" ]; then
|
|
|
43
55
|
# The branch goes as well: nothing else deletes it where `delete_branch_on_merge` is off.
|
|
44
56
|
gh api -X DELETE "repos/$REPO/git/refs/heads/$head_ref" >/dev/null 2>&1 || true
|
|
45
57
|
echo "Merged #$PR into $base."
|
|
58
|
+
dispatch_post_merge
|
|
46
59
|
out merged
|
|
47
60
|
exit 0
|
|
48
61
|
fi
|
|
@@ -87,6 +100,7 @@ case "$status" in
|
|
|
87
100
|
;;
|
|
88
101
|
merged)
|
|
89
102
|
echo "#$PR merged into $base."
|
|
103
|
+
dispatch_post_merge
|
|
90
104
|
out merged
|
|
91
105
|
;;
|
|
92
106
|
*)
|
|
@@ -108,7 +108,7 @@ runs:
|
|
|
108
108
|
'release-tag.yml',
|
|
109
109
|
'agentics-checks.yml',
|
|
110
110
|
'agentics-maintenance.yml',
|
|
111
|
-
...['refine', 'implement', 'triage', 'apply-review', 'merge-gate', 'audit', 'release']
|
|
111
|
+
...['refine', 'implement', 'triage', 'apply-review', 'merge-gate', 'audit', 'docs-audit', 'release']
|
|
112
112
|
.map((route) => `agent-${route}.lock.yml`),
|
|
113
113
|
]);
|
|
114
114
|
const ownedName = (path) => (path ?? '').split('/').pop() ?? '';
|
|
@@ -26,7 +26,8 @@ router_env() {
|
|
|
26
26
|
sed -n "s/^ $1: *//p" "$ROUTER_YML" | head -1 | sed -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'$/\1/"
|
|
27
27
|
}
|
|
28
28
|
AUDIT_CRON="$(router_env AUDIT_CRON)"
|
|
29
|
-
|
|
29
|
+
DOCS_AUDIT_CRON="$(router_env DOCS_AUDIT_CRON)"
|
|
30
|
+
export AUDIT_CRON DOCS_AUDIT_CRON
|
|
30
31
|
|
|
31
32
|
# shellcheck source-path=SCRIPTDIR
|
|
32
33
|
# shellcheck source=../classify-route/classify-route.sh
|
|
@@ -34,7 +35,7 @@ source "${HERE}/../classify-route/classify-route.sh"
|
|
|
34
35
|
|
|
35
36
|
# Every worker route the package knows. The ones with a worker file here are installed; the
|
|
36
37
|
# others must have no job in this router. Plumbing routes are in every router.
|
|
37
|
-
ALL_WORKER_ROUTES=(refine implement triage apply-review merge-gate audit release)
|
|
38
|
+
ALL_WORKER_ROUTES=(refine implement triage apply-review merge-gate audit docs-audit release)
|
|
38
39
|
PLUMBING_ROUTES=(bot-approve evidence audit-close cleanup-artifacts reconcile-bot-pr-runs housekeeping validate)
|
|
39
40
|
|
|
40
41
|
worker_installed() {
|
|
@@ -285,6 +286,13 @@ assert "a scheduled audit reports its trigger kind" scheduled \
|
|
|
285
286
|
assert "a dispatched audit reports its trigger kind" manual \
|
|
286
287
|
"$(route_field trigger-kind EVENT=workflow_dispatch OPERATION=audit INPUT_TRIGGER_KIND=manual)"
|
|
287
288
|
|
|
289
|
+
assert_route "the docs audit slot reaches its route" docs-audit \
|
|
290
|
+
EVENT=schedule "SCHEDULE=${DOCS_AUDIT_CRON:-47 2 1,15 * *}"
|
|
291
|
+
assert_route "a dispatched docs audit reaches its route" docs-audit \
|
|
292
|
+
EVENT=workflow_dispatch OPERATION=docs-audit
|
|
293
|
+
assert "a scheduled docs audit reports its trigger kind, or its cheap exit never fires" scheduled \
|
|
294
|
+
"$(route_field trigger-kind EVENT=schedule "SCHEDULE=${DOCS_AUDIT_CRON:-47 2 1,15 * *}")"
|
|
295
|
+
|
|
288
296
|
echo "── Prompt hygiene ────────────────────────────────────────────────────────"
|
|
289
297
|
|
|
290
298
|
# Everything below a worker's frontmatter is the prompt. Three things must not be in one.
|
|
@@ -368,6 +376,15 @@ if worker_installed audit; then
|
|
|
368
376
|
echo "FAIL: the audit slot cron is '${cron_line}' but env.AUDIT_CRON is '${AUDIT_CRON}'" >&2
|
|
369
377
|
fi
|
|
370
378
|
fi
|
|
379
|
+
if worker_installed docs-audit; then
|
|
380
|
+
cron_line="$(sed -n 's/^ *- cron: "\([^"]*\)" # docs-audit slot.*/\1/p' "$ROUTER_YML" | head -1)"
|
|
381
|
+
if [ -z "$DOCS_AUDIT_CRON" ]; then
|
|
382
|
+
MIRROR_OK=0; echo "FAIL: work-router.yml defines no DOCS_AUDIT_CRON in its env: block" >&2
|
|
383
|
+
elif [ "$DOCS_AUDIT_CRON" != "$cron_line" ]; then
|
|
384
|
+
MIRROR_OK=0
|
|
385
|
+
echo "FAIL: the docs-audit slot cron is '${cron_line}' but env.DOCS_AUDIT_CRON is '${DOCS_AUDIT_CRON}'" >&2
|
|
386
|
+
fi
|
|
387
|
+
fi
|
|
371
388
|
# And nothing may go back to naming the CI workflow directly: a second literal is a second
|
|
372
389
|
# thing to keep in step, and the one that gets forgotten is the one inside a jq filter.
|
|
373
390
|
if [ "$(count -c '"App: CI"' "$ROUTER_YML")" -gt 2 ]; then
|
|
@@ -1317,6 +1334,19 @@ if [ -f "$AUDIT_CLOSE_YML" ] && worker_installed audit; then
|
|
|
1317
1334
|
echo "FAIL: the audit backpressure query and audit-close disagree about stale-audit; abandoned reports would block every future audit" >&2
|
|
1318
1335
|
fi
|
|
1319
1336
|
|
|
1337
|
+
# The docs audit shares the chain, so it has the same two ways to go quiet: audit-close stops
|
|
1338
|
+
# listing its reports, or its backpressure stops excluding the ones marked stale.
|
|
1339
|
+
DOCS_AUDIT_WORKER_MD="${WORKFLOWS_DIR}/agent-docs-audit.md"
|
|
1340
|
+
if [ -f "$DOCS_AUDIT_WORKER_MD" ]; then
|
|
1341
|
+
docs_query="$(sed -n 's/^ *query: *"\(.*\)" *$/\1/p' "$DOCS_AUDIT_WORKER_MD" | head -1)"
|
|
1342
|
+
if [[ "$docs_query" == *label:docs-audit*-label:stale-audit* ]] && grep -qF "'docs-audit'" "$AUDIT_CLOSE_YML"; then
|
|
1343
|
+
PASS=$((PASS + 1))
|
|
1344
|
+
else
|
|
1345
|
+
AC_OK=0
|
|
1346
|
+
echo "FAIL: audit-close and the docs-audit backpressure disagree; abandoned reports would stop every future docs audit" >&2
|
|
1347
|
+
fi
|
|
1348
|
+
fi
|
|
1349
|
+
|
|
1320
1350
|
if [ "$AC_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
|
|
1321
1351
|
fi
|
|
1322
1352
|
|
|
@@ -1822,7 +1852,7 @@ LEAD_OK=1
|
|
|
1822
1852
|
# A consumer has it at its root; this package keeps the template beside the workflows.
|
|
1823
1853
|
CI_JSON="${HERE}/../../../opencode.ci.json"
|
|
1824
1854
|
[ -f "$CI_JSON" ] || CI_JSON="${HERE}/../../templates/opencode/opencode.ci.json"
|
|
1825
|
-
for route in refine implement triage apply-review merge-gate audit release; do
|
|
1855
|
+
for route in refine implement triage apply-review merge-gate audit docs-audit release; do
|
|
1826
1856
|
worker_md="${WORKFLOWS_DIR}/agent-${route}.md"
|
|
1827
1857
|
[ -f "$worker_md" ] || continue
|
|
1828
1858
|
lead=$(sed -n 's/^ AGENT_LEAD_MODEL: "\(.*\)"$/\1/p' "$worker_md")
|
|
@@ -1853,7 +1883,7 @@ PLUGINS_OK=1
|
|
|
1853
1883
|
if ! grep -qF "(cd .opencode && npm install" "${WORKFLOWS_DIR}/shared/opencode-ci.md"; then
|
|
1854
1884
|
PLUGINS_OK=0; echo "FAIL: the CI setup must install .opencode's dependencies or the harness plugins never load" >&2
|
|
1855
1885
|
fi
|
|
1856
|
-
for route in refine implement triage apply-review merge-gate audit release; do
|
|
1886
|
+
for route in refine implement triage apply-review merge-gate audit docs-audit release; do
|
|
1857
1887
|
worker_md="${WORKFLOWS_DIR}/agent-${route}.md"
|
|
1858
1888
|
[ -f "$worker_md" ] || continue
|
|
1859
1889
|
tier=$(sed -n 's/^ AGENT_TIER_MODEL: "\(.*\)"$/\1/p' "$worker_md")
|
|
@@ -2281,7 +2311,7 @@ if [ -f "$ERROR_REPORT_YML" ]; then
|
|
|
2281
2311
|
# The worker names are built from a route list by a template literal, so expand that list
|
|
2282
2312
|
# the same way rather than looking for filenames the file never spells out.
|
|
2283
2313
|
sed -n "/const OWNED = new Set(\[/,/^ \]);\$/p" "$ERROR_REPORT_YML" |
|
|
2284
|
-
grep -oE "'(refine|implement|triage|apply-review|merge-gate|audit|release)'" | tr -d "'" |
|
|
2314
|
+
grep -oE "'(refine|implement|triage|apply-review|merge-gate|audit|docs-audit|release)'" | tr -d "'" |
|
|
2285
2315
|
sed 's|^|agent-|; s|$|.lock.yml|' || true
|
|
2286
2316
|
} | sort -u )
|
|
2287
2317
|
shipped=$( {
|
|
@@ -2942,6 +2972,7 @@ fi
|
|
|
2942
2972
|
ONE_AT_A_TIME_OK=1
|
|
2943
2973
|
grep -qF 'The next landing releases the next one."' "${WORKFLOWS_DIR}/work-router.yml" ||
|
|
2944
2974
|
{ ONE_AT_A_TIME_OK=0; echo "FAIL: land-stack-child releases every free sibling at once" >&2; }
|
|
2975
|
+
# shellcheck disable=SC2015
|
|
2945
2976
|
grep -qF 'echo "Released #$child of feature #$feature."' "${WORKFLOWS_DIR}/work-router.yml" &&
|
|
2946
2977
|
grep -A1 -F 'echo "Released #$child of feature #$feature."' "${WORKFLOWS_DIR}/work-router.yml" | grep -qE '^\s+break$' ||
|
|
2947
2978
|
{ ONE_AT_A_TIME_OK=0; echo "FAIL: stack-landed releases every free child of the next feature at once" >&2; }
|
package/loops/docs/diagrams.md
CHANGED
|
@@ -48,6 +48,7 @@ flowchart TD
|
|
|
48
48
|
work -->|apply-review| wReview
|
|
49
49
|
work -->|merge-gate| wGate
|
|
50
50
|
work -->|audit| wAudit
|
|
51
|
+
work -->|docs-audit| wDocs
|
|
51
52
|
work -->|release| wRelease
|
|
52
53
|
triageOnly("dispatch-triage<br/>re-enters as the App, so the<br/>worker sees a trusted actor") --> wTriage
|
|
53
54
|
wRefine("agent-refine")
|
|
@@ -55,6 +56,7 @@ flowchart TD
|
|
|
55
56
|
wReview("agent-apply-review")
|
|
56
57
|
wGate("agent-merge-gate<br/>one lock for the whole repo")
|
|
57
58
|
wAudit("agent-audit")
|
|
59
|
+
wDocs("agent-docs-audit")
|
|
58
60
|
wRelease("agent-release")
|
|
59
61
|
wTriage("agent-triage")
|
|
60
62
|
classify -.->|"cron or dispatch"| plumbing
|
|
@@ -487,8 +489,7 @@ before anything changes. Applying them one at a time produces contradictory comm
|
|
|
487
489
|
|
|
488
490
|
## agent-audit.md
|
|
489
491
|
|
|
490
|
-
|
|
491
|
-
changes nothing.
|
|
492
|
+
Creates work rather than consuming it. Read-only: it files an issue and changes nothing.
|
|
492
493
|
|
|
493
494
|
```mermaid
|
|
494
495
|
flowchart TD
|
|
@@ -528,6 +529,48 @@ reads them next time.
|
|
|
528
529
|
|
|
529
530
|
---
|
|
530
531
|
|
|
532
|
+
## agent-docs-audit.md
|
|
533
|
+
|
|
534
|
+
The audit's documentation sibling. Read-only, optional, and most of its work happens before the
|
|
535
|
+
model starts: a repository with no recent commits never reaches it, and the facts a script can
|
|
536
|
+
establish are on disk when it does.
|
|
537
|
+
|
|
538
|
+
```mermaid
|
|
539
|
+
flowchart TD
|
|
540
|
+
daStart("Work Router<br/>docs-audit route<br/>days 1 and 15, or dispatch") --> daBack
|
|
541
|
+
daBack{"Backpressure (rung 1)<br/>No open report?"}
|
|
542
|
+
daBack -.->|no| daIdle
|
|
543
|
+
daBack -->|yes| daMoved
|
|
544
|
+
daMoved{"Commits in 17 days,<br/>or a dispatch? (rung 1)"}
|
|
545
|
+
daMoved -.->|no| daIdle
|
|
546
|
+
daMoved -->|yes| daFacts
|
|
547
|
+
daFacts("Facts (rung 3)<br/>Broken links, missing paths,<br/>open issues, the standard") --> daRun
|
|
548
|
+
daRun("Compare (rung 5)<br/>Entry documents against<br/>the code and the standard") --> daDedupe
|
|
549
|
+
daDedupe{"New finding, proven<br/>on both sides?"}
|
|
550
|
+
daDedupe -.->|"none left"| daQuiet
|
|
551
|
+
daDedupe -->|"some"| daFile
|
|
552
|
+
daFile("One issue with<br/>evidence per finding") --> daReport
|
|
553
|
+
daReport(("Filed<br/>labelled docs-audit and refine,<br/>sized and split per finding"))
|
|
554
|
+
daQuiet(("Nothing to file<br/>the documentation is current"))
|
|
555
|
+
daIdle(("Idle<br/>no agent, no spend"))
|
|
556
|
+
|
|
557
|
+
classDef start fill:#ffffff,stroke:#172033,stroke-width:2px,color:#172033
|
|
558
|
+
classDef action fill:#eef0ff,stroke:#554cff,stroke-width:2px,color:#172033
|
|
559
|
+
classDef decision fill:#fff8e8,stroke:#c75b00,stroke-width:2px,color:#172033
|
|
560
|
+
classDef idle fill:#202c40,stroke:#738198,stroke-width:2px,color:#ffffff
|
|
561
|
+
classDef success fill:#e8f8ec,stroke:#18883c,stroke-width:2px,color:#145a32
|
|
562
|
+
class daStart start
|
|
563
|
+
class daFacts,daRun,daFile action
|
|
564
|
+
class daBack,daMoved,daDedupe decision
|
|
565
|
+
class daIdle idle
|
|
566
|
+
class daReport,daQuiet success
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
Reports join the audit chain: `audit-close` closes one when its children are closed, and marks it
|
|
570
|
+
`stale-audit` when nobody acted on it, which frees the slot.
|
|
571
|
+
|
|
572
|
+
---
|
|
573
|
+
|
|
531
574
|
## agent-release.md
|
|
532
575
|
|
|
533
576
|
Manual dispatch only. The agent writes prose; deterministic jobs do everything irreversible, and
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
---
|
|
2
|
+
# Managed by @plainconceptsplatform/workflows. Source: loops/workflows/agent-docs-audit.md. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
3
|
+
env:
|
|
4
|
+
# The model this worker's own session runs on. See agent-audit.md for why it names one.
|
|
5
|
+
AGENT_LEAD_MODEL: "forge/glm-5-2"
|
|
6
|
+
# Every harness tier on one model the CI's Forge account is granted; see agent-audit.md.
|
|
7
|
+
AGENT_TIER_MODEL: "plainconcepts/glm-5-2"
|
|
8
|
+
AGENT_FAST_MODEL: "plainconcepts/glm-5-2"
|
|
9
|
+
REPO_RULES: "Read-only documentation audit. Report only stale documentation you can prove with evidence from both the document and the code. Do not modify files, commit, push, or run write operations."
|
|
10
|
+
# What this repository counts as its entry documents, and what the scan leaves out. Archives,
|
|
11
|
+
# parked material and generated documents belong in the exclusions: a broken link in a record
|
|
12
|
+
# of the past is not drift.
|
|
13
|
+
DOCS_AUDIT_ENTRY_DOCS: "README.md,ARCHITECTURE.md,AGENTS.md,CLAUDE.md,CONTRIBUTING.md,docs/README.md"
|
|
14
|
+
DOCS_AUDIT_EXCLUDE: ".github/workflows/,.agents/,.claude/,.opencode/,node_modules/"
|
|
15
|
+
DOCS_AUDIT_FOCUS: "Components, modules or bounded contexts the documents list against the ones that exist. Counts that no longer hold. Infrastructure, hosting or tooling that was retired but is still described. Workflows, scripts and commands that no longer exist. Decisions superseded in practice that still read as current. Two documents stating the same fact differently."
|
|
16
|
+
# The Platform documentation standard, read before the agent starts. A repository that keeps
|
|
17
|
+
# its own may point this elsewhere. Empty skips the structure check.
|
|
18
|
+
DOCS_STANDARD_URL: "https://raw.githubusercontent.com/PlainConceptsPlatform/foundations/main/ai/DOCUMENTATION.md"
|
|
19
|
+
# No commit in this many days means nothing can have drifted since the last run, so a scheduled
|
|
20
|
+
# run stops before the model starts. 17 is the longest gap between runs on days 1 and 15.
|
|
21
|
+
DOCS_AUDIT_QUIET_DAYS: "17"
|
|
22
|
+
GIT_AUTHOR_NAME: "github-actions[bot]"
|
|
23
|
+
GIT_AUTHOR_EMAIL: "github-actions[bot]@users.noreply.github.com"
|
|
24
|
+
GIT_COMMITTER_NAME: "github-actions[bot]"
|
|
25
|
+
GIT_COMMITTER_EMAIL: "github-actions[bot]@users.noreply.github.com"
|
|
26
|
+
description: |
|
|
27
|
+
Read-only documentation audit. Checks that the repository's documentation still matches its
|
|
28
|
+
code and the Platform documentation standard. When it does not, files a single issue of
|
|
29
|
+
findings with evidence from both sides, labelled `docs-audit` + `refine`, so Refine sizes it
|
|
30
|
+
and splits it into one issue per finding. When the documentation is current, files nothing.
|
|
31
|
+
|
|
32
|
+
Router-only worker: triggered exclusively via workflow_call from work-router.yml.
|
|
33
|
+
Contract input: trigger-kind(scheduled|manual).
|
|
34
|
+
The router owns the fortnightly schedule and manual dispatch.
|
|
35
|
+
|
|
36
|
+
The `skip-if-match` below is backpressure: one unactioned report is enough, because a second
|
|
37
|
+
one would mostly restate the first.
|
|
38
|
+
|
|
39
|
+
name: "Agent: Docs Audit"
|
|
40
|
+
|
|
41
|
+
# Shared: network policy only. This workflow owns its Safe Outputs and OpenCode configuration.
|
|
42
|
+
# permissions, engine, model and runs-on cannot be shared , see shared/platform-defaults.md.
|
|
43
|
+
imports:
|
|
44
|
+
- github/gh-aw/.github/workflows/shared/opencode.md@v0.87.5
|
|
45
|
+
- shared/platform-defaults.md
|
|
46
|
+
- shared/opencode-ci.md
|
|
47
|
+
|
|
48
|
+
on:
|
|
49
|
+
workflow_call:
|
|
50
|
+
inputs:
|
|
51
|
+
trigger-kind:
|
|
52
|
+
description: "Docs audit trigger: scheduled or manual"
|
|
53
|
+
required: false
|
|
54
|
+
type: string
|
|
55
|
+
default: manual
|
|
56
|
+
|
|
57
|
+
# Rung 1. `-label:stale-audit` keeps this from becoming a stop: audit-close labels a report
|
|
58
|
+
# nobody actioned, which frees the slot and lists it in the "Needs a human" digest. Without it
|
|
59
|
+
# one abandoned report would disable this worker for good, with a green run every fortnight.
|
|
60
|
+
skip-if-match:
|
|
61
|
+
query: "is:issue is:open label:docs-audit -label:stale-audit"
|
|
62
|
+
max: 1
|
|
63
|
+
|
|
64
|
+
# gh-aw folds the top-level `if:` into activation without depending on the job it reads, so
|
|
65
|
+
# the reference would resolve to '' and the agent would never run.
|
|
66
|
+
needs: [recent_commits]
|
|
67
|
+
|
|
68
|
+
if: needs.recent_commits.outputs.changed == 'true'
|
|
69
|
+
|
|
70
|
+
runs-on: agents-arc
|
|
71
|
+
runs-on-slim: agents-arc
|
|
72
|
+
|
|
73
|
+
secrets:
|
|
74
|
+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
|
75
|
+
|
|
76
|
+
engine:
|
|
77
|
+
id: opencode
|
|
78
|
+
version: "1.2.14"
|
|
79
|
+
env:
|
|
80
|
+
OPENAI_BASE_URL: https://forge.plainconcepts.com/v1
|
|
81
|
+
|
|
82
|
+
model: openai/glm-5-2
|
|
83
|
+
|
|
84
|
+
max-turns: 150
|
|
85
|
+
max-turn-cache-misses: 1500
|
|
86
|
+
max-ai-credits: 2500
|
|
87
|
+
|
|
88
|
+
permissions: read-all
|
|
89
|
+
|
|
90
|
+
steps:
|
|
91
|
+
- name: List what is already tracked
|
|
92
|
+
uses: ./.github/actions/list-open-issues
|
|
93
|
+
with:
|
|
94
|
+
token: ${{ github.token }}
|
|
95
|
+
repo: ${{ github.repository }}
|
|
96
|
+
- name: Collect documentation drift
|
|
97
|
+
uses: ./.github/actions/collect-doc-drift
|
|
98
|
+
with:
|
|
99
|
+
entry-docs: ${{ env.DOCS_AUDIT_ENTRY_DOCS }}
|
|
100
|
+
exclude: ${{ env.DOCS_AUDIT_EXCLUDE }}
|
|
101
|
+
# Fetched here rather than by the agent, whose network is closed. A standard that cannot be
|
|
102
|
+
# read costs the structure check, not the run: the prompt says to report it.
|
|
103
|
+
- name: Fetch the documentation standard
|
|
104
|
+
env:
|
|
105
|
+
STANDARD_PATH: /tmp/gh-aw/agent/documentation-standard.md
|
|
106
|
+
run: |
|
|
107
|
+
set -uo pipefail
|
|
108
|
+
mkdir -p "$(dirname "$STANDARD_PATH")"
|
|
109
|
+
if [ -n "$DOCS_STANDARD_URL" ] && curl -fsSL --max-time 30 "$DOCS_STANDARD_URL" -o "$STANDARD_PATH"; then
|
|
110
|
+
echo "Read the documentation standard from $DOCS_STANDARD_URL"
|
|
111
|
+
else
|
|
112
|
+
rm -f "$STANDARD_PATH"
|
|
113
|
+
echo "::warning::No documentation standard could be read from '${DOCS_STANDARD_URL}'. The audit runs without its structure check."
|
|
114
|
+
fi
|
|
115
|
+
|
|
116
|
+
jobs:
|
|
117
|
+
# The cheap exit. A repository with no commits since the last run cannot have drifted, and a
|
|
118
|
+
# full agent run to say so would hold half of a two-machine fleet. A dispatch always runs: a
|
|
119
|
+
# person asking for an audit has a reason.
|
|
120
|
+
recent_commits:
|
|
121
|
+
runs-on: agents-arc
|
|
122
|
+
timeout-minutes: 5
|
|
123
|
+
permissions:
|
|
124
|
+
contents: read
|
|
125
|
+
outputs:
|
|
126
|
+
changed: ${{ steps.commits.outputs.changed }}
|
|
127
|
+
steps:
|
|
128
|
+
- name: Count recent commits on the default branch
|
|
129
|
+
id: commits
|
|
130
|
+
env:
|
|
131
|
+
GH_TOKEN: ${{ github.token }}
|
|
132
|
+
REPO: ${{ github.repository }}
|
|
133
|
+
TRIGGER_KIND: ${{ inputs.trigger-kind }}
|
|
134
|
+
QUIET_DAYS: ${{ env.DOCS_AUDIT_QUIET_DAYS }}
|
|
135
|
+
run: |
|
|
136
|
+
set -uo pipefail
|
|
137
|
+
if [ "$TRIGGER_KIND" != "scheduled" ]; then
|
|
138
|
+
echo "changed=true" >> "$GITHUB_OUTPUT"
|
|
139
|
+
exit 0
|
|
140
|
+
fi
|
|
141
|
+
since="$(date -u -d "${QUIET_DAYS} days ago" +%Y-%m-%dT%H:%M:%SZ)"
|
|
142
|
+
# An unanswered API call must not read as "nothing changed": that is a green run that
|
|
143
|
+
# audited nothing. Run the audit instead.
|
|
144
|
+
if ! commits="$(gh api "repos/${REPO}/commits?since=${since}&per_page=1" --jq 'length')"; then
|
|
145
|
+
echo "::warning::Could not list recent commits; running the audit anyway."
|
|
146
|
+
commits=1
|
|
147
|
+
fi
|
|
148
|
+
if [ "$commits" = "0" ]; then
|
|
149
|
+
echo "No commits since ${since}: nothing can have drifted, so the agent does not run."
|
|
150
|
+
echo "changed=false" >> "$GITHUB_OUTPUT"
|
|
151
|
+
else
|
|
152
|
+
echo "changed=true" >> "$GITHUB_OUTPUT"
|
|
153
|
+
fi
|
|
154
|
+
|
|
155
|
+
conclude:
|
|
156
|
+
needs: [activation, agent, safe_outputs]
|
|
157
|
+
if: >
|
|
158
|
+
needs.agent.result == 'success' &&
|
|
159
|
+
needs.safe_outputs.result == 'success' &&
|
|
160
|
+
needs.safe_outputs.outputs.process_safe_outputs_processed_count != '0'
|
|
161
|
+
runs-on: agents-arc
|
|
162
|
+
permissions:
|
|
163
|
+
contents: read
|
|
164
|
+
issues: write
|
|
165
|
+
steps:
|
|
166
|
+
- name: Checkout workflow actions
|
|
167
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
168
|
+
with:
|
|
169
|
+
persist-credentials: false
|
|
170
|
+
- name: Create bot token
|
|
171
|
+
id: app-token
|
|
172
|
+
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
|
173
|
+
with:
|
|
174
|
+
client-id: ${{ secrets.BOT_APP_ID }}
|
|
175
|
+
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
176
|
+
# The safe_outputs job already created the issue. Only the labels are left, and they are
|
|
177
|
+
# applied with the App token so that `refine` reaches the router as a trusted event.
|
|
178
|
+
- name: Apply labels to the report
|
|
179
|
+
if: needs.safe_outputs.outputs.created_issue_number != ''
|
|
180
|
+
uses: ./.github/actions/add-issue-labels
|
|
181
|
+
with:
|
|
182
|
+
token: ${{ steps.app-token.outputs.token }}
|
|
183
|
+
issue-number: ${{ needs.safe_outputs.outputs.created_issue_number }}
|
|
184
|
+
labels: |
|
|
185
|
+
docs-audit
|
|
186
|
+
refine
|
|
187
|
+
|
|
188
|
+
# An agent that ended without a report or a noop would otherwise be a green run that audited
|
|
189
|
+
# nothing; see the same job in agent-audit.md.
|
|
190
|
+
empty_run:
|
|
191
|
+
needs: [agent, safe_outputs]
|
|
192
|
+
if: >
|
|
193
|
+
needs.agent.result == 'success' &&
|
|
194
|
+
needs.safe_outputs.outputs.process_safe_outputs_processed_count == '0'
|
|
195
|
+
runs-on: agents-arc
|
|
196
|
+
permissions:
|
|
197
|
+
contents: read
|
|
198
|
+
steps:
|
|
199
|
+
- name: Report a docs audit that produced no outcome
|
|
200
|
+
env:
|
|
201
|
+
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
|
202
|
+
run: |
|
|
203
|
+
set -euo pipefail
|
|
204
|
+
echo "::error::The docs audit agent finished without emitting a report or a noop. Nothing was filed and no reason was given, so this run is a failure rather than a clean audit. ${RUN_URL}"
|
|
205
|
+
exit 1
|
|
206
|
+
|
|
207
|
+
safe-outputs:
|
|
208
|
+
# A failed run is already a red run. An issue per failure buries the real backlog.
|
|
209
|
+
report-failure-as-issue: false
|
|
210
|
+
threat-detection: false
|
|
211
|
+
create-issue:
|
|
212
|
+
max: 1
|
|
213
|
+
|
|
214
|
+
# Reads documents and the code they describe, and writes one issue. Unobserved so far: audit, the
|
|
215
|
+
# nearest worker, takes about 45 minutes over a whole repository, and this one reads less.
|
|
216
|
+
timeout-minutes: 60
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
1. This is a read-only documentation audit. Do not modify any file, do not commit, and do not
|
|
220
|
+
push. Adhere to ${{ env.REPO_RULES }}.
|
|
221
|
+
|
|
222
|
+
2. Read the facts already collected for you, so you do not re-derive them:
|
|
223
|
+
- `/tmp/gh-aw/agent/doc-drift.json`: `brokenLinks` and `missingPaths`, each with its file and
|
|
224
|
+
line, plus `missingEntryDocuments`. These are established; do not check them again.
|
|
225
|
+
- `/tmp/gh-aw/agent/documentation-standard.md`: the Platform documentation standard. If this
|
|
226
|
+
file does not exist, skip step 4 and say so in one line of the report.
|
|
227
|
+
- `/tmp/gh-aw/agent/open-issues.json`: every open issue with its title and labels.
|
|
228
|
+
|
|
229
|
+
3. Read the entry documents in `doc-drift.json`, then compare what they state with the code.
|
|
230
|
+
Look for: ${{ env.DOCS_AUDIT_FOCUS }}
|
|
231
|
+
|
|
232
|
+
Every finding needs evidence on both sides: the document's `file:line` and what it says, and
|
|
233
|
+
the `file:line` or directory that shows otherwise. A claim you cannot back with both is not a
|
|
234
|
+
finding. Prefer what misleads a reader acting on it over what is merely imprecise, and do not
|
|
235
|
+
report style, tone or missing polish.
|
|
236
|
+
|
|
237
|
+
4. Compare the repository's documentation structure with the standard. Report a required
|
|
238
|
+
section that is missing, or that exists and is empty. A section the standard marks as not
|
|
239
|
+
applicable to this kind of repository is not a finding.
|
|
240
|
+
|
|
241
|
+
5. Group the broken links and missing paths from step 2 into findings by document or by cause,
|
|
242
|
+
such as one renamed directory, rather than one finding per link. State their counts as they
|
|
243
|
+
appear in `doc-drift.json`.
|
|
244
|
+
|
|
245
|
+
6. Discard any finding already tracked in `open-issues.json`. Exact-title duplicates are rejected
|
|
246
|
+
for you; your job is the ones worded differently that mean the same thing.
|
|
247
|
+
|
|
248
|
+
Before filing, call `memory_smart_search` for prior documentation audits of this repository
|
|
249
|
+
and drop any finding that was reported and consciously not acted on. After you have decided
|
|
250
|
+
what to file, call `memory_save` once with the date and, for each finding, its file, a
|
|
251
|
+
one-line description and its disposition. Keep it short; it is read by the next audit.
|
|
252
|
+
|
|
253
|
+
7. If no finding is left, call `noop` and stop. Filing nothing is the right outcome when the
|
|
254
|
+
documentation is current.
|
|
255
|
+
|
|
256
|
+
8. Call `create_issue` **once** with title "Docs audit: <date>". Do NOT specify labels, the
|
|
257
|
+
conclude job applies them. List every finding, the most misleading first, each with:
|
|
258
|
+
- the document's `file:line` and the stale statement;
|
|
259
|
+
- the evidence from the code, with its `file:line` or path;
|
|
260
|
+
- what the document should say, or the section that has to be added.
|
|
261
|
+
|
|
262
|
+
The issue goes to Refine, which sizes it and splits it into one issue per finding, so write
|
|
263
|
+
each finding to survive that: self-contained, naming its own files, never "as above".
|
|
264
|
+
|
|
265
|
+
Refer to people by role, never by name, and include no personal contact details, even when
|
|
266
|
+
a document you are quoting contains them.
|
|
@@ -83,6 +83,9 @@ run-name: >-
|
|
|
83
83
|
&& inputs.operation == 'audit'
|
|
84
84
|
&& format('Agent: Audit - manual{0}', '')
|
|
85
85
|
|| github.event_name == 'workflow_dispatch'
|
|
86
|
+
&& inputs.operation == 'docs-audit'
|
|
87
|
+
&& format('Agent: Docs Audit - manual{0}', '')
|
|
88
|
+
|| github.event_name == 'workflow_dispatch'
|
|
86
89
|
&& inputs.operation == 'release'
|
|
87
90
|
&& format('Agent: Release{0}', '')
|
|
88
91
|
|| github.event_name == 'workflow_dispatch'
|
|
@@ -91,6 +94,9 @@ run-name: >-
|
|
|
91
94
|
&& github.event.schedule == '17 1 * * 1'
|
|
92
95
|
&& format('Agent: Audit - weekly{0}', '')
|
|
93
96
|
|| github.event_name == 'schedule'
|
|
97
|
+
&& github.event.schedule == '47 2 1,15 * *'
|
|
98
|
+
&& format('Agent: Docs Audit - fortnightly{0}', '')
|
|
99
|
+
|| github.event_name == 'schedule'
|
|
94
100
|
&& github.event.schedule == '43 3 * * *'
|
|
95
101
|
&& format('Agentics: Audit Close - daily{0}', '')
|
|
96
102
|
|| github.event_name == 'schedule'
|
|
@@ -125,6 +131,9 @@ env:
|
|
|
125
131
|
# first and the two of them consume the whole fleet. Pick a free slot per repository: see
|
|
126
132
|
# "Scheduling across repositories" in the package README.
|
|
127
133
|
AUDIT_CRON: "17 1 * * 1"
|
|
134
|
+
# The same idea for the documentation audit, if it is installed: see the same section of the
|
|
135
|
+
# package README. Two days a month, so a slot is a pair of days rather than a weekday.
|
|
136
|
+
DOCS_AUDIT_CRON: "47 2 1,15 * *"
|
|
128
137
|
# The bot identities whose label changes and pull requests this router trusts. Space
|
|
129
138
|
# separated, and every login the same account can present: GitHub reports an App as
|
|
130
139
|
# `name[bot]` on some endpoints and `app/name` on others, so both spellings belong here.
|
|
@@ -221,6 +230,7 @@ on:
|
|
|
221
230
|
# Cron to route mapping lives in .github/actions/classify-route/classify-route.sh.
|
|
222
231
|
schedule:
|
|
223
232
|
- cron: "17 1 * * 1" # audit slot, mirrored from env.AUDIT_CRON by the installer
|
|
233
|
+
- cron: "47 2 1,15 * *" # docs-audit slot, mirrored from env.DOCS_AUDIT_CRON by the installer
|
|
224
234
|
- cron: "43 3 * * *"
|
|
225
235
|
- cron: "0 6 * * *"
|
|
226
236
|
- cron: "17 * * * *"
|
|
@@ -241,6 +251,7 @@ on:
|
|
|
241
251
|
- merge-train
|
|
242
252
|
- evidence
|
|
243
253
|
- audit
|
|
254
|
+
- docs-audit
|
|
244
255
|
- audit-close
|
|
245
256
|
- cleanup-artifacts
|
|
246
257
|
- reconcile-bot-pr-runs
|
|
@@ -684,7 +695,12 @@ jobs:
|
|
|
684
695
|
gh issue view "$feature" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null \
|
|
685
696
|
| grep -oiE 'blocked by #[0-9]+' | grep -oE '[0-9]+' || true
|
|
686
697
|
} | sort -u ); do
|
|
687
|
-
|
|
698
|
+
# A feature's own children are not what the feature waits on. Refine's tracker lists them
|
|
699
|
+
# in the feature body as "#N ... Blocked by #M", and reading those lines as the feature's
|
|
700
|
+
# blockers held every child behind its siblings, and the first one behind itself.
|
|
701
|
+
info=$(gh issue view "$b" --repo "$REPO" --json state,body 2>/dev/null) || continue
|
|
702
|
+
printf '%s' "$info" | jq -e '(.state // "" | ascii_downcase) == "open"' >/dev/null || continue
|
|
703
|
+
printf '%s' "$info" | jq -r '.body // ""' | grep -qF "<!-- split-parent: ${feature} -->" && continue
|
|
688
704
|
waiting="$waiting #$b"
|
|
689
705
|
done
|
|
690
706
|
fi
|
|
@@ -741,6 +757,7 @@ jobs:
|
|
|
741
757
|
gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --remove-label bot-working >/dev/null 2>&1 || true
|
|
742
758
|
marker='<!-- stack-held -->'
|
|
743
759
|
if ! gh api "repos/$REPO/issues/$ISSUE_NUMBER/comments?per_page=100" --jq '.[].body' 2>/dev/null | grep -qF "$marker"; then
|
|
760
|
+
# shellcheck disable=SC2016
|
|
744
761
|
printf '%s\nHeld until%s have landed on the default branch: this feature builds on them. It keeps `implement` and is released when they merge.\n' "$marker" "$waiting" > /tmp/held.md
|
|
745
762
|
gh issue comment "$ISSUE_NUMBER" --repo "$REPO" --body-file /tmp/held.md >/dev/null || true
|
|
746
763
|
fi
|
|
@@ -1148,6 +1165,24 @@ jobs:
|
|
|
1148
1165
|
BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
1149
1166
|
AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
|
|
1150
1167
|
|
|
1168
|
+
call-docs-audit:
|
|
1169
|
+
needs: classify
|
|
1170
|
+
if: needs.classify.outputs.route == 'docs-audit'
|
|
1171
|
+
uses: ./.github/workflows/agent-docs-audit.lock.yml
|
|
1172
|
+
concurrency:
|
|
1173
|
+
group: docs-audit
|
|
1174
|
+
cancel-in-progress: false
|
|
1175
|
+
permissions: write-all
|
|
1176
|
+
with:
|
|
1177
|
+
trigger-kind: ${{ needs.classify.outputs.trigger-kind }}
|
|
1178
|
+
secrets:
|
|
1179
|
+
OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
|
|
1180
|
+
CODEX_API_KEY: ${{ secrets.FORGE_API_KEY }}
|
|
1181
|
+
COPILOT_GITHUB_TOKEN: ${{ secrets.FORGE_API_KEY }}
|
|
1182
|
+
BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
|
|
1183
|
+
BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
|
|
1184
|
+
AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
|
|
1185
|
+
|
|
1151
1186
|
call-release:
|
|
1152
1187
|
needs: [classify, authorize]
|
|
1153
1188
|
if: needs.classify.outputs.route == 'release' && needs.authorize.outputs.trusted == 'true'
|
|
@@ -1298,6 +1333,7 @@ jobs:
|
|
|
1298
1333
|
# each cut from the same branch, and every landing left the others conflicting. The
|
|
1299
1334
|
# next landing releases the next child. A held child with no `Blocked by` of its
|
|
1300
1335
|
# own (held because a sibling was in flight) is free once it carries the hold marker.
|
|
1336
|
+
# shellcheck disable=SC2086
|
|
1301
1337
|
for child in $(printf '%s\n' $children | sort -n); do
|
|
1302
1338
|
[ "$child" = "$closed_issue" ] && continue
|
|
1303
1339
|
meta=$(gh issue view "$child" --repo "$REPO" --json state,labels,body 2>/dev/null || echo '{}')
|
|
@@ -1424,7 +1460,11 @@ jobs:
|
|
|
1424
1460
|
gh api "repos/$REPO/issues/$1/dependencies/blocked_by" --jq '.[] | select(.state == "open") | .number' 2>/dev/null || true
|
|
1425
1461
|
for b in $(gh issue view "$1" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null \
|
|
1426
1462
|
| grep -oiE 'blocked by #[0-9]+' | grep -oE '[0-9]+' | sort -u || true); do
|
|
1427
|
-
|
|
1463
|
+
# The feature's own children, listed in its tracker with their own "Blocked by", are not blockers.
|
|
1464
|
+
info=$(gh issue view "$b" --repo "$REPO" --json state,body 2>/dev/null) || continue
|
|
1465
|
+
printf '%s' "$info" | jq -e '(.state // "" | ascii_downcase) == "open"' >/dev/null || continue
|
|
1466
|
+
printf '%s' "$info" | jq -r '.body // ""' | grep -qF "<!-- split-parent: $1 -->" && continue
|
|
1467
|
+
echo "$b"
|
|
1428
1468
|
done
|
|
1429
1469
|
} | grep -vx "$parent" | sort -u || true
|
|
1430
1470
|
}
|
|
@@ -1476,6 +1516,7 @@ jobs:
|
|
|
1476
1516
|
fi
|
|
1477
1517
|
# One child per feature, the lowest-numbered free one; each landing on the feature branch
|
|
1478
1518
|
# releases the next (land-stack-child), so siblings never build side by side.
|
|
1519
|
+
# shellcheck disable=SC2086
|
|
1479
1520
|
for child in $(printf '%s\n' ${next_children[$feature]} | sort -n); do
|
|
1480
1521
|
own=$(gh issue view "$child" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null \
|
|
1481
1522
|
| grep -oiE 'blocked by #[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
|