@plainconceptsplatform/workflows 1.8.3 → 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 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, routeNames, templateNames, workflowRoutes } from "./workflow-catalog.js";
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 ?? routeNames;
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);
@@ -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: "${escapeRegex(cron)}"\\n`, "gm"), "");
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);
@@ -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 two literal lines that cannot read them. */
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): {
@@ -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 two
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
- /** Copy the router's own `env:` values into the two literal lines that cannot read them. */
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 {
@@ -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
- const reports = await github.paginate(github.rest.issues.listForRepo, {
25
- ...context.repo,
26
- state: 'open',
27
- labels: 'audit',
28
- per_page: 100,
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
- export AUDIT_CRON
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; }
@@ -2950,6 +2981,19 @@ grep -qF -- '--base "$STACK_BRANCH" --limit 100 --json number,body' "${WORKFLOWS
2950
2981
  grep -qF 'releasedFeatures' "${HERE}/../housekeeping/action.yml" ||
2951
2982
  { ONE_AT_A_TIME_OK=0; echo "FAIL: housekeeping releases several children of one feature in a sweep" >&2; }
2952
2983
  if [ "$ONE_AT_A_TIME_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
2984
+ # A repository variable AGENTIC_WORKFLOWS_DISABLED turns everything off: the router's two root jobs
2985
+ # (every other job needs one of them) and every workflow with a trigger of its own.
2986
+ OFF_OK=1
2987
+ for job in authorize classify; do
2988
+ sed -n "/^ ${job}:\$/,/^ runs-on:/p" "${WORKFLOWS_DIR}/work-router.yml" | grep -qF "if: vars.AGENTIC_WORKFLOWS_DISABLED == ''" ||
2989
+ { OFF_OK=0; echo "FAIL: the router's ${job} job ignores AGENTIC_WORKFLOWS_DISABLED" >&2; }
2990
+ done
2991
+ for f in "${WORKFLOWS_DIR}/authorize-bot-work.yml" "${WORKFLOWS_DIR}/release-tag.yml"; do
2992
+ [ -f "$f" ] || continue
2993
+ grep -qF "vars.AGENTIC_WORKFLOWS_DISABLED == ''" "$f" ||
2994
+ { OFF_OK=0; echo "FAIL: $(basename "$f") ignores AGENTIC_WORKFLOWS_DISABLED" >&2; }
2995
+ done
2996
+ if [ "$OFF_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
2953
2997
  # A stack child runs the feature branch's copy of the workflows, so the router brings the default
2954
2998
  # branch in before it dispatches one.
2955
2999
  if grep -qF 'bring $DEFAULT_BRANCH into $STACK_BRANCH before a child runs' "${WORKFLOWS_DIR}/work-router.yml"; then
@@ -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
- The only worker that creates work rather than consuming it. Read-only: it files an issue and
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
@@ -87,7 +87,8 @@ env:
87
87
  jobs:
88
88
  collect:
89
89
  # Fork of a consumer: not their Azure, not their issues.
90
- if: "!github.event.repository.fork"
90
+ # AGENTIC_WORKFLOWS_DISABLED, a repository variable with any value, turns the automation off.
91
+ if: "!github.event.repository.fork && vars.AGENTIC_WORKFLOWS_DISABLED == ''"
91
92
  runs-on: agents-arc
92
93
  timeout-minutes: 20
93
94
  # Load bearing, not decoration. The federated credential on the deploy identity trusts the
@@ -58,7 +58,8 @@ env:
58
58
  jobs:
59
59
  report:
60
60
  # Fork of a consumer: the fleet is not theirs and neither is the report.
61
- if: "!github.event.repository.fork"
61
+ # AGENTIC_WORKFLOWS_DISABLED, a repository variable with any value, turns the automation off.
62
+ if: "!github.event.repository.fork && vars.AGENTIC_WORKFLOWS_DISABLED == ''"
62
63
  runs-on: agents-arc
63
64
  timeout-minutes: 20
64
65
  concurrency: